internal/control/mr.go

1283 lines · 44287 bytes

   1package control
   2
   3import (
   4	"errors"
   5	"fmt"
   6	"io"
   7	"slices"
   8	"strconv"
   9	"strings"
  10	"time"
  11
  12	"gitbay.org/gitbay/internal/gitutil"
  13	"gitbay.org/gitbay/internal/policy"
  14	"gitbay.org/gitbay/internal/protocol"
  15	"gitbay.org/gitbay/internal/sig"
  16	"gitbay.org/gitbay/internal/store"
  17)
  18
  19func init() {
  20	register(Command{Path: []string{"repo", "fork"},
  21		Summary: "fork a repository under your account",
  22		Usage:   "repo fork <owner/name> [--name <n>]", Run: runRepoFork})
  23	register(Command{Path: []string{"repo", "settings", "require-approvals"},
  24		Summary: "require N fresh approvals to merge",
  25		Usage:   "repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals})
  26	register(Command{Path: []string{"repo", "settings", "require-resolved"},
  27		Summary: "require all review threads resolved to merge",
  28		Usage:   "repo settings require-resolved <owner/name> on|off", Run: runRequireResolved})
  29	register(Command{Path: []string{"repo", "settings", "require-checks"},
  30		Summary: "gate merges on green statuses",
  31		Usage:   "repo settings require-checks <owner/name> on|off", Run: runRequireChecks})
  32	register(Command{Path: []string{"repo", "settings", "require-signed"},
  33		Summary: "require verified commit signatures",
  34		Usage:   "repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
  35	register(Command{Path: []string{"mr", "create"},
  36		Summary:    "open a merge request",
  37		Usage:      "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -] [--format md|org]",
  38		ReadsStdin: true, Run: runMRCreate})
  39	register(Command{Path: []string{"mr", "list"},
  40		Summary: "list merge requests",
  41		Usage:   "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList})
  42	register(Command{Path: []string{"mr", "show"},
  43		Summary: "show a merge request",
  44		Usage:   "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
  45	register(Command{Path: []string{"mr", "diff"},
  46		Summary: "show the diff",
  47		Usage:   "mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
  48	register(Command{Path: []string{"mr", "edit"},
  49		Summary:    "edit title or body",
  50		Usage:      "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
  51		ReadsStdin: true, Run: runMREdit})
  52	register(Command{Path: []string{"mr", "retarget"},
  53		Summary: "retarget onto another branch",
  54		Usage:   "mr retarget <owner/name> <n> <branch>", Run: runMRRetarget})
  55	register(Command{Path: []string{"mr", "comment"},
  56		Summary:    "comment",
  57		Usage:      "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
  58		ReadsStdin: true, Run: runMRComment})
  59	register(Command{Path: []string{"mr", "review"},
  60		Summary: "review",
  61		Usage:   "mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
  62	register(Command{Path: []string{"mr", "merge"},
  63		Summary: "merge",
  64		Usage:   "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
  65	register(Command{Path: []string{"mr", "close"},
  66		Summary: "close without merging",
  67		Usage:   "mr close <owner/name> <n>", Run: runMRClose})
  68}
  69
  70func runRepoFork(c *Ctx, args []string) int {
  71	var path, name string
  72	for i := 0; i < len(args); i++ {
  73		switch args[i] {
  74		case "--name":
  75			if i+1 >= len(args) {
  76				return c.fail(protocol.ExitUsage, "--name requires a value")
  77			}
  78			name = args[i+1]
  79			i++
  80		default:
  81			if path != "" {
  82				return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
  83			}
  84			path = args[i]
  85		}
  86	}
  87	if path == "" {
  88		return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
  89	}
  90	src, code := resolveRepo(c, path, policy.CanRead)
  91	if code >= 0 {
  92		return code
  93	}
  94	if name == "" {
  95		name = src.Name
  96	}
  97	if err := policy.ValidateName(name); err != nil {
  98		return c.fail(protocol.ExitUsage, "%v", err)
  99	}
 100	if code := checkRepoQuota(c); code >= 0 {
 101		return code
 102	}
 103	id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
 104	if err != nil {
 105		return c.fail(protocol.ExitFailure, "%v", err)
 106	}
 107	if err := c.Store.SetForkOf(id, src.ID); err != nil {
 108		return c.fail(protocol.ExitFailure, "%v", err)
 109	}
 110	dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name)
 111	srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
 112	if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
 113		c.Store.DeleteRepo(id)
 114		return c.fail(protocol.ExitFailure, "%v", err)
 115	}
 116	if desc := gitutil.ReadDescription(srcDir); desc != "" {
 117		gitutil.WriteDescription(dstDir, desc)
 118	}
 119	if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
 120		// Empty source repos have nothing to fetch; that is fine.
 121		if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
 122			c.Store.DeleteRepo(id)
 123			return c.fail(protocol.ExitFailure, "copying refs: %v", err)
 124		}
 125	}
 126	forkPath := c.User.Username + "/" + name
 127	return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) {
 128		fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
 129	})
 130}
 131
 132func runRequireApprovals(c *Ctx, args []string) int {
 133	if len(args) != 2 {
 134		return c.fail(protocol.ExitUsage, "usage: repo settings require-approvals <owner/name> <n>")
 135	}
 136	n, err := strconv.Atoi(args[1])
 137	if err != nil || n < 0 || n > 20 {
 138		return c.fail(protocol.ExitUsage, "approvals must be 0..20")
 139	}
 140	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 141	if code >= 0 {
 142		return code
 143	}
 144	s := repo.Settings
 145	s.RequireApprovals = n
 146	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
 147		return c.fail(protocol.ExitFailure, "%v", err)
 148	}
 149	return c.emit(s, func(w io.Writer) {
 150		fmt.Fprintf(w, "require_approvals %d on %s\n", n, repo.Path())
 151	})
 152}
 153
 154func runRequireResolved(c *Ctx, args []string) int {
 155	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 156		return c.fail(protocol.ExitUsage, "usage: repo settings require-resolved <owner/name> on|off")
 157	}
 158	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 159	if code >= 0 {
 160		return code
 161	}
 162	s := repo.Settings
 163	s.RequireResolved = args[1] == "on"
 164	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
 165		return c.fail(protocol.ExitFailure, "%v", err)
 166	}
 167	return c.emit(s, func(w io.Writer) {
 168		fmt.Fprintf(w, "require_resolved %s on %s\n", args[1], repo.Path())
 169	})
 170}
 171
 172func runRequireChecks(c *Ctx, args []string) int {
 173	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 174		return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off")
 175	}
 176	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 177	if code >= 0 {
 178		return code
 179	}
 180	s := repo.Settings
 181	s.RequireChecks = args[1] == "on"
 182	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
 183		return c.fail(protocol.ExitFailure, "%v", err)
 184	}
 185	return c.emit(s, func(w io.Writer) {
 186		fmt.Fprintf(w, "require_checks %s on %s\n", args[1], repo.Path())
 187	})
 188}
 189
 190func runRequireSigned(c *Ctx, args []string) int {
 191	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 192		return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off")
 193	}
 194	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 195	if code >= 0 {
 196		return code
 197	}
 198	s := repo.Settings
 199	s.RequireSignedCommits = args[1] == "on"
 200	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
 201		return c.fail(protocol.ExitFailure, "%v", err)
 202	}
 203	return c.emit(s, func(w io.Writer) {
 204		fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path())
 205	})
 206}
 207
 208// mrRef parses "<owner/name> <n>" and loads the MR.
 209func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) {
 210	if len(args) < 2 {
 211		return store.Repo{}, store.MR{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
 212	}
 213	repo, code := resolveRepo(c, args[0], perm)
 214	if code >= 0 {
 215		return repo, store.MR{}, code
 216	}
 217	n, err := strconv.ParseInt(args[1], 10, 64)
 218	if err != nil {
 219		return repo, store.MR{}, c.fail(protocol.ExitUsage, "bad MR number %q", args[1])
 220	}
 221	mr, err := c.Store.MRByNumber(repo.ID, n)
 222	if errors.Is(err, store.ErrNotFound) {
 223		return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
 224	}
 225	if err != nil {
 226		return repo, mr, c.fail(protocol.ExitFailure, "%v", err)
 227	}
 228	return repo, mr, -1
 229}
 230
 231func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
 232
 233func runMRCreate(c *Ctx, args []string) int {
 234	var path, source, target, title, body, file, format string
 235	for i := 0; i < len(args); i++ {
 236		switch args[i] {
 237		case "--source", "--target", "--title", "--body", "--file", "--format":
 238			if i+1 >= len(args) {
 239				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 240			}
 241			v := args[i+1]
 242			switch args[i] {
 243			case "--source":
 244				source = v
 245			case "--target":
 246				target = v
 247			case "--title":
 248				title = v
 249			case "--body":
 250				body = v
 251			case "--file":
 252				file = v
 253			case "--format":
 254				format = v
 255			}
 256			i++
 257		default:
 258			if path != "" {
 259				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
 260			}
 261			path = args[i]
 262		}
 263	}
 264	if path == "" || source == "" || title == "" {
 265		return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
 266	}
 267	fmtName, err := markupFormat(format)
 268	if err != nil {
 269		return c.fail(protocol.ExitUsage, "%v", err)
 270	}
 271	if fmtName == "" {
 272		fmtName = "md"
 273	}
 274	repo, code := resolveRepo(c, path, policy.CanRead)
 275	if code >= 0 {
 276		return code
 277	}
 278	if code := refuseArchived(c, repo); code >= 0 {
 279		return code
 280	}
 281	if target == "" {
 282		target = repo.DefaultBranch
 283	}
 284
 285	// Source is "branch" (same repo) or "owner/name:branch" (a fork).
 286	srcRepo := repo
 287	srcBranch := source
 288	if sp, br, ok := strings.Cut(source, ":"); ok {
 289		srcBranch = br
 290		var scode int
 291		srcRepo, scode = resolveRepo(c, sp, policy.CanRead)
 292		if scode >= 0 {
 293			return scode
 294		}
 295		if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID {
 296			return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path())
 297		}
 298	}
 299	srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
 300	headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch)
 301	if err != nil {
 302		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path())
 303	}
 304	b, err := bodyFrom(c, body, file)
 305	if err != nil {
 306		return c.fail(protocol.ExitUsage, "%v", err)
 307	}
 308	n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName)
 309	if err != nil {
 310		return c.fail(protocol.ExitFailure, "%v", err)
 311	}
 312	// Fetch the head into the target so the target owns the objects.
 313	dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 314	if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
 315		return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
 316	}
 317	if srcRepo.ID != repo.ID {
 318		QueueMRBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL, repo, c.User.ID, n, headSHA)
 319	}
 320	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
 321	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
 322		notifyUsers(c, targets, mrSubject(repo, n, title),
 323			notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n)))
 324	}
 325	out := map[string]any{"number": n, "head_sha": headSHA}
 326	var parent *stackRef
 327	if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok {
 328		parent = &stackRef{p.Number, p.Title}
 329		out["stacked_on"] = parent
 330	}
 331	return c.emit(out, func(w io.Writer) {
 332		fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
 333		if parent != nil {
 334			fmt.Fprintf(w, "stacked on !%d %s\n", parent.Number, parent.Title)
 335		}
 336	})
 337}
 338
 339type mrOut struct {
 340	Number     int64  `json:"number"`
 341	Title      string `json:"title"`
 342	State      string `json:"state"`
 343	Author     string `json:"author"`
 344	Source     string `json:"source"` // owner/name:branch, or branch, "" if gone
 345	TargetRef  string `json:"target_ref"`
 346	HeadSHA    string `json:"head_sha"`
 347	Body       string `json:"body,omitempty"`
 348	BodyFormat string `json:"body_format,omitempty"`
 349	Milestone  string `json:"milestone,omitempty"`
 350	// StackedOn is the open merge request whose source branch this one
 351	// targets; Stacked are the open ones targeting this one's source.
 352	StackedOn *stackRef  `json:"stacked_on,omitempty"`
 353	Stacked   []stackRef `json:"stacked,omitempty"`
 354	CreatedAt string     `json:"created_at"`
 355	MergedAt  string     `json:"merged_at,omitempty"`
 356	MergedBy  string     `json:"merged_by,omitempty"`
 357	ClosedAt  string     `json:"closed_at,omitempty"`
 358	ClosedBy  string     `json:"closed_by,omitempty"`
 359}
 360
 361type stackRef struct {
 362	Number int64  `json:"number"`
 363	Title  string `json:"title"`
 364}
 365
 366// stackOf derives the stack around m: the open merge request whose source
 367// branch m targets, and the open ones targeting m's source. Both only
 368// within m's repository; a fork's branch is not a target anything can
 369// stack on.
 370func stackOf(c *Ctx, repo store.Repo, m store.MR) (*stackRef, []stackRef) {
 371	if m.State != "open" {
 372		return nil, nil
 373	}
 374	var parent *stackRef
 375	if p, ok, err := c.Store.OpenMRBySource(repo.ID, m.TargetRef); err == nil && ok && p.ID != m.ID {
 376		parent = &stackRef{p.Number, p.Title}
 377	}
 378	var children []stackRef
 379	if m.SourceRepoID == repo.ID {
 380		if kids, err := c.Store.OpenMRsByTarget(repo.ID, m.SourceRef); err == nil {
 381			for _, k := range kids {
 382				if k.ID != m.ID {
 383					children = append(children, stackRef{k.Number, k.Title})
 384				}
 385			}
 386		}
 387	}
 388	return parent, children
 389}
 390
 391func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
 392	src := ""
 393	if m.SourcePath != "" {
 394		if m.SourceRepoID == repo.ID {
 395			src = m.SourceRef
 396		} else {
 397			src = m.SourcePath + ":" + m.SourceRef
 398		}
 399	}
 400	o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
 401		Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
 402		CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
 403		ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy}
 404	if withBody {
 405		o.Body = m.Body
 406		o.BodyFormat = m.BodyFormat
 407	}
 408	return o
 409}
 410
 411func runMRList(c *Ctx, args []string) int {
 412	args, p, code := parsePageFlags(c, args, "mr", true)
 413	if code >= 0 {
 414		return code
 415	}
 416	const usage = "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--limit <n>] [--cursor <c>]"
 417	f := store.MRFilter{State: "open"}
 418	var path string
 419	for i := 0; i < len(args); i++ {
 420		var target *string
 421		switch args[i] {
 422		case "--state":
 423			target = &f.State
 424		case "--author":
 425			target = &f.Author
 426		case "--milestone":
 427			target = &f.Milestone
 428		default:
 429			if path != "" || strings.HasPrefix(args[i], "--") {
 430				return c.fail(protocol.ExitUsage, "unexpected argument %q\n%s", args[i], usage)
 431			}
 432			path = args[i]
 433			continue
 434		}
 435		if i+1 >= len(args) {
 436			return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 437		}
 438		*target = args[i+1]
 439		i++
 440	}
 441	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
 442	if path == "" || !valid[f.State] {
 443		return c.fail(protocol.ExitUsage, usage)
 444	}
 445	repo, code := resolveRepo(c, path, policy.CanRead)
 446	if code >= 0 {
 447		return code
 448	}
 449	f.Limit, f.Before = p.queryLimit(), p.keyInt()
 450	mrs, err := c.Store.QueryMRs(repo.ID, f)
 451	if err != nil {
 452		return c.fail(protocol.ExitFailure, "%v", err)
 453	}
 454	mrs, next := trimPage(p, mrs, "mr", func(m store.MR) string {
 455		return strconv.FormatInt(m.Number, 10)
 456	})
 457	var ds []mrOut
 458	for _, m := range mrs {
 459		o := mrToOut(repo, m, false)
 460		o.StackedOn, _ = stackOf(c, repo, m)
 461		ds = append(ds, o)
 462	}
 463	return c.emitPage(p, ds, next, func(w io.Writer) {
 464		for _, d := range ds {
 465			stacked := ""
 466			if d.StackedOn != nil {
 467				stacked = fmt.Sprintf("\tstacked on !%d", d.StackedOn.Number)
 468			}
 469			fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s%s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef, stacked)
 470		}
 471	})
 472}
 473
 474// byWhom renders " by <user>", or nothing when the actor is unknown — an
 475// imported merge request carries a time but no local account.
 476func byWhom(user string) string {
 477	if user == "" {
 478		return ""
 479	}
 480	return " by " + user
 481}
 482
 483func runMRShow(c *Ctx, args []string) int {
 484	repo, mr, code := mrRef(c, args, policy.CanRead)
 485	if code >= 0 {
 486		return code
 487	}
 488	if len(args) != 2 {
 489		return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>")
 490	}
 491	comments, err := c.Store.ListMRComments(mr.ID)
 492	if err != nil {
 493		return c.fail(protocol.ExitFailure, "%v", err)
 494	}
 495	reviews, err := c.Store.ListMRReviews(mr.ID)
 496	if err != nil {
 497		return c.fail(protocol.ExitFailure, "%v", err)
 498	}
 499	statuses, combined, err := c.Store.ChecksForCommit(repo.ID, mr.HeadSHA)
 500	if err != nil {
 501		return c.fail(protocol.ExitFailure, "%v", err)
 502	}
 503	unresolved, err := c.Store.UnresolvedThreadCount(mr.ID)
 504	if err != nil {
 505		return c.fail(protocol.ExitFailure, "%v", err)
 506	}
 507	type commentOut struct {
 508		Author     string `json:"author"`
 509		Body       string `json:"body"`
 510		BodyFormat string `json:"body_format,omitempty"`
 511		CreatedAt  string `json:"created_at"`
 512	}
 513	type reviewOut struct {
 514		Reviewer  string `json:"reviewer"`
 515		Verdict   string `json:"verdict"`
 516		Stale     bool   `json:"stale"`
 517		CreatedAt string `json:"created_at"`
 518	}
 519	type checkOut struct {
 520		Context   string `json:"context"`
 521		State     string `json:"state"`
 522		URL       string `json:"url,omitempty"`
 523		UpdatedAt string `json:"updated_at"`
 524		Duration  string `json:"duration,omitempty"` // CI checks only, once finished
 525	}
 526	var checks []checkOut
 527	for _, st := range statuses {
 528		out := checkOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt}
 529		if st.Duration > 0 {
 530			out.Duration = st.Duration.String()
 531		}
 532		checks = append(checks, out)
 533	}
 534	var cs []commentOut
 535	for _, cm := range comments {
 536		cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
 537	}
 538	var rs []reviewOut
 539	for _, r := range reviews {
 540		rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale, r.CreatedAt})
 541	}
 542	// The commits this MR carries: base..head, the diff's range.
 543	type commitOut struct {
 544		SHA     string `json:"sha"`
 545		Subject string `json:"subject"`
 546	}
 547	var commits []commitOut
 548	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 549	base := mr.MergedBase
 550	if base == "" {
 551		if b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number)); err == nil {
 552			base = b
 553		}
 554	}
 555	if base != "" {
 556		if shas, err := gitutil.RevListRange(dir, base, mrHeadRef(mr.Number)); err == nil {
 557			for _, sha := range shas {
 558				subject := ""
 559				if raw, err := gitutil.ReadCommit(dir, sha); err == nil {
 560					if parsed, err := sig.ParseCommit(raw); err == nil {
 561						subject = parsed.Subject
 562					}
 563				}
 564				commits = append(commits, commitOut{sha, subject})
 565			}
 566		}
 567	}
 568	d := struct {
 569		mrOut
 570		Checks            []checkOut   `json:"checks,omitempty"`
 571		Combined          string       `json:"checks_combined,omitempty"`
 572		UnresolvedThreads int          `json:"unresolved_threads,omitempty"`
 573		Commits           []commitOut  `json:"commits,omitempty"`
 574		Comments          []commentOut `json:"comments,omitempty"`
 575		Reviews           []reviewOut  `json:"reviews,omitempty"`
 576	}{mrToOut(repo, mr, true), checks, combined, unresolved, commits, cs, rs}
 577	d.StackedOn, d.Stacked = stackOf(c, repo, mr)
 578	return c.emit(d, func(w io.Writer) {
 579		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)
 580		if d.StackedOn != nil {
 581			fmt.Fprintf(w, "stacked on !%d %s\n", d.StackedOn.Number, d.StackedOn.Title)
 582		}
 583		for _, k := range d.Stacked {
 584			fmt.Fprintf(w, "stacked: !%d %s\n", k.Number, k.Title)
 585		}
 586		if d.MergedAt != "" {
 587			fmt.Fprintf(w, "merged %s%s\n", d.MergedAt, byWhom(d.MergedBy))
 588		}
 589		if d.ClosedAt != "" {
 590			fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy))
 591		}
 592		if d.Body != "" {
 593			fmt.Fprintf(w, "\n%s\n", d.Body)
 594		}
 595		for _, cm := range commits {
 596			fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject)
 597		}
 598		for _, x := range checks {
 599			dur := ""
 600			if x.Duration != "" {
 601				dur = " in " + x.Duration
 602			}
 603			fmt.Fprintf(w, "check: %s %s at %s%s\n", x.Context, x.State, x.UpdatedAt, dur)
 604		}
 605		if d.UnresolvedThreads > 0 {
 606			fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads)
 607		}
 608		for _, r := range rs {
 609			stale := ""
 610			if r.Stale {
 611				stale = " (stale)"
 612			}
 613			fmt.Fprintf(w, "review: %s %s%s at %s\n", r.Reviewer, r.Verdict, stale, r.CreatedAt)
 614		}
 615		for _, cm := range cs {
 616			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
 617		}
 618	})
 619}
 620
 621func runMRDiff(c *Ctx, args []string) int {
 622	repo, mr, code := mrRef(c, args, policy.CanRead)
 623	if code >= 0 {
 624		return code
 625	}
 626	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 627	head := mrHeadRef(mr.Number)
 628	// After a merge (especially fast-forward) the live merge-base equals
 629	// the head and the diff would vanish; use the recorded base instead.
 630	base := mr.MergedBase
 631	if base == "" {
 632		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
 633		if err != nil {
 634			return c.fail(protocol.ExitFailure, "%v", err)
 635		}
 636		base = b
 637	}
 638	patch, err := gitutil.Diff(dir, base, head, 4<<20)
 639	if err != nil {
 640		return c.fail(protocol.ExitFailure, "%v", err)
 641	}
 642	fmt.Fprint(c.Stdout, patch)
 643	return protocol.ExitOK
 644}
 645
 646func runMREdit(c *Ctx, args []string) int {
 647	rest, title, body, format, code := editText(c, args, "mr")
 648	if code >= 0 {
 649		return code
 650	}
 651	repo, mr, code := mrRef(c, rest, policy.CanRead)
 652	if code >= 0 {
 653		return code
 654	}
 655	if code := refuseArchived(c, repo); code >= 0 {
 656		return code
 657	}
 658	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
 659	if err != nil {
 660		return c.fail(protocol.ExitFailure, "%v", err)
 661	}
 662	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
 663		return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request")
 664	}
 665	if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
 666		return c.fail(protocol.ExitFailure, "%v", err)
 667	}
 668	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
 669		fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
 670	})
 671}
 672
 673// runMRRetarget moves an open merge request onto another branch of the
 674// same repository.
 675func runMRRetarget(c *Ctx, args []string) int {
 676	if len(args) != 3 {
 677		return c.fail(protocol.ExitUsage, "usage: mr retarget <owner/name> <n> <branch>")
 678	}
 679	repo, mr, code := mrRef(c, args[:2], policy.CanRead)
 680	if code >= 0 {
 681		return code
 682	}
 683	if code := refuseArchived(c, repo); code >= 0 {
 684		return code
 685	}
 686	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
 687	if err != nil {
 688		return c.fail(protocol.ExitFailure, "%v", err)
 689	}
 690	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
 691		return c.fail(protocol.ExitDenied, "only the author or users with write access can retarget this merge request")
 692	}
 693	if mr.State == "merged" || mr.State == "closed" {
 694		return c.fail(protocol.ExitUsage, "!%d is %s; only an open merge request can be retargeted", mr.Number, mr.State)
 695	}
 696	target := args[2]
 697	if target == mr.TargetRef {
 698		return c.fail(protocol.ExitUsage, "!%d already targets %s", mr.Number, target)
 699	}
 700	if mr.SourceRepoID == repo.ID && target == mr.SourceRef {
 701		return c.fail(protocol.ExitUsage, "%s is the source branch of !%d", target, mr.Number)
 702	}
 703	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 704	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+target); err != nil {
 705		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", target, repo.Path())
 706	}
 707	// The diff, the commit list and the merge gates all derive their base
 708	// from the target on every read, so the only thing to check here is
 709	// that a base exists at all: without one there is nothing to show and
 710	// nothing to merge.
 711	base, err := gitutil.MergeBase(dir, "refs/heads/"+target, mrHeadRef(mr.Number))
 712	if err != nil || base == "" {
 713		return c.fail(protocol.ExitUsage, "%s shares no history with the head of !%d", target, mr.Number)
 714	}
 715	old := mr.TargetRef
 716	if err := c.Store.SetMRTarget(mr.ID, target); err != nil {
 717		return c.fail(protocol.ExitFailure, "%v", err)
 718	}
 719	c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
 720	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 721		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 722			notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), "",
 723				fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 724	}
 725	return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) {
 726		fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base)
 727	})
 728}
 729
 730func runMRComment(c *Ctx, args []string) int {
 731	var rest []string
 732	var message, file, format string
 733	for i := 0; i < len(args); i++ {
 734		switch args[i] {
 735		case "--message", "--file", "--format":
 736			if i+1 >= len(args) {
 737				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 738			}
 739			switch args[i] {
 740			case "--message":
 741				message = args[i+1]
 742			case "--file":
 743				file = args[i+1]
 744			case "--format":
 745				format = args[i+1]
 746			}
 747			i++
 748		default:
 749			rest = append(rest, args[i])
 750		}
 751	}
 752	fmtName, err := markupFormat(format)
 753	if err != nil {
 754		return c.fail(protocol.ExitUsage, "%v", err)
 755	}
 756	if fmtName == "" {
 757		fmtName = "md"
 758	}
 759	repo, mr, code := mrRef(c, rest, policy.CanRead)
 760	if code >= 0 {
 761		return code
 762	}
 763	if code := refuseArchived(c, repo); code >= 0 {
 764		return code
 765	}
 766	body, err := bodyFrom(c, message, file)
 767	if err != nil {
 768		return c.fail(protocol.ExitUsage, "%v", err)
 769	}
 770	if strings.TrimSpace(body) == "" {
 771		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
 772	}
 773	if err := c.Store.AddMRComment(mr.ID, c.User.ID, body, fmtName); err != nil {
 774		return c.fail(protocol.ExitFailure, "%v", err)
 775	}
 776	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number))
 777	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 778		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 779			notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 780	}
 781	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
 782		fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
 783	})
 784}
 785
 786func runMRReview(c *Ctx, args []string) int {
 787	verdict := ""
 788	var rest []string
 789	for _, a := range args {
 790		switch a {
 791		case "--approve":
 792			verdict = "approve"
 793		case "--request-changes":
 794			verdict = "request_changes"
 795		case "--comment":
 796			verdict = "comment"
 797		default:
 798			rest = append(rest, a)
 799		}
 800	}
 801	if verdict == "" {
 802		return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
 803	}
 804	repo, mr, code := mrRef(c, rest, policy.CanRead)
 805	if code >= 0 {
 806		return code
 807	}
 808	if code := refuseArchived(c, repo); code >= 0 {
 809		return code
 810	}
 811	if mr.State != "open" {
 812		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
 813	}
 814	if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
 815		return c.fail(protocol.ExitFailure, "%v", err)
 816	}
 817	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 818		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 819			notifyBody(c, fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 820	}
 821	return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
 822		fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
 823	})
 824}
 825
 826func runMRMerge(c *Ctx, args []string) int {
 827	strategy := ""
 828	var rest []string
 829	for i := 0; i < len(args); i++ {
 830		if args[i] == "--strategy" {
 831			if i+1 >= len(args) {
 832				return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
 833			}
 834			strategy = args[i+1]
 835			i++
 836			continue
 837		}
 838		rest = append(rest, args[i])
 839	}
 840	valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
 841	if !valid[strategy] {
 842		return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
 843	}
 844	repo, mr, code := mrRef(c, rest, policy.CanWrite)
 845	if code >= 0 {
 846		return code
 847	}
 848	if code := refuseArchived(c, repo); code >= 0 {
 849		return code
 850	}
 851	if mr.State != "open" && mr.State != "source_gone" {
 852		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
 853	}
 854
 855	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 856	targetRef := "refs/heads/" + mr.TargetRef
 857	targetSHA, err := gitutil.ResolveRef(dir, targetRef)
 858	if err != nil {
 859		return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
 860	}
 861	headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
 862	if err != nil {
 863		return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
 864	}
 865
 866	// Check gate: with require_checks, the MR head must carry statuses
 867	// and every one of them must be green.
 868	if repo.Settings.RequireChecks {
 869		statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA)
 870		if err != nil {
 871			return c.fail(protocol.ExitFailure, "%v", err)
 872		}
 873		switch store.CombinedStatus(statuses) {
 874		case "success":
 875		case "":
 876			return c.fail(protocol.ExitDenied,
 877				"%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)
 878		default:
 879			var bad []string
 880			for _, st := range statuses {
 881				if st.State != "success" {
 882					bad = append(bad, st.Context+"="+st.State)
 883				}
 884			}
 885			return c.fail(protocol.ExitDenied,
 886				"%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))
 887		}
 888	}
 889
 890	// Review gates: approvals, CODEOWNERS, resolved threads.
 891	if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
 892		return code
 893	}
 894
 895	upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
 896	if err != nil {
 897		return c.fail(protocol.ExitFailure, "%v", err)
 898	}
 899	if upToDate {
 900		return c.fail(protocol.ExitUsage, "target already contains the MR head")
 901	}
 902	ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
 903	if err != nil {
 904		return c.fail(protocol.ExitFailure, "%v", err)
 905	}
 906
 907	// Signature policy matrix: with require_signed_commits, only
 908	// fast-forward is allowed — squash, rebase-replay, and merge commits
 909	// are all server-created and unsigned, violating the branch's own
 910	// policy — and every landed commit must be verified. An explicit
 911	// rebase when fast-forward is already possible IS a fast-forward
 912	// (nothing is rewritten), so it stays legal.
 913	if repo.Settings.RequireSignedCommits {
 914		if strategy == "merge" || strategy == "squash" || !ffPossible {
 915			return c.fail(protocol.ExitDenied,
 916				"%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
 917				repo.Path(), mr.SourceRef, mr.TargetRef)
 918		}
 919		strategy = "ff"
 920		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
 921		if err != nil {
 922			return c.fail(protocol.ExitFailure, "%v", err)
 923		}
 924		for _, sha := range commits {
 925			raw, err := gitutil.ReadCommit(dir, sha)
 926			if err != nil {
 927				return c.fail(protocol.ExitFailure, "%v", err)
 928			}
 929			parsed, err := sigParse(raw)
 930			if err != nil {
 931				return c.fail(protocol.ExitFailure, "%v", err)
 932			}
 933			res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
 934			if err != nil {
 935				return c.fail(protocol.ExitFailure, "%v", err)
 936			}
 937			if res.State != "verified" {
 938				return c.fail(protocol.ExitDenied,
 939					"%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
 940			}
 941		}
 942	}
 943	if strategy == "" {
 944		if ffPossible {
 945			strategy = "ff"
 946		} else {
 947			strategy = "merge"
 948		}
 949	}
 950	if strategy == "rebase" && ffPossible {
 951		// Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
 952		// and taking it keeps the original commits and their signatures.
 953		strategy = "ff"
 954	}
 955
 956	// Every server-created commit needs the merger's verified identity.
 957	mergerEmail := ""
 958	if strategy != "ff" {
 959		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
 960		if err != nil {
 961			return c.fail(protocol.ExitFailure, "%v", err)
 962		}
 963		if email == "" {
 964			return c.fail(protocol.ExitDenied,
 965				"%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
 966		}
 967		mergerEmail = email
 968	}
 969
 970	var newSHA string
 971	switch strategy {
 972	case "ff":
 973		if !ffPossible {
 974			return c.fail(protocol.ExitUsage,
 975				"fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
 976		}
 977		newSHA = headSHA
 978
 979	case "merge":
 980		tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
 981		if err != nil {
 982			return c.fail(protocol.ExitFailure, "%v", err)
 983		}
 984		if conflict {
 985			return c.fail(protocol.ExitUsage,
 986				"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
 987		}
 988		msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
 989		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
 990		if err != nil {
 991			return c.fail(protocol.ExitFailure, "%v", err)
 992		}
 993
 994	case "squash":
 995		// One new commit with the merged tree. Authorship credit goes to
 996		// the MR author (their verified identity when they have one); the
 997		// committer is the merger.
 998		tree := ""
 999		if ffPossible {
1000			t, err := gitutil.ResolveTree(dir, headSHA)
1001			if err != nil {
1002				return c.fail(protocol.ExitFailure, "%v", err)
1003			}
1004			tree = t
1005		} else {
1006			t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
1007			if err != nil {
1008				return c.fail(protocol.ExitFailure, "%v", err)
1009			}
1010			if conflict {
1011				return c.fail(protocol.ExitUsage,
1012					"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
1013			}
1014			tree = t
1015		}
1016		authorName, authorEmail := c.User.Username, mergerEmail
1017		if author, err := c.Store.UserByUsername(mr.Author); err == nil {
1018			if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
1019				authorName, authorEmail = author.Username, ae
1020			}
1021		}
1022		msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
1023		if mr.Body != "" {
1024			msg += "\n\n" + mr.Body
1025		}
1026		var err error
1027		newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
1028			authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
1029		if err != nil {
1030			return c.fail(protocol.ExitFailure, "%v", err)
1031		}
1032
1033	case "rebase":
1034		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
1035		if err != nil {
1036			return c.fail(protocol.ExitFailure, "%v", err)
1037		}
1038		// Oldest first.
1039		for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
1040			commits[i], commits[j] = commits[j], commits[i]
1041		}
1042		onto := targetSHA
1043		for _, sha := range commits {
1044			parents, err := gitutil.CommitParents(dir, sha)
1045			if err != nil {
1046				return c.fail(protocol.ExitFailure, "%v", err)
1047			}
1048			if len(parents) > 1 {
1049				return c.fail(protocol.ExitUsage,
1050					"the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
1051			}
1052			base := onto // root commit: replay against the new tip itself
1053			if len(parents) == 1 {
1054				base = parents[0]
1055			}
1056			tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
1057			if err != nil {
1058				return c.fail(protocol.ExitFailure, "%v", err)
1059			}
1060			if conflict {
1061				return c.fail(protocol.ExitUsage,
1062					"commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
1063			}
1064			aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
1065			if err != nil {
1066				return c.fail(protocol.ExitFailure, "%v", err)
1067			}
1068			msg, err := gitutil.CommitMessage(dir, sha)
1069			if err != nil {
1070				return c.fail(protocol.ExitFailure, "%v", err)
1071			}
1072			onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
1073				aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
1074			if err != nil {
1075				return c.fail(protocol.ExitFailure, "%v", err)
1076			}
1077		}
1078		newSHA = onto
1079	}
1080
1081	// A stacked merge request's diff is against this branch. After a
1082	// fast-forward or merge commit the same commits are on the target and
1083	// its diff is unchanged there; after a squash or rebase they are not,
1084	// and the stack would carry this merge request's changes a second
1085	// time. Refuse rather than leave the stack wrong.
1086	var stack []store.MR
1087	if mr.SourceRepoID == repo.ID {
1088		stack, _ = c.Store.OpenMRsByTarget(repo.ID, mr.SourceRef)
1089	}
1090	if len(stack) > 0 && (strategy == "squash" || strategy == "rebase") {
1091		var nums []string
1092		for _, k := range stack {
1093			nums = append(nums, fmt.Sprintf("!%d", k.Number))
1094		}
1095		return c.fail(protocol.ExitUsage,
1096			"%s is stacked on by %s; a %s merge rewrites the commits they build on. Merge with --strategy ff or merge, or merge the stack into %s first",
1097			fmt.Sprintf("!%d", mr.Number), strings.Join(nums, ", "), strategy, mr.SourceRef)
1098	}
1099
1100	// CAS so a concurrent push between our read and this write fails the
1101	// merge instead of silently discarding the push.
1102	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
1103		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
1104	}
1105	if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil {
1106		return c.fail(protocol.ExitFailure, "%v", err)
1107	}
1108	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
1109	// The stack moves up: whatever targeted this branch now targets what
1110	// it merged into, reviews intact, since that diff is the one they
1111	// were of.
1112	for _, k := range stack {
1113		if err := c.Store.RetargetKeepingReviews(k.ID, mr.TargetRef); err != nil {
1114			continue
1115		}
1116		c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number))
1117		if parts, err := c.Store.MRParticipants(k.ID); err == nil {
1118			notifyUsers(c, parts, mrSubject(repo, k.Number, k.Title),
1119				notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number), "",
1120					fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)))
1121		}
1122	}
1123	// Merges bypass receive-pack, so the commit-message issue actions
1124	// (closes #N, references) run here for the newly landed commits. The
1125	// description is scanned after them, so a commit wins the attribution
1126	// when both name the same issue.
1127	if mr.TargetRef == repo.DefaultBranch {
1128		ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
1129		ProcessMRDescription(c.Store, repo, mr, c.User.ID)
1130		RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
1131	}
1132	// A merge moves the ref directly, so it never reaches post-receive and
1133	// none of the ref-update work fires on its own. The event webhooks
1134	// subscribe to, and the branch's CI jobs, happen here instead.
1135	c.Store.RecordEvent(repo.ID, c.User.ID, "push", fmt.Sprintf(
1136		`{"ref":%q,"old":%q,"new":%q,"forced":false,"deleted":false}`,
1137		targetRef, targetSHA, newSHA))
1138	QueueBranchBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL,
1139		repo, c.User.ID, mr.TargetRef, newSHA, time.Now())
1140	c.Store.MarkMirrorsDirty(repo.ID, "push")
1141	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1142		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
1143			notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
1144	}
1145	return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
1146		fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
1147	})
1148}
1149
1150// reviewGates enforces require_approvals (fresh, non-author, latest review
1151// per reviewer; a fresh request-changes blocks), CODEOWNERS coverage
1152// whenever the target branch carries a CODEOWNERS file, and
1153// require_resolved. Returns -1 to proceed.
1154func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
1155	set := repo.Settings
1156	reviews, err := c.Store.ListMRReviews(mr.ID)
1157	if err != nil {
1158		return c.fail(protocol.ExitFailure, "%v", err)
1159	}
1160	// Latest fresh review per reviewer decides their stance.
1161	latest := map[string]string{}
1162	for _, r := range reviews {
1163		if r.Stale || r.Reviewer == mr.Author {
1164			continue
1165		}
1166		latest[r.Reviewer] = r.Verdict
1167	}
1168	var approvers []string
1169	var blockers []string
1170	for who, verdict := range latest {
1171		switch verdict {
1172		case "approve":
1173			approvers = append(approvers, who)
1174		case "request_changes":
1175			blockers = append(blockers, who)
1176		}
1177	}
1178
1179	if set.RequireApprovals > 0 {
1180		if len(blockers) > 0 {
1181			slices.Sort(blockers)
1182			return c.fail(protocol.ExitDenied,
1183				"%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number)
1184		}
1185		if len(approvers) < set.RequireApprovals {
1186			return c.fail(protocol.ExitDenied,
1187				"%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers))
1188		}
1189	}
1190
1191	// CODEOWNERS: every owned changed file needs an approval from one of
1192	// its owners. The file's presence is the opt-in; it does not wait on
1193	// require_approvals (#99).
1194	content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20)
1195	if err != nil {
1196		content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
1197	}
1198	if err == nil && len(content) > 0 {
1199		rules := policy.ParseCodeowners(string(content))
1200		base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
1201		if err != nil {
1202			return c.fail(protocol.ExitFailure, "%v", err)
1203		}
1204		files, err := gitutil.DiffFiles(dir, base, headSHA)
1205		if err != nil {
1206			return c.fail(protocol.ExitFailure, "%v", err)
1207		}
1208		approved := map[string]bool{}
1209		for _, a := range approvers {
1210			approved[a] = true
1211		}
1212		missing := map[string][]string{} // owner-set key -> example paths
1213		for _, f := range files {
1214			owners := policy.OwnersFor(rules, f)
1215			if owners == nil {
1216				continue
1217			}
1218			ok := false
1219			for _, o := range owners {
1220				if approved[o] {
1221					ok = true
1222					break
1223				}
1224			}
1225			if !ok {
1226				key := strings.Join(owners, ",")
1227				if len(missing[key]) < 3 {
1228					missing[key] = append(missing[key], f)
1229				}
1230			}
1231		}
1232		if len(missing) > 0 {
1233			var parts []string
1234			for owners, paths := range missing {
1235				parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners))
1236			}
1237			slices.Sort(parts)
1238			return c.fail(protocol.ExitDenied,
1239				"CODEOWNERS approval missing for: %s", strings.Join(parts, "; "))
1240		}
1241	}
1242
1243	if set.RequireResolved {
1244		n, err := c.Store.UnresolvedThreadCount(mr.ID)
1245		if err != nil {
1246			return c.fail(protocol.ExitFailure, "%v", err)
1247		}
1248		if n > 0 {
1249			return c.fail(protocol.ExitDenied,
1250				"%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number)
1251		}
1252	}
1253	return -1
1254}
1255
1256func runMRClose(c *Ctx, args []string) int {
1257	repo, mr, code := mrRef(c, args, policy.CanRead)
1258	if code >= 0 {
1259		return code
1260	}
1261	if code := refuseArchived(c, repo); code >= 0 {
1262		return code
1263	}
1264	if len(args) != 2 {
1265		return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
1266	}
1267	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1268	if err != nil {
1269		return c.fail(protocol.ExitFailure, "%v", err)
1270	}
1271	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
1272		return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
1273	}
1274	if mr.State == "merged" || mr.State == "closed" {
1275		return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
1276	}
1277	if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil {
1278		return c.fail(protocol.ExitFailure, "%v", err)
1279	}
1280	return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
1281		fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
1282	})
1283}