internal/control/mr.go

2161 lines · 78073 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/ci"
  13	"gitbay.org/gitbay/internal/gitutil"
  14	"gitbay.org/gitbay/internal/policy"
  15	"gitbay.org/gitbay/internal/protocol"
  16	"gitbay.org/gitbay/internal/sig"
  17	"gitbay.org/gitbay/internal/store"
  18)
  19
  20func init() {
  21	register(Command{Path: []string{"repo", "fork"},
  22		Summary: "fork a repository under your account",
  23		Usage:   "repo fork <owner/name> [--owner <o>] [--name <n>]",
  24		Flags: []Flag{
  25			{"--owner", "<o>", "fork under this user or org, default your account", ""},
  26			{"--name", "<n>", "name the fork", "the source's name"},
  27		},
  28		Examples: []string{"repo fork krz/gitbay"},
  29		Run:      runRepoFork})
  30	register(Command{Path: []string{"repo", "settings", "require-approvals"},
  31		Summary:  "require N fresh approvals to merge",
  32		Usage:    "repo settings require-approvals <owner/name> <n> (0 = off)",
  33		Examples: []string{"repo settings require-approvals krz/gitbay 1"},
  34		Run:      runRequireApprovals})
  35	register(Command{Path: []string{"repo", "settings", "require-resolved"},
  36		Summary:  "require all review threads resolved to merge",
  37		Usage:    "repo settings require-resolved <owner/name> on|off",
  38		Examples: []string{"repo settings require-resolved krz/gitbay on"},
  39		Run:      runRequireResolved})
  40	register(Command{Path: []string{"repo", "settings", "require-codeowners"},
  41		Summary:  "require an owner's approval for every file CODEOWNERS covers",
  42		Usage:    "repo settings require-codeowners <owner/name> on|off",
  43		Examples: []string{"repo settings require-codeowners krz/gitbay on"},
  44		Run:      runRequireCodeowners})
  45	register(Command{Path: []string{"repo", "settings", "require-checks"},
  46		Summary:  "gate merges on green statuses",
  47		Usage:    "repo settings require-checks <owner/name> on|off",
  48		Examples: []string{"repo settings require-checks krz/gitbay on"},
  49		Run:      runRequireChecks})
  50	register(Command{Path: []string{"repo", "settings", "require-contexts"},
  51		Summary:  "name the statuses the checks gate waits for, and turn the gate on",
  52		Usage:    "repo settings require-contexts <owner/name> [<context>...] (none clears the list)",
  53		Examples: []string{"repo settings require-contexts krz/gitbay ci/build ci/test"},
  54		Run:      runRequireContexts})
  55	register(Command{Path: []string{"repo", "settings", "require-mr"},
  56		Summary:  "protected branches take changes through merge requests only",
  57		Usage:    "repo settings require-mr <owner/name> on|off",
  58		Examples: []string{"repo settings require-mr krz/gitbay on"},
  59		Run:      runRequireMR})
  60	register(Command{Path: []string{"repo", "settings", "require-signed"},
  61		Summary:  "require verified commit signatures",
  62		Usage:    "repo settings require-signed <owner/name> on|off",
  63		Examples: []string{"repo settings require-signed krz/gitbay on"},
  64		Run:      runRequireSigned})
  65	register(Command{Path: []string{"mr", "create"},
  66		Summary: "open a merge request",
  67		Usage:   "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -] [--format md|org] [--draft]",
  68		Flags: []Flag{
  69			{"--source", "[owner/name:]<branch>", "the branch to merge, from a fork with owner/name:", ""},
  70			{"--target", "<branch>", "the branch to merge into", ""},
  71			{"--title", "<t>", "the merge request's title", ""},
  72			{"--body", "<b>", "the merge request's body", ""},
  73			{"--file", "-", "read the body from stdin", ""},
  74			{"--format", "md|org", "the body's markup", "md"},
  75			{"--draft", "", "open it as work in progress", ""},
  76		},
  77		Examples: []string{
  78			`mr create krz/gitbay --source cli-output-help --target main --title "control: flag help"`,
  79			"mr create krz/gitbay --source cli-output-help --target main --title notes --file - < notes.md",
  80		},
  81		ReadsStdin: true, Run: runMRCreate})
  82	register(Command{Path: []string{"mr", "range-diff"},
  83		Summary: "what changed between two revisions of a merge request",
  84		Usage:   "mr range-diff <owner/name> <n> [--from <sha>] [--to <sha>]",
  85		Flags: []Flag{
  86			{"--from", "<sha>", "earlier revision, default the one before --to", ""},
  87			{"--to", "<sha>", "later revision, default the head", ""},
  88		},
  89		Examples: []string{"mr range-diff krz/gitbay 431"},
  90		ReadOnly: true, Run: runMRRangeDiff})
  91	register(Command{Path: []string{"mr", "revisions"},
  92		Summary:  "the heads a merge request has had",
  93		Usage:    "mr revisions <owner/name> <n>",
  94		Examples: []string{"mr revisions krz/gitbay 431"},
  95		ReadOnly: true, Run: runMRRevisions})
  96	register(Command{Path: []string{"mr", "draft"},
  97		Summary:  "mark a merge request as work in progress",
  98		Usage:    "mr draft <owner/name> <n>",
  99		Examples: []string{"mr draft krz/gitbay 431"},
 100		Run:      runMRDraft})
 101	register(Command{Path: []string{"mr", "ready"},
 102		Summary:  "take the draft mark off, so it can merge",
 103		Usage:    "mr ready <owner/name> <n>",
 104		Examples: []string{"mr ready krz/gitbay 431"},
 105		Run:      runMRReady})
 106	register(Command{Path: []string{"mr", "list"},
 107		Summary: "list merge requests",
 108		Usage:   "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--label <l>] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>] | mr list --query <name> | --q <query> [--limit <n>] [--cursor <c>]",
 109		Flags: []Flag{
 110			{"--state", "open|merged|closed|source_gone|all", "which merge requests", "open"},
 111			{"--label", "<l>", "only MRs carrying this label", ""},
 112			{"--author", "<user>", "only MRs opened by this user", ""},
 113			{"--milestone", "<title>|none", "only MRs in this milestone, or in none", ""},
 114			{"--search", "<text>", "match title and body", ""},
 115			{"--query", "<name>", "a saved query, across repositories, in place of a repository and filters", ""},
 116			{"--q", "<query>", "a query written out, as query save takes it", ""},
 117			{"--limit", "<n>", "rows per page", ""},
 118			{"--cursor", "<c>", "continue from the previous page", ""},
 119		},
 120		Examples: []string{
 121			"mr list krz/gitbay --state open",
 122			"mr list krz/gitbay --author cmc --state all",
 123			"mr list --query reviews",
 124			`mr list --q "repo:krz/* is:open author:@me"`,
 125		},
 126		ReadOnly: true, Run: runMRList})
 127	register(Command{Path: []string{"mr", "show"},
 128		Summary:  "show a merge request",
 129		Usage:    "mr show <owner/name> <n>",
 130		Examples: []string{"mr show krz/gitbay 431"},
 131		ReadOnly: true, Run: runMRShow})
 132	register(Command{Path: []string{"mr", "diff"},
 133		Summary:  "show the diff",
 134		Usage:    "mr diff <owner/name> <n>",
 135		Examples: []string{"mr diff krz/gitbay 431"},
 136		ReadOnly: true, Run: runMRDiff})
 137	register(Command{Path: []string{"mr", "edit"},
 138		Summary: "edit title or body",
 139		Usage:   "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org] [--superseded-by <m>|none]",
 140		Flags: []Flag{
 141			{"--title", "<t>", "the merge request's new title", ""},
 142			{"--body", "<b>", "the merge request's new body", ""},
 143			{"--file", "-", "read the new body from stdin", ""},
 144			{"--format", "md|org", "the body's markup", ""},
 145			{"--superseded-by", "<m>|none", "the MR replacing this one, or none to clear", ""},
 146		},
 147		Examples:   []string{`mr edit krz/gitbay 431 --title "control: flag help, take two"`},
 148		ReadsStdin: true, Run: runMREdit})
 149	register(Command{Path: []string{"mr", "retarget"},
 150		Summary:  "retarget onto another branch",
 151		Usage:    "mr retarget <owner/name> <n> <branch>",
 152		Examples: []string{"mr retarget krz/gitbay 431 main"},
 153		Run:      runMRRetarget})
 154	register(Command{Path: []string{"mr", "comment"},
 155		Summary: "add a comment",
 156		Usage:   "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
 157		Flags: []Flag{
 158			{"--message", "<m>", "the comment's text", ""},
 159			{"--file", "-", "read the comment from stdin", ""},
 160			{"--format", "md|org", "the comment's markup", "md"},
 161		},
 162		Examples:   []string{`mr comment krz/gitbay 431 --message "looks good"`},
 163		ReadsStdin: true, Run: runMRComment})
 164	register(Command{Path: []string{"mr", "review"},
 165		Summary: "record a review verdict",
 166		Usage:   "mr review <owner/name> <n> --approve|--request-changes|--comment|--discard",
 167		Flags: []Flag{
 168			{"--approve", "", "approve the merge request", ""},
 169			{"--request-changes", "", "ask for changes", ""},
 170			{"--comment", "", "submit pending diff comments without a verdict", ""},
 171			{"--discard", "", "throw away pending diff comments", ""},
 172		},
 173		Examples: []string{"mr review krz/gitbay 431 --approve"},
 174		Run:      runMRReview})
 175	register(Command{Path: []string{"mr", "review", "request"},
 176		Summary: "ask specific people for a review",
 177		Usage:   "mr review request <owner/name> <n> [--add <user>]... [--remove <user>]...",
 178		Flags: []Flag{
 179			{"--add", "<user>", "reviewer to add, may repeat", ""},
 180			{"--remove", "<user>", "reviewer to remove, may repeat", ""},
 181		},
 182		Examples: []string{"mr review request krz/gitbay 431 --add cmc"},
 183		Run:      runMRReviewRequest})
 184	register(Command{Path: []string{"mr", "label"},
 185		Summary: "add or remove labels",
 186		Usage:   "mr label <owner/name> <n> [--add <l>]... [--remove <l>]...",
 187		Flags: []Flag{
 188			{"--add", "<l>", "label to add, may repeat", ""},
 189			{"--remove", "<l>", "label to remove, may repeat", ""},
 190		},
 191		Examples: []string{"mr label krz/gitbay 431 --add needs-review"},
 192		Run:      runMRLabel})
 193	register(Command{Path: []string{"mr", "merge"},
 194		Summary: "merge",
 195		Usage:   "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase] [--when-ready | --cancel]",
 196		Flags: []Flag{
 197			{"--strategy", "ff|merge|squash|rebase", "how to merge", ""},
 198			{"--when-ready", "", "queue the merge until the gates pass; merges now if they already do", ""},
 199			{"--cancel", "", "take a queued merge off the queue", ""},
 200		},
 201		Examples: []string{"mr merge krz/gitbay 431 --strategy ff", "mr merge krz/gitbay 431 --when-ready", "mr merge krz/gitbay 431 --cancel"},
 202		Run:      runMRMerge})
 203	register(Command{Path: []string{"mr", "close"},
 204		Summary: "close without merging",
 205		Usage:   "mr close <owner/name> <n> [--by <m>]",
 206		Flags: []Flag{
 207			{"--by", "<m>", "the MR that supersedes this one", ""},
 208		},
 209		Examples: []string{"mr close krz/gitbay 431"},
 210		Run:      runMRClose})
 211}
 212
 213// ForkOut is what `repo fork` emits: where the fork landed, and what it
 214// came from. Named so the web can send a person to the new repository.
 215type ForkOut struct {
 216	Path   string `json:"path"`
 217	ForkOf string `json:"fork_of"`
 218}
 219
 220func runRepoFork(c *Ctx, args []string) int {
 221	f, err := c.parseArgs(args, flagSpec{Values: []string{"--name", "--owner"}, MaxPos: 1, Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]"})
 222	if err != nil {
 223		return c.fail(protocol.ExitUsage, "%v", err)
 224	}
 225	path, name, owner := f.pos(0), f.Value("--name"), f.Value("--owner")
 226	if path == "" {
 227		return c.usage()
 228	}
 229	src, code := resolveRepo(c, path, policy.CanRead)
 230	if code >= 0 {
 231		return code
 232	}
 233	if name == "" {
 234		name = src.Name
 235	}
 236	if err := policy.ValidateName(name); err != nil {
 237		return c.failInput(err)
 238	}
 239	if owner == "" {
 240		owner = c.User.Username
 241	}
 242	ownerKind, ownerID, code := resolveNewRepoOwner(c, owner)
 243	if code >= 0 {
 244		return code
 245	}
 246	repoCreateMu.Lock()
 247	// An organization's repositories are not counted against the quota,
 248	// the same as repo create.
 249	if ownerKind == "user" {
 250		if code := checkRepoQuota(c); code >= 0 {
 251			repoCreateMu.Unlock()
 252			return code
 253		}
 254	}
 255	id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID)
 256	repoCreateMu.Unlock()
 257	if err != nil {
 258		return c.fail(protocol.ExitFailure, "%v", err)
 259	}
 260	dstDir := RepoDir(c.Cfg.Server.Root, owner, name)
 261	srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
 262	if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
 263		c.Store.DeleteRepo(id)
 264		return c.fail(protocol.ExitFailure, "%v", err)
 265	}
 266	if desc := gitutil.ReadDescription(srcDir); desc != "" {
 267		gitutil.WriteDescription(dstDir, desc)
 268	}
 269	if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
 270		// Empty source repos have nothing to fetch; that is fine.
 271		if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
 272			c.Store.DeleteRepo(id)
 273			return c.fail(protocol.ExitFailure, "copying refs: %v", err)
 274		}
 275	}
 276	forkPath := owner + "/" + name
 277	return c.emit(ForkOut{Path: forkPath, ForkOf: src.Path()}, func(w io.Writer) {
 278		fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
 279	})
 280}
 281
 282func runRequireApprovals(c *Ctx, args []string) int {
 283	if len(args) != 2 {
 284		return c.usage()
 285	}
 286	n, err := strconv.Atoi(args[1])
 287	if err != nil || n < 0 || n > 20 {
 288		return c.fail(protocol.ExitUsage, "approvals must be 0..20")
 289	}
 290	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 291	if code >= 0 {
 292		return code
 293	}
 294	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireApprovals = n })
 295	if err != nil {
 296		return c.fail(protocol.ExitFailure, "%v", err)
 297	}
 298	return c.emit(s, func(w io.Writer) {
 299		fmt.Fprintf(w, "require_approvals %d on %s\n", n, repo.Path())
 300	})
 301}
 302
 303func runRequireResolved(c *Ctx, args []string) int {
 304	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 305		return c.usage()
 306	}
 307	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 308	if code >= 0 {
 309		return code
 310	}
 311	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireResolved = args[1] == "on" })
 312	if err != nil {
 313		return c.fail(protocol.ExitFailure, "%v", err)
 314	}
 315	return c.emit(s, func(w io.Writer) {
 316		fmt.Fprintf(w, "require_resolved %s on %s\n", args[1], repo.Path())
 317	})
 318}
 319
 320func runRequireCodeowners(c *Ctx, args []string) int {
 321	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 322		return c.usage()
 323	}
 324	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 325	if code >= 0 {
 326		return code
 327	}
 328	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireCodeowners = args[1] == "on" })
 329	if err != nil {
 330		return c.fail(protocol.ExitFailure, "%v", err)
 331	}
 332	return c.emit(s, func(w io.Writer) {
 333		fmt.Fprintf(w, "require_codeowners %s on %s\n", args[1], repo.Path())
 334	})
 335}
 336
 337func runRequireChecks(c *Ctx, args []string) int {
 338	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 339		return c.usage()
 340	}
 341	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 342	if code >= 0 {
 343		return code
 344	}
 345	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireChecks = args[1] == "on" })
 346	if err != nil {
 347		return c.fail(protocol.ExitFailure, "%v", err)
 348	}
 349	return c.emit(s, func(w io.Writer) {
 350		fmt.Fprintf(w, "require_checks %s on %s\n", args[1], repo.Path())
 351	})
 352}
 353
 354// maxRequiredContexts bounds the list: a gate naming more checks than
 355// this is a configuration mistake.
 356const maxRequiredContexts = 20
 357
 358func runRequireContexts(c *Ctx, args []string) int {
 359	if len(args) < 1 {
 360		return c.usage()
 361	}
 362	var contexts []string
 363	for _, ctx := range args[1:] {
 364		if ctx == "" || len(ctx) > 100 || strings.ContainsAny(ctx, " \t\r\n") {
 365			return c.fail(protocol.ExitUsage, "a context is 1 to 100 characters with no whitespace: %q", ctx)
 366		}
 367		if !slices.Contains(contexts, ctx) {
 368			contexts = append(contexts, ctx)
 369		}
 370	}
 371	if len(contexts) > maxRequiredContexts {
 372		return c.fail(protocol.ExitUsage, "at most %d required contexts", maxRequiredContexts)
 373	}
 374	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 375	if code >= 0 {
 376		return code
 377	}
 378	// Naming contexts asks for the gate, so it turns require_checks on in
 379	// the same update. Clearing the list leaves the gate as it was.
 380	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
 381		s.RequiredContexts = contexts
 382		if len(contexts) > 0 {
 383			s.RequireChecks = true
 384		}
 385	})
 386	if err != nil {
 387		return c.fail(protocol.ExitFailure, "%v", err)
 388	}
 389	return c.emit(s, func(w io.Writer) {
 390		if len(contexts) > 0 {
 391			fmt.Fprintf(w, "required contexts on %s: %s; require_checks on\n", repo.Path(), strings.Join(contexts, ", "))
 392			return
 393		}
 394		gate := "off"
 395		if s.RequireChecks {
 396			gate = "on"
 397		}
 398		fmt.Fprintf(w, "required contexts cleared on %s; require_checks %s\n", repo.Path(), gate)
 399	})
 400}
 401
 402func runRequireMR(c *Ctx, args []string) int {
 403	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 404		return c.usage()
 405	}
 406	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 407	if code >= 0 {
 408		return code
 409	}
 410	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireMR = args[1] == "on" })
 411	if err != nil {
 412		return c.fail(protocol.ExitFailure, "%v", err)
 413	}
 414	return c.emit(s, func(w io.Writer) {
 415		fmt.Fprintf(w, "require_mr %s on %s\n", args[1], repo.Path())
 416	})
 417}
 418
 419func runRequireSigned(c *Ctx, args []string) int {
 420	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 421		return c.usage()
 422	}
 423	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 424	if code >= 0 {
 425		return code
 426	}
 427	s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireSignedCommits = args[1] == "on" })
 428	if err != nil {
 429		return c.fail(protocol.ExitFailure, "%v", err)
 430	}
 431	return c.emit(s, func(w io.Writer) {
 432		fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path())
 433	})
 434}
 435
 436// mrRef parses "<owner/name> <n>" and loads the MR.
 437func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) {
 438	repo, n, code := refArgs(c, args, perm, "MR")
 439	if code >= 0 {
 440		return repo, store.MR{}, code
 441	}
 442	mr, err := c.Store.MRByNumber(repo.ID, n)
 443	if errors.Is(err, store.ErrNotFound) {
 444		return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
 445	}
 446	if err != nil {
 447		return repo, mr, c.fail(protocol.ExitFailure, "%v", err)
 448	}
 449	return repo, mr, -1
 450}
 451
 452func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
 453
 454func runMRCreate(c *Ctx, args []string) int {
 455	f, err := c.parseArgs(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"},
 456		Bools: []string{"--draft"}, MaxPos: 1,
 457		Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]"})
 458	if err != nil {
 459		return c.fail(protocol.ExitUsage, "%v", err)
 460	}
 461	path, source, target := f.pos(0), f.Value("--source"), f.Value("--target")
 462	title, body, file, format := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format")
 463	if path == "" || source == "" || title == "" {
 464		return c.usage()
 465	}
 466	fmtName, err := markupFormat(format)
 467	if err != nil {
 468		return c.failInput(err)
 469	}
 470	if fmtName == "" {
 471		fmtName = "md"
 472	}
 473	repo, code := resolveRepo(c, path, policy.CanRead)
 474	if code >= 0 {
 475		return code
 476	}
 477	if code := refuseArchived(c, repo); code >= 0 {
 478		return code
 479	}
 480	if target == "" {
 481		target = repo.DefaultBranch
 482	}
 483
 484	// Source is "branch" (same repo) or "owner/name:branch" (a fork).
 485	srcRepo := repo
 486	srcBranch := source
 487	if sp, br, ok := strings.Cut(source, ":"); ok {
 488		srcBranch = br
 489		var scode int
 490		srcRepo, scode = resolveRepo(c, sp, policy.CanRead)
 491		if scode >= 0 {
 492			return scode
 493		}
 494		if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID {
 495			return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path())
 496		}
 497	}
 498	srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
 499	headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch)
 500	if err != nil {
 501		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path())
 502	}
 503	b, err := bodyFrom(c, body, file)
 504	if err != nil {
 505		return c.failInput(err)
 506	}
 507	n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName, f.Has("--draft"))
 508	if err != nil {
 509		return c.fail(protocol.ExitFailure, "%v", err)
 510	}
 511	// Fetch the head into the target so the target owns the objects.
 512	dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 513	if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
 514		return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
 515	}
 516	if srcRepo.ID != repo.ID {
 517		QueueMRBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL, repo, c.User.ID, n, headSHA)
 518	}
 519	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
 520	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
 521		notify(c, targets, notice{repo: repo, kind: "mr", number: n,
 522			subject: mrSubject(repo, n, title),
 523			action:  fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target),
 524			excerpt: b, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), n)})
 525	}
 526	if created, err := c.Store.MRByNumber(repo.ID, n); err == nil {
 527		notifyMentions(c, repo, mrThread, created.ID, n, title, b)
 528	}
 529	out := MRCreated{Number: n, HeadSHA: headSHA}
 530	if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok {
 531		out.StackedOn = &stackRef{p.Number, p.Title}
 532	}
 533	return c.emit(out, func(w io.Writer) {
 534		fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
 535		if out.StackedOn != nil {
 536			fmt.Fprintf(w, "stacked on !%d %s\n", out.StackedOn.Number, out.StackedOn.Title)
 537		}
 538	})
 539}
 540
 541type mrOut struct {
 542	Number int64  `json:"number"`
 543	Title  string `json:"title"`
 544	State  string `json:"state"`
 545	// Draft is an open merge request not asking to be merged yet.
 546	Draft      bool     `json:"draft,omitempty"`
 547	Author     string   `json:"author"`
 548	Source     string   `json:"source"` // owner/name:branch, or branch, "" if gone
 549	TargetRef  string   `json:"target_ref"`
 550	HeadSHA    string   `json:"head_sha"`
 551	Body       string   `json:"body,omitempty"`
 552	BodyFormat string   `json:"body_format,omitempty"`
 553	Milestone  string   `json:"milestone,omitempty"`
 554	Labels     []string `json:"labels,omitempty"`
 555	// ReviewRequests is who has been asked, directly, for a review.
 556	ReviewRequests []string `json:"review_requests,omitempty"`
 557	// StackedOn is the open merge request whose source branch this one
 558	// targets; Stacked are the open ones targeting this one's source.
 559	StackedOn *stackRef  `json:"stacked_on,omitempty"`
 560	Stacked   []stackRef `json:"stacked,omitempty"`
 561	CreatedAt string     `json:"created_at"`
 562	MergedAt  string     `json:"merged_at,omitempty"`
 563	MergedBy  string     `json:"merged_by,omitempty"`
 564	ClosedAt  string     `json:"closed_at,omitempty"`
 565	ClosedBy  string     `json:"closed_by,omitempty"`
 566	// SupersededBy is the merge request, by number, this one was closed
 567	// in favour of. 0 means none.
 568	SupersededBy int64 `json:"superseded_by,omitempty"`
 569	// Queued is the merge queued with mr merge --when-ready, if any.
 570	Queued *QueuedOut `json:"queued,omitempty"`
 571}
 572
 573type stackRef struct {
 574	Number int64  `json:"number"`
 575	Title  string `json:"title"`
 576}
 577
 578// stackOf derives the stack around m: the open merge request whose source
 579// branch m targets, and the open ones targeting m's source. Both only
 580// within m's repository; a fork's branch is not a target anything can
 581// stack on.
 582func stackOf(c *Ctx, repo store.Repo, m store.MR) (*stackRef, []stackRef) {
 583	if m.State != "open" {
 584		return nil, nil
 585	}
 586	var parent *stackRef
 587	if p, ok, err := c.Store.OpenMRBySource(repo.ID, m.TargetRef); err == nil && ok && p.ID != m.ID {
 588		parent = &stackRef{p.Number, p.Title}
 589	}
 590	var children []stackRef
 591	if m.SourceRepoID == repo.ID {
 592		if kids, err := c.Store.OpenMRsByTarget(repo.ID, m.SourceRef); err == nil {
 593			for _, k := range kids {
 594				if k.ID != m.ID {
 595					children = append(children, stackRef{k.Number, k.Title})
 596				}
 597			}
 598		}
 599	}
 600	return parent, children
 601}
 602
 603func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
 604	src := ""
 605	if m.SourcePath != "" {
 606		if m.SourceRepoID == repo.ID {
 607			src = m.SourceRef
 608		} else {
 609			src = m.SourcePath + ":" + m.SourceRef
 610		}
 611	}
 612	o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Draft: m.Draft, Author: m.Author,
 613		Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
 614		Labels: m.Labels, ReviewRequests: m.ReviewRequests,
 615		CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
 616		ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy,
 617		Queued: queuedOut(m)}
 618	if withBody {
 619		o.Body = m.Body
 620		o.BodyFormat = m.BodyFormat
 621	}
 622	return o
 623}
 624
 625func runMRList(c *Ctx, args []string) int {
 626	if usesQuery(args) {
 627		args, p, code := parsePageFlags(c, args, "query", false)
 628		if code >= 0 {
 629			return code
 630		}
 631		fl, err := c.parseArgs(args, flagSpec{Values: []string{"--query", "--q", "--state", "--label", "--assignee", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
 632		if err != nil {
 633			return c.fail(protocol.ExitUsage, "%v", err)
 634		}
 635		return listByQuery(c, fl, "mr", p)
 636	}
 637	args, p, code := parsePageFlags(c, args, "mr", true)
 638	if code >= 0 {
 639		return code
 640	}
 641	f := store.MRFilter{State: "open"}
 642	fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.Usage})
 643	if err != nil {
 644		return c.fail(protocol.ExitUsage, "%v", err)
 645	}
 646	path := fl.pos(0)
 647	if fl.Has("--state") {
 648		f.State = fl.Value("--state")
 649	}
 650	f.Label, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--author"), fl.Value("--milestone")
 651	f.Search = fl.Value("--search")
 652	if fl.Has("--search") {
 653		if err := validQuery(f.Search); err != nil {
 654			return c.failInput(err)
 655		}
 656	}
 657	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
 658	if path == "" || !valid[f.State] {
 659		return c.usage()
 660	}
 661	repo, code := resolveRepo(c, path, policy.CanRead)
 662	if code >= 0 {
 663		return code
 664	}
 665	f.Limit, f.Before = p.queryLimit(), p.keyInt()
 666	mrs, err := c.Store.QueryMRs(repo.ID, f)
 667	if err != nil {
 668		return c.fail(protocol.ExitFailure, "%v", err)
 669	}
 670	mrs, next := trimPage(p, mrs, "mr", func(m store.MR) string {
 671		return strconv.FormatInt(m.Number, 10)
 672	})
 673	var ds []mrOut
 674	for _, m := range mrs {
 675		o := mrToOut(repo, m, false)
 676		o.StackedOn, _ = stackOf(c, repo, m)
 677		ds = append(ds, o)
 678	}
 679	return c.emitPage(p, ds, next, func(w io.Writer) {
 680		tb := c.table(w, "!", "STATE", "TITLE", "REF")
 681		for _, d := range ds {
 682			state := d.State
 683			if d.Draft {
 684				state = "draft"
 685			}
 686			cells := []cell{cRef(fmt.Sprintf("!%d", d.Number)), cState(state), cFlex(d.Title), cText(fmt.Sprintf("%s -> %s", d.Source, d.TargetRef))}
 687			if d.StackedOn != nil {
 688				cells = append(cells, cText(fmt.Sprintf("stacked on !%d", d.StackedOn.Number)))
 689			}
 690			tb.row(cells...)
 691		}
 692		tb.flush()
 693	})
 694}
 695
 696// byWhom renders " by <user>", or nothing when the actor is unknown — an
 697// imported merge request carries a time but no local account.
 698func byWhom(user string) string {
 699	if user == "" {
 700		return ""
 701	}
 702	return " by " + user
 703}
 704
 705func runMRShow(c *Ctx, args []string) int {
 706	repo, mr, code := mrRef(c, args, policy.CanRead)
 707	if code >= 0 {
 708		return code
 709	}
 710	if len(args) != 2 {
 711		return c.usage()
 712	}
 713	comments, err := c.Store.ListMRComments(mr.ID)
 714	if err != nil {
 715		return c.fail(protocol.ExitFailure, "%v", err)
 716	}
 717	reviews, err := c.Store.ListMRReviews(mr.ID)
 718	if err != nil {
 719		return c.fail(protocol.ExitFailure, "%v", err)
 720	}
 721	statuses, combined, err := c.Store.ChecksForCommit(repo.ID, mr.HeadSHA)
 722	if err != nil {
 723		return c.fail(protocol.ExitFailure, "%v", err)
 724	}
 725	unresolved, err := c.Store.UnresolvedThreadCount(mr.ID)
 726	if err != nil {
 727		return c.fail(protocol.ExitFailure, "%v", err)
 728	}
 729	var checks []CheckOut
 730	for _, st := range statuses {
 731		out := CheckOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt}
 732		if st.Duration > 0 {
 733			out.Duration = st.Duration.String()
 734		}
 735		checks = append(checks, out)
 736	}
 737	rx, err := c.Store.ReactionCounts("mr", mr.ID, c.User.ID)
 738	if err != nil {
 739		return c.fail(protocol.ExitFailure, "%v", err)
 740	}
 741	var cs []commentOut
 742	for _, cm := range comments {
 743		cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])})
 744	}
 745	var rs []ReviewOut
 746	counts := ReviewersWhoCount(c.Store, repo, reviews)
 747	for _, r := range reviews {
 748		rs = append(rs, ReviewOut{r.Reviewer, r.Verdict, r.Stale, counts[r.Reviewer], r.CreatedAt})
 749	}
 750	// The commits this MR carries: base..head, the diff's range.
 751	var commits []CommitOut
 752	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 753	base := mr.MergedBase
 754	if base == "" {
 755		if b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number)); err == nil {
 756			base = b
 757		}
 758	}
 759	if base != "" {
 760		if shas, err := gitutil.RevListRange(dir, base, mrHeadRef(mr.Number)); err == nil {
 761			for _, sha := range shas {
 762				subject := ""
 763				if raw, err := gitutil.ReadCommit(dir, sha); err == nil {
 764					if parsed, err := sig.ParseCommit(raw); err == nil {
 765						subject = parsed.Subject
 766					}
 767				}
 768				commits = append(commits, CommitOut{sha, subject})
 769			}
 770		}
 771	}
 772	d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined,
 773		UnresolvedThreads: unresolved, Commits: commits, Reactions: reactionsOut(rx[0]), Comments: cs, Reviews: rs}
 774	d.StackedOn, d.Stacked = stackOf(c, repo, mr)
 775	if mr.State == "open" || mr.State == "source_gone" {
 776		if targetSHA, err := gitutil.ResolveRef(dir, "refs/heads/"+mr.TargetRef); err == nil {
 777			if g, err := MergeGates(c.Store, repo, mr, dir, targetSHA, mr.HeadSHA); err == nil {
 778				d.Gates = &g
 779			}
 780		}
 781	}
 782	return c.emit(d, func(w io.Writer) {
 783		state := d.State
 784		if d.Draft {
 785			state = "draft"
 786		}
 787		v := c.view(w)
 788		v.title(fmt.Sprintf("!%d", d.Number), d.Title, state)
 789
 790		stackedOn, stacked := "", ""
 791		if d.StackedOn != nil {
 792			stackedOn = fmt.Sprintf("!%d %s", d.StackedOn.Number, d.StackedOn.Title)
 793		}
 794		for _, k := range d.Stacked {
 795			if stacked != "" {
 796				stacked += ", "
 797			}
 798			stacked += fmt.Sprintf("!%d %s", k.Number, k.Title)
 799		}
 800		merged, closed, superseded := "", "", ""
 801		if d.MergedAt != "" {
 802			merged = c.when(d.MergedAt) + byWhom(d.MergedBy)
 803		}
 804		if d.ClosedAt != "" {
 805			closed = c.when(d.ClosedAt) + byWhom(d.ClosedBy)
 806		}
 807		if d.SupersededBy != 0 {
 808			superseded = fmt.Sprintf("!%d", d.SupersededBy)
 809		}
 810		gates := ""
 811		if g := d.Gates; g != nil {
 812			ff := "fast-forward possible"
 813			if !g.FastForward {
 814				ff = "fast-forward not possible"
 815			}
 816			if len(g.Unmet) == 0 {
 817				gates = "met; " + ff
 818			} else {
 819				gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff)
 820			}
 821		}
 822		queued, waiting := "", ""
 823		if q := d.Queued; q != nil {
 824			queued = "by " + q.By
 825			if q.Strategy != "" {
 826				queued += " (" + q.Strategy + ")"
 827			}
 828			queued += ", " + c.when(q.QueuedAt)
 829			waiting = q.Reason
 830		}
 831		unresolved := ""
 832		if d.UnresolvedThreads > 0 {
 833			unresolved = fmt.Sprintf("%d", d.UnresolvedThreads)
 834		}
 835		// One fields call for every one-row fact, including the unmet
 836		// gates and the commit/check/review that only has a single row
 837		// to show: separate calls each compute their own key width, so
 838		// keeping them in one call keeps every key aligned.
 839		kv := []string{
 840			"author", d.Author + ", " + c.when(d.CreatedAt),
 841			"source", fmt.Sprintf("%s -> %s", d.Source, d.TargetRef),
 842			"head", fmt.Sprintf("%.10s", d.HeadSHA),
 843			"milestone", d.Milestone,
 844			"labels", strings.Join(d.Labels, ", "),
 845			"reviewers", strings.Join(d.ReviewRequests, ", "),
 846			"stacked on", stackedOn,
 847			"stacked", stacked,
 848			"merged", merged,
 849			"closed", closed,
 850			"superseded by", superseded,
 851			"queued to merge", queued,
 852			"waiting on", waiting,
 853			"unresolved threads", unresolved,
 854			"gates", gates,
 855		}
 856		if g := d.Gates; g != nil {
 857			for _, u := range g.Unmet {
 858				kv = append(kv, "unmet", u)
 859			}
 860		}
 861		if len(commits) == 1 {
 862			kv = append(kv, "commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject))
 863		}
 864		if len(checks) == 1 {
 865			x := checks[0]
 866			dur := ""
 867			if x.Duration != "" {
 868				dur = " in " + x.Duration
 869			}
 870			kv = append(kv, "check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur))
 871		}
 872		if len(rs) == 1 {
 873			kv = append(kv, "review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt))
 874		}
 875		kv = append(kv, "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)))
 876		v.fields(kv...)
 877
 878		v.body(d.Body, d.BodyFormat)
 879		v.reactions(d.Reactions)
 880
 881		if len(commits) > 1 {
 882			v.section(fmt.Sprintf("commits (%d)", len(commits)))
 883			tb := c.table(w, "SHA", "SUBJECT")
 884			for _, cm := range commits {
 885				tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject))
 886			}
 887			tb.flush()
 888		}
 889
 890		if len(checks) > 1 {
 891			v.section(fmt.Sprintf("checks (%d)", len(checks)))
 892			tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
 893			for _, x := range checks {
 894				tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt)))
 895			}
 896			tb.flush()
 897		}
 898
 899		if len(rs) > 1 {
 900			v.section(fmt.Sprintf("reviews (%d)", len(rs)))
 901			tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
 902			for _, r := range rs {
 903				verdict := r.Verdict
 904				if r.Stale {
 905					verdict += " (stale)"
 906				}
 907				if !r.Counts {
 908					verdict += " (advisory)"
 909				}
 910				tb.row(cText(r.Reviewer), cState(verdict), cText(c.when(r.CreatedAt)))
 911			}
 912			tb.flush()
 913		}
 914
 915		events := false
 916		for _, cm := range cs {
 917			if cm.Kind != "system" {
 918				continue
 919			}
 920			if !events {
 921				io.WriteString(w, "\n")
 922				events = true
 923			}
 924			v.event(cm.Body, cm.BodyFormat, cm.CreatedAt)
 925		}
 926		for _, cm := range cs {
 927			if cm.Kind == "system" {
 928				continue
 929			}
 930			v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
 931			v.reactions(cm.Reactions)
 932		}
 933	})
 934}
 935
 936// reviewLine renders one review as fields prose: "reviewer verdict
 937// (stale) (advisory) at when".
 938func reviewLine(r ReviewOut) string {
 939	s := r.Reviewer + " " + r.Verdict
 940	if r.Stale {
 941		s += " (stale)"
 942	}
 943	if !r.Counts {
 944		s += " (advisory: no write access)"
 945	}
 946	return s
 947}
 948
 949func runMRDiff(c *Ctx, args []string) int {
 950	repo, mr, code := mrRef(c, args, policy.CanRead)
 951	if code >= 0 {
 952		return code
 953	}
 954	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 955	head := mrHeadRef(mr.Number)
 956	if _, err := gitutil.ResolveRef(dir, head); err != nil {
 957		return c.fail(protocol.ExitFailure, "the head of !%d is no longer in the repository; its diff is not available", mr.Number)
 958	}
 959	// After a merge (especially fast-forward) the live merge-base equals
 960	// the head and the diff would vanish; use the recorded base instead.
 961	base := mr.MergedBase
 962	if base == "" {
 963		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
 964		if err != nil {
 965			return c.fail(protocol.ExitFailure, "%v", err)
 966		}
 967		base = b
 968	}
 969	patch, truncated, err := gitutil.Diff(dir, base, head, 4<<20)
 970	if err != nil {
 971		return c.fail(protocol.ExitFailure, "%v", err)
 972	}
 973	fmt.Fprint(c.Stdout, patch)
 974	if truncated {
 975		fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB; fetch the branch for the rest")
 976	}
 977	return protocol.ExitOK
 978}
 979
 980func runMREdit(c *Ctx, args []string) int {
 981	rest, title, body, format, fl, code := editText(c, args, "mr", "--superseded-by")
 982	if code >= 0 {
 983		return code
 984	}
 985	repo, mr, code := mrRef(c, rest, policy.CanRead)
 986	if code >= 0 {
 987		return code
 988	}
 989	if code := refuseArchived(c, repo); code >= 0 {
 990		return code
 991	}
 992	if code := authorOrWrite(c, repo, mr.Author, "edit this merge request"); code >= 0 {
 993		return code
 994	}
 995	var clearSuperseded bool
 996	var supersededBy int64
 997	if fl.Has("--superseded-by") {
 998		if mr.State != "closed" {
 999			return c.fail(protocol.ExitUsage, "only a closed merge request can be superseded")
1000		}
1001		if v := fl.Value("--superseded-by"); v == "none" {
1002			clearSuperseded = true
1003		} else {
1004			supersededBy, code = resolveSupersededBy(c, repo, mr.Number, v)
1005			if code >= 0 {
1006				return code
1007			}
1008		}
1009	}
1010	if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
1011		return c.fail(protocol.ExitFailure, "%v", err)
1012	}
1013	if clearSuperseded {
1014		if err := c.Store.SetSupersededBy(mr.ID, 0); err != nil {
1015			return c.fail(protocol.ExitFailure, "%v", err)
1016		}
1017	} else if supersededBy != 0 {
1018		if err := c.Store.SetSupersededBy(mr.ID, supersededBy); err != nil {
1019			return c.fail(protocol.ExitFailure, "%v", err)
1020		}
1021	}
1022	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.edited", fmt.Sprintf(`{"number":%d}`, mr.Number))
1023	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
1024		fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
1025	})
1026}
1027
1028// runMRRetarget moves an open merge request onto another branch of the
1029// same repository.
1030func runMRRetarget(c *Ctx, args []string) int {
1031	if len(args) != 3 {
1032		return c.usage()
1033	}
1034	repo, mr, code := mrRef(c, args[:2], policy.CanRead)
1035	if code >= 0 {
1036		return code
1037	}
1038	if code := refuseArchived(c, repo); code >= 0 {
1039		return code
1040	}
1041	if code := authorOrWrite(c, repo, mr.Author, "retarget this merge request"); code >= 0 {
1042		return code
1043	}
1044	if mr.State == "merged" || mr.State == "closed" {
1045		return c.fail(protocol.ExitUsage, "!%d is %s; only an open merge request can be retargeted", mr.Number, mr.State)
1046	}
1047	target := args[2]
1048	if target == mr.TargetRef {
1049		return c.fail(protocol.ExitUsage, "!%d already targets %s", mr.Number, target)
1050	}
1051	if mr.SourceRepoID == repo.ID && target == mr.SourceRef {
1052		return c.fail(protocol.ExitUsage, "%s is the source branch of !%d", target, mr.Number)
1053	}
1054	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1055	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+target); err != nil {
1056		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", target, repo.Path())
1057	}
1058	// The diff, the commit list and the merge gates all derive their base
1059	// from the target on every read, so the only thing to check here is
1060	// that a base exists at all: without one there is nothing to show and
1061	// nothing to merge.
1062	base, err := gitutil.MergeBase(dir, "refs/heads/"+target, mrHeadRef(mr.Number))
1063	if err != nil || base == "" {
1064		return c.fail(protocol.ExitUsage, "%s shares no history with the head of !%d", target, mr.Number)
1065	}
1066	old := mr.TargetRef
1067	if err := c.Store.SetMRTarget(mr.ID, target); err != nil {
1068		return c.fail(protocol.ExitFailure, "%v", err)
1069	}
1070	c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
1071	// A queued merge was asked for against the old target. Someone who
1072	// could not merge it themselves does not get to point it elsewhere.
1073	if mr.QueuedAt != "" {
1074		grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1075		if err != nil || !policy.CanWrite(c.User, repo, grant) {
1076			mergeQueueMu.Lock()
1077			dequeueWithReason(c.Store, mr, fmt.Sprintf("%s retargeted it to %s and cannot merge into %s", c.User.Username, target, repo.Path()))
1078			mergeQueueMu.Unlock()
1079		}
1080	}
1081	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted",
1082		fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target))
1083	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1084		notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1085			subject: mrSubject(repo, mr.Number, mr.Title),
1086			action:  fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target),
1087			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1088	}
1089	return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) {
1090		fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base)
1091	})
1092}
1093
1094func runMRComment(c *Ctx, args []string) int {
1095	return runComment(c, args, mrThread, "mr",
1096		func(rest []string) (store.Repo, int64, int64, string, int) {
1097			repo, mr, code := mrRef(c, rest, policy.CanRead)
1098			return repo, mr.ID, mr.Number, mr.Title, code
1099		},
1100		c.Store.AddMRComment, c.Store.MRParticipants)
1101}
1102
1103func runMRReview(c *Ctx, args []string) int {
1104	verdict, discard := "", false
1105	var rest []string
1106	for _, a := range args {
1107		switch a {
1108		case "--approve":
1109			verdict = "approve"
1110		case "--request-changes":
1111			verdict = "request_changes"
1112		case "--comment":
1113			verdict = "comment"
1114		case "--discard":
1115			discard = true
1116		default:
1117			rest = append(rest, a)
1118		}
1119	}
1120	if discard && verdict != "" {
1121		return c.fail(protocol.ExitUsage, "--discard throws the batch away; it takes no verdict")
1122	}
1123	if verdict == "" && !discard {
1124		return c.usage()
1125	}
1126	repo, mr, code := mrRef(c, rest, policy.CanRead)
1127	if code >= 0 {
1128		return code
1129	}
1130	if code := refuseArchived(c, repo); code >= 0 {
1131		return code
1132	}
1133	if mr.State != "open" {
1134		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
1135	}
1136	// Throwing the batch away is not a review, so it stops here: no
1137	// verdict, no event, nobody told about comments nobody ever saw.
1138	if discard {
1139		n, err := c.Store.DiscardPendingComments(mr.ID, c.User.ID)
1140		if err != nil {
1141			return c.fail(protocol.ExitFailure, "%v", err)
1142		}
1143		return c.emit(map[string]any{"number": mr.Number, "discarded": n}, func(w io.Writer) {
1144			fmt.Fprintf(w, "discarded %d pending comment(s) on %s!%d\n", n, repo.Path(), mr.Number)
1145		})
1146	}
1147	if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
1148		return c.fail(protocol.ExitFailure, "%v", err)
1149	}
1150	// The batch the reviewer composed becomes visible with the verdict,
1151	// which is what makes it one review rather than a trickle.
1152	published, err := c.Store.PublishPendingComments(mr.ID, c.User.ID)
1153	if err != nil {
1154		return c.fail(protocol.ExitFailure, "%v", err)
1155	}
1156	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed",
1157		fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict))
1158	TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1159	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1160		notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1161			subject: mrSubject(repo, mr.Number, mr.Title),
1162			action:  reviewAction(mr.Number, verdict, published),
1163			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1164	}
1165	// Whether the merge gates will count this verdict, said now rather
1166	// than at the refusal (#199).
1167	counts := ReviewersWhoCount(c.Store, repo, []store.MRReview{{Reviewer: c.User.Username}})[c.User.Username]
1168	return c.emit(map[string]any{"number": mr.Number, "verdict": verdict, "published": published, "counts": counts}, func(w io.Writer) {
1169		fmt.Fprintf(w, "reviewed %s!%d: %s", repo.Path(), mr.Number, verdict)
1170		if published > 0 {
1171			fmt.Fprintf(w, " (%d comment(s))", published)
1172		}
1173		if !counts {
1174			fmt.Fprintf(w, " (advisory: no write access on %s, so the merge gates do not count it)", repo.Path())
1175		}
1176		fmt.Fprintln(w)
1177	})
1178}
1179
1180// runMRReviewRequest is issue assign's counterpart for merge requests: it
1181// pushes a merge request into a specific person's review queue and inbox
1182// directly, rather than waiting for them to be otherwise involved (#145).
1183func runMRReviewRequest(c *Ctx, args []string) int {
1184	rest, adds, removes, err := addRemoveFlags(args)
1185	if err != nil {
1186		return c.failInput(err)
1187	}
1188	if len(adds)+len(removes) == 0 {
1189		return c.usage()
1190	}
1191	repo, mr, code := mrRef(c, rest, policy.CanWrite)
1192	if code >= 0 {
1193		return code
1194	}
1195	if code := refuseArchived(c, repo); code >= 0 {
1196		return code
1197	}
1198	resolve := func(name string) (store.User, int) {
1199		u, err := c.Store.UserByUsername(name)
1200		if errors.Is(err, store.ErrNotFound) {
1201			return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
1202		}
1203		if err != nil {
1204			return u, c.fail(protocol.ExitFailure, "%v", err)
1205		}
1206		return u, -1
1207	}
1208	// Notified on every return, not just success: a name later in --add
1209	// that fails to resolve or lacks access must not silence the people
1210	// already added earlier in the same call.
1211	var added []store.User
1212	defer func() {
1213		if len(added) == 0 {
1214			return
1215		}
1216		ids := make([]int64, len(added))
1217		for i, u := range added {
1218			ids[i] = u.ID
1219		}
1220		notify(c, ids, notice{repo: repo, kind: "mr", number: mr.Number,
1221			subject: mrSubject(repo, mr.Number, mr.Title),
1222			action:  fmt.Sprintf("asked for a review on !%d", mr.Number),
1223			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1224	}()
1225	for _, name := range adds {
1226		u, code := resolve(name)
1227		if code >= 0 {
1228			return code
1229		}
1230		// A review request that lands nowhere the recipient can see it is
1231		// worse than useless: it looks like the ask went through.
1232		grant, err := c.Store.AccessRole(repo.ID, u.ID)
1233		if err != nil {
1234			return c.fail(protocol.ExitFailure, "%v", err)
1235		}
1236		if !policy.CanRead(u, repo, grant) {
1237			return c.fail(protocol.ExitDenied, "%s cannot read %s", name, repo.Path())
1238		}
1239		if err := c.Store.SetMRReviewRequest(mr.ID, u.ID, true); err != nil {
1240			return c.fail(protocol.ExitFailure, "%v", err)
1241		}
1242		added = append(added, u)
1243	}
1244	for _, name := range removes {
1245		u, code := resolve(name)
1246		if code >= 0 {
1247			return code
1248		}
1249		if err := c.Store.SetMRReviewRequest(mr.ID, u.ID, false); err != nil {
1250			if errors.Is(err, store.ErrNotFound) {
1251				return c.fail(protocol.ExitNotFound, "%s is not a requested reviewer", name)
1252			}
1253			return c.fail(protocol.ExitFailure, "%v", err)
1254		}
1255	}
1256	updated, err := c.Store.MRByNumber(repo.ID, mr.Number)
1257	if err != nil {
1258		return c.fail(protocol.ExitFailure, "%v", err)
1259	}
1260	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.review_requested",
1261		fmt.Sprintf(`{"number":%d,"reviewers":%s}`, mr.Number, jsonStrings(updated.ReviewRequests)))
1262	return c.emit(map[string]any{"number": mr.Number, "reviewers": updated.ReviewRequests}, func(w io.Writer) {
1263		fmt.Fprintf(w, "requested reviewers on %s!%d: %s\n", repo.Path(), mr.Number, strings.Join(updated.ReviewRequests, ", "))
1264	})
1265}
1266
1267// runMRLabel is issue label's counterpart for merge requests: the label
1268// set is the repository's (or its org's), shared with the issues (#231).
1269func runMRLabel(c *Ctx, args []string) int {
1270	rest, adds, removes, err := addRemoveFlags(args)
1271	if err != nil {
1272		return c.failInput(err)
1273	}
1274	if len(adds)+len(removes) == 0 {
1275		return c.usage()
1276	}
1277	repo, mr, code := mrRef(c, rest, policy.CanWrite)
1278	if code >= 0 {
1279		return code
1280	}
1281	if code := refuseArchived(c, repo); code >= 0 {
1282		return code
1283	}
1284	for _, l := range adds {
1285		if err := c.Store.SetMRLabel(repo, mr.ID, l, true); err != nil {
1286			return c.fail(protocol.ExitFailure, "%v", err)
1287		}
1288	}
1289	for _, l := range removes {
1290		if err := c.Store.SetMRLabel(repo, mr.ID, l, false); err != nil {
1291			if errors.Is(err, store.ErrNotFound) {
1292				return c.fail(protocol.ExitNotFound, "%v", err)
1293			}
1294			return c.fail(protocol.ExitFailure, "%v", err)
1295		}
1296	}
1297	updated, err := c.Store.MRByNumber(repo.ID, mr.Number)
1298	if err != nil {
1299		return c.fail(protocol.ExitFailure, "%v", err)
1300	}
1301	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.labeled",
1302		fmt.Sprintf(`{"number":%d,"labels":%s}`, mr.Number, jsonStrings(updated.Labels)))
1303	return c.emit(map[string]any{"number": mr.Number, "labels": updated.Labels}, func(w io.Writer) {
1304		fmt.Fprintf(w, "labels on %s!%d: %s\n", repo.Path(), mr.Number, strings.Join(updated.Labels, ", "))
1305	})
1306}
1307
1308func runMRMerge(c *Ctx, args []string) int {
1309	f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, Bools: []string{"--when-ready", "--cancel"},
1310		MaxPos: -1, Usage: c.Cmd.Usage})
1311	if err != nil {
1312		return c.fail(protocol.ExitUsage, "%v", err)
1313	}
1314	strategy, rest := f.Value("--strategy"), f.Pos
1315	valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
1316	if !valid[strategy] {
1317		return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
1318	}
1319	if f.Has("--cancel") && (f.Has("--when-ready") || f.Has("--strategy")) {
1320		return c.fail(protocol.ExitUsage, "--cancel takes no other flag")
1321	}
1322	repo, mr, code := mrRef(c, rest, policy.CanWrite)
1323	if code >= 0 {
1324		return code
1325	}
1326	switch {
1327	case f.Has("--cancel"):
1328		return cancelQueuedMerge(c, repo, mr)
1329	case f.Has("--when-ready"):
1330		return queueMerge(c, repo, mr, strategy)
1331	}
1332	return mergeMR(c, repo, mr, strategy)
1333}
1334
1335// mergeMR merges mr now as c.User, or refuses saying why. The queue runs
1336// it too, as the user who queued the merge.
1337func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
1338	if code := refuseArchived(c, repo); code >= 0 {
1339		return code
1340	}
1341	if mr.State != "open" && mr.State != "source_gone" {
1342		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
1343	}
1344
1345	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1346	targetRef := "refs/heads/" + mr.TargetRef
1347	targetSHA, err := gitutil.ResolveRef(dir, targetRef)
1348	if err != nil {
1349		return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
1350	}
1351	headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
1352	if err != nil {
1353		return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
1354	}
1355
1356	// Merge gates: draft, checks, approvals, CODEOWNERS, resolved threads,
1357	// all reported at once.
1358	if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
1359		return code
1360	}
1361
1362	upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
1363	if err != nil {
1364		return c.fail(protocol.ExitFailure, "%v", err)
1365	}
1366	if upToDate {
1367		// The head is already on the target: merged by hand and pushed, or
1368		// a merge whose ref update landed and whose record did not. Record
1369		// it rather than refuse, so a merge request cannot be stuck open
1370		// with no way to close it as merged (#108).
1371		if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil {
1372			return c.fail(protocol.ExitFailure, "%v", err)
1373		}
1374		c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d}`, mr.Number))
1375		return c.emit(map[string]any{"number": mr.Number, "strategy": "recorded", "sha": headSHA}, func(w io.Writer) {
1376			fmt.Fprintf(w, "%s already contains !%d; recorded as merged at %.10s\n", mr.TargetRef, mr.Number, headSHA)
1377		})
1378	}
1379	ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
1380	if err != nil {
1381		return c.fail(protocol.ExitFailure, "%v", err)
1382	}
1383
1384	// Signature policy matrix: with require_signed_commits, only
1385	// fast-forward is allowed — squash, rebase-replay, and merge commits
1386	// are all server-created and unsigned, violating the branch's own
1387	// policy — and every landed commit must be verified. An explicit
1388	// rebase when fast-forward is already possible IS a fast-forward
1389	// (nothing is rewritten), so it stays legal.
1390	if repo.Settings.RequireSignedCommits {
1391		if c.Source == queueSource && !ffPossible {
1392			return c.fail(protocol.ExitDenied,
1393				"%s is behind %s and %s requires signed commits, so the server cannot rebase it; rebase and push, and the merge stays queued",
1394				mr.SourceRef, mr.TargetRef, repo.Path())
1395		}
1396		if strategy == "merge" || strategy == "squash" || !ffPossible {
1397			return c.fail(protocol.ExitDenied,
1398				"%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
1399				repo.Path(), mr.SourceRef, mr.TargetRef)
1400		}
1401		strategy = "ff"
1402		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
1403		if err != nil {
1404			return c.fail(protocol.ExitFailure, "%v", err)
1405		}
1406		for _, sha := range commits {
1407			raw, err := gitutil.ReadCommit(dir, sha)
1408			if err != nil {
1409				return c.fail(protocol.ExitFailure, "%v", err)
1410			}
1411			parsed, err := sigParse(raw)
1412			if err != nil {
1413				return c.fail(protocol.ExitFailure, "%v", err)
1414			}
1415			res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
1416			if err != nil {
1417				return c.fail(protocol.ExitFailure, "%v", err)
1418			}
1419			if res.State != "verified" {
1420				return c.fail(protocol.ExitDenied,
1421					"%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
1422			}
1423		}
1424	}
1425	if strategy == "" {
1426		if ffPossible {
1427			strategy = "ff"
1428		} else {
1429			strategy = "merge"
1430		}
1431	}
1432	if strategy == "rebase" && ffPossible {
1433		// Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
1434		// and taking it keeps the original commits and their signatures.
1435		strategy = "ff"
1436	}
1437
1438	// Every server-created commit needs the merger's verified identity.
1439	mergerEmail := ""
1440	if strategy != "ff" {
1441		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
1442		if err != nil {
1443			return c.fail(protocol.ExitFailure, "%v", err)
1444		}
1445		if email == "" {
1446			return c.fail(protocol.ExitDenied,
1447				"%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
1448		}
1449		mergerEmail = email
1450	}
1451
1452	var newSHA string
1453	switch strategy {
1454	case "ff":
1455		if !ffPossible {
1456			return c.fail(protocol.ExitUsage,
1457				"fast-forward not possible: %s has diverged from the MR head; merge with the merge strategy, or rebase and push again", mr.TargetRef)
1458		}
1459		newSHA = headSHA
1460
1461	case "merge":
1462		tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
1463		if err != nil {
1464			return c.fail(protocol.ExitFailure, "%v", err)
1465		}
1466		if conflict {
1467			return c.fail(protocol.ExitUsage,
1468				"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
1469		}
1470		msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
1471		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
1472		if err != nil {
1473			return c.fail(protocol.ExitFailure, "%v", err)
1474		}
1475
1476	case "squash":
1477		// One new commit with the merged tree. Authorship credit goes to
1478		// the MR author (their verified identity when they have one); the
1479		// committer is the merger.
1480		tree := ""
1481		if ffPossible {
1482			t, err := gitutil.ResolveTree(dir, headSHA)
1483			if err != nil {
1484				return c.fail(protocol.ExitFailure, "%v", err)
1485			}
1486			tree = t
1487		} else {
1488			t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
1489			if err != nil {
1490				return c.fail(protocol.ExitFailure, "%v", err)
1491			}
1492			if conflict {
1493				return c.fail(protocol.ExitUsage,
1494					"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
1495			}
1496			tree = t
1497		}
1498		authorName, authorEmail := c.User.Username, mergerEmail
1499		if author, err := c.Store.UserByUsername(mr.Author); err == nil {
1500			if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
1501				authorName, authorEmail = author.Username, ae
1502			}
1503		}
1504		msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
1505		if mr.Body != "" {
1506			msg += "\n\n" + mr.Body
1507		}
1508		var err error
1509		newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
1510			authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
1511		if err != nil {
1512			return c.fail(protocol.ExitFailure, "%v", err)
1513		}
1514
1515	case "rebase":
1516		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
1517		if err != nil {
1518			return c.fail(protocol.ExitFailure, "%v", err)
1519		}
1520		// Oldest first.
1521		for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
1522			commits[i], commits[j] = commits[j], commits[i]
1523		}
1524		onto := targetSHA
1525		for _, sha := range commits {
1526			parents, err := gitutil.CommitParents(dir, sha)
1527			if err != nil {
1528				return c.fail(protocol.ExitFailure, "%v", err)
1529			}
1530			if len(parents) > 1 {
1531				return c.fail(protocol.ExitUsage,
1532					"the MR contains merge commit %.10s; a rebase merge needs linear history — choose the merge or squash strategy", sha)
1533			}
1534			base := onto // root commit: replay against the new tip itself
1535			if len(parents) == 1 {
1536				base = parents[0]
1537			}
1538			tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
1539			if err != nil {
1540				return c.fail(protocol.ExitFailure, "%v", err)
1541			}
1542			if conflict {
1543				return c.fail(protocol.ExitUsage,
1544					"commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
1545			}
1546			aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
1547			if err != nil {
1548				return c.fail(protocol.ExitFailure, "%v", err)
1549			}
1550			msg, err := gitutil.CommitMessage(dir, sha)
1551			if err != nil {
1552				return c.fail(protocol.ExitFailure, "%v", err)
1553			}
1554			onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
1555				aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
1556			if err != nil {
1557				return c.fail(protocol.ExitFailure, "%v", err)
1558			}
1559		}
1560		newSHA = onto
1561	}
1562
1563	// A stacked merge request's diff is against this branch. After a
1564	// fast-forward or merge commit the same commits are on the target and
1565	// its diff is unchanged there; after a squash or rebase they are not,
1566	// and the stack would carry this merge request's changes a second
1567	// time. Refuse rather than leave the stack wrong.
1568	var stack []store.MR
1569	if mr.SourceRepoID == repo.ID {
1570		stack, _ = c.Store.OpenMRsByTarget(repo.ID, mr.SourceRef)
1571	}
1572	if len(stack) > 0 && (strategy == "squash" || strategy == "rebase") {
1573		var nums []string
1574		for _, k := range stack {
1575			nums = append(nums, fmt.Sprintf("!%d", k.Number))
1576		}
1577		return c.fail(protocol.ExitUsage,
1578			"%s is stacked on by %s; a %s merge rewrites the commits they build on. Merge with the fast-forward or merge strategy, or merge the stack into %s first",
1579			fmt.Sprintf("!%d", mr.Number), strings.Join(nums, ", "), strategy, mr.SourceRef)
1580	}
1581
1582	// CAS so a concurrent push between our read and this write fails the
1583	// merge instead of silently discarding the push.
1584	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
1585		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
1586	}
1587	if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil {
1588		return c.fail(protocol.ExitFailure, "%v", err)
1589	}
1590	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
1591	// The stack moves up: whatever targeted this branch now targets what
1592	// it merged into, reviews intact, since that diff is the one they
1593	// were of.
1594	for _, k := range stack {
1595		if err := c.Store.RetargetKeepingReviews(k.ID, mr.TargetRef); err != nil {
1596			continue
1597		}
1598		c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number))
1599		if parts, err := c.Store.MRParticipants(k.ID); err == nil {
1600			notify(c, parts, notice{repo: repo, kind: "mr", number: k.Number,
1601				subject: mrSubject(repo, k.Number, k.Title),
1602				action:  fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number),
1603				path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)})
1604		}
1605	}
1606	// Merges bypass receive-pack, so the commit-message issue actions
1607	// (closes #N, references) run here for the newly landed commits. The
1608	// description is scanned after them, so a commit wins the attribution
1609	// when both name the same issue.
1610	if mr.TargetRef == repo.DefaultBranch {
1611		ProcessCommitMessages(c.Store, dir, repo, c.User.ID, c.Scope, targetSHA, newSHA)
1612		ProcessMRDescription(c.Store, repo, mr, c.User.ID, c.Scope)
1613		RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
1614		c.Store.RequestSymbolIndex(repo.ID, false)
1615	}
1616	// A merge moves the ref directly, so it never reaches post-receive and
1617	// none of the ref-update work fires on its own. The event webhooks
1618	// subscribe to, and the branch's CI jobs, happen here instead.
1619	c.Store.RecordEvent(repo.ID, c.User.ID, "push", fmt.Sprintf(
1620		`{"ref":%q,"old":%q,"new":%q,"forced":false,"deleted":false}`,
1621		targetRef, targetSHA, newSHA))
1622	QueueBranchBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL,
1623		repo, c.User.ID, mr.TargetRef, targetSHA, newSHA, time.Now())
1624	c.Store.MarkMirrorsDirty(repo.ID, "push")
1625	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1626		notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1627			subject: mrSubject(repo, mr.Number, mr.Title),
1628			action:  fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy),
1629			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1630	}
1631	return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
1632		fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
1633	})
1634}
1635
1636// reviewGates refuses a merge whose gates are not all met, naming every
1637// unmet one. Returns -1 to proceed.
1638func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
1639	g, err := MergeGates(c.Store, repo, mr, dir, targetSHA, headSHA)
1640	if err != nil {
1641		return c.fail(protocol.ExitFailure, "%v", err)
1642	}
1643	if len(g.Unmet) > 0 {
1644		return c.fail(protocol.ExitDenied, "%s", strings.Join(g.Unmet, "; "))
1645	}
1646	return -1
1647}
1648
1649// checksExpected reports whether anything was going to report a status
1650// on this head. A repository with no CI configuration and no history of
1651// statuses can never satisfy require_checks, and refusing its merges
1652// leaves no remedy but turning the setting off. Two things say a report
1653// was coming: a .gitbay/ci.yml at the head with a job a push runs, and a
1654// status having ever been recorded in the repository, which is how a
1655// repository reporting from outside through `status set` looks.
1656func checksExpected(st *store.Store, repoID int64, dir, headSHA string) bool {
1657	if seen, err := st.RepoHasStatuses(repoID); err != nil || seen {
1658		return true
1659	}
1660	return headRunsJobs(dir, headSHA)
1661}
1662
1663// headRunsJobs reports whether a push of this head would have queued or
1664// skipped a job, and so left it a status. A configuration that will not
1665// parse counts as running jobs: the push recorded a ci/config failure
1666// for it, so the head is not silent and this is not the branch that
1667// decides.
1668func headRunsJobs(dir, headSHA string) bool {
1669	raw, err := gitutil.ReadBlob(dir, headSHA, ci.ConfigPath, 1<<16)
1670	if err != nil {
1671		return false
1672	}
1673	jobs, err := ci.Parse(raw)
1674	if err != nil {
1675		return true
1676	}
1677	for _, j := range jobs {
1678		if j.Tags == "" && j.Schedule == "" {
1679			return true
1680		}
1681	}
1682	return false
1683}
1684
1685// MergeGates computes where a merge request stands against its
1686// repository's gates: draft, require_checks, require_approvals (fresh,
1687// non-author, latest review per reviewer from someone who can write; a
1688// fresh request-changes blocks), require_codeowners and require_resolved.
1689// Unmet carries one sentence per gate not passed. Fast-forward is
1690// reported, not gated: whether it matters depends on the strategy.
1691func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) (GatesOut, error) {
1692	set := repo.Settings
1693	g := GatesOut{Draft: mr.Draft, ApprovalsRequired: set.RequireApprovals,
1694		CodeownersRequired: set.RequireCodeowners, ResolvedRequired: set.RequireResolved,
1695		ChecksRequired: set.RequireChecks}
1696	// A draft is open but not asking. This gate is unconditional — no
1697	// setting turns it off — because the author said so themselves.
1698	if mr.Draft {
1699		g.Unmet = append(g.Unmet, fmt.Sprintf("!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number))
1700	}
1701
1702	// Checks: with require_checks, every status the head carries must be
1703	// green, a head something was going to report on must carry some, and
1704	// every required context must have reported: one that has not is
1705	// pending whatever the others say (#258). Setting contexts turns
1706	// require_checks on; turned off again, the list is kept and unread.
1707	statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
1708	if err != nil {
1709		return g, err
1710	}
1711	g.Checks = store.CombinedStatus(statuses)
1712	if set.RequireChecks {
1713		reported := map[string]bool{}
1714		for _, s := range statuses {
1715			reported[s.Context] = true
1716		}
1717		for _, want := range set.RequiredContexts {
1718			if !reported[want] {
1719				g.ChecksMissing = append(g.ChecksMissing, want)
1720			}
1721		}
1722		if len(g.ChecksMissing) > 0 && (g.Checks == "" || g.Checks == "success") {
1723			g.Checks = "pending"
1724		}
1725		switch g.Checks {
1726		case "success":
1727		case "":
1728			if checksExpected(st, repo.ID, dir, headSHA) {
1729				g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA))
1730			}
1731		default:
1732			var bad []string
1733			for _, st := range statuses {
1734				if st.State != "success" {
1735					bad = append(bad, st.Context+"="+st.State)
1736				}
1737			}
1738			for _, m := range g.ChecksMissing {
1739				bad = append(bad, m+"=missing")
1740			}
1741			g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", ")))
1742		}
1743	}
1744
1745	reviews, err := st.ListMRReviews(mr.ID)
1746	if err != nil {
1747		return g, err
1748	}
1749	// Latest fresh review per reviewer decides their stance — but only
1750	// from someone the repository trusts to write to it. Reviewing is
1751	// open to any reader, which is what makes an outside opinion on a
1752	// public change possible; deciding a merge gate is not the same
1753	// thing, and counting every verdict let anyone with an account
1754	// satisfy require_approvals or block a merge indefinitely (#147).
1755	counts := ReviewersWhoCount(st, repo, reviews)
1756	latest := map[string]string{}
1757	for _, r := range reviews {
1758		if r.Stale || r.Reviewer == mr.Author || !counts[r.Reviewer] {
1759			continue
1760		}
1761		latest[r.Reviewer] = r.Verdict
1762	}
1763	for who, verdict := range latest {
1764		switch verdict {
1765		case "approve":
1766			g.Approvals = append(g.Approvals, who)
1767		case "request_changes":
1768			g.ChangesRequested = append(g.ChangesRequested, who)
1769		}
1770	}
1771	slices.Sort(g.Approvals)
1772	slices.Sort(g.ChangesRequested)
1773	if set.RequireApprovals > 0 {
1774		if len(g.ChangesRequested) > 0 {
1775			g.Unmet = append(g.Unmet, fmt.Sprintf("%s requested changes on !%d; resolve their review before merging", strings.Join(g.ChangesRequested, ", "), mr.Number))
1776		}
1777		if len(g.Approvals) < set.RequireApprovals {
1778			g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(g.Approvals)))
1779		}
1780	}
1781
1782	// CODEOWNERS: every owned changed file needs an approval from one of
1783	// its owners. require_codeowners is the opt-in — a repository can
1784	// carry the file as documentation of who to ask without it gating
1785	// merges — and it does not wait on require_approvals (#99).
1786	if set.RequireCodeowners {
1787		content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20)
1788		if err != nil {
1789			content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
1790		}
1791		if err != nil || len(content) == 0 {
1792			g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires CODEOWNERS approval but %s carries no CODEOWNERS file", repo.Path(), mr.TargetRef))
1793		} else {
1794			rules := policy.ParseCodeowners(string(content))
1795			base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
1796			if err != nil {
1797				return g, err
1798			}
1799			files, err := gitutil.DiffFiles(dir, base, headSHA)
1800			if err != nil {
1801				return g, err
1802			}
1803			approved := map[string]bool{}
1804			for _, a := range g.Approvals {
1805				approved[a] = true
1806			}
1807			missing := map[string][]string{} // owner-set key -> paths
1808			var keys []string
1809			for _, f := range files {
1810				owners := policy.OwnersFor(rules, f)
1811				if owners == nil {
1812					continue
1813				}
1814				ok := false
1815				for _, o := range owners {
1816					if approved[o] {
1817						ok = true
1818						break
1819					}
1820				}
1821				if !ok {
1822					key := strings.Join(owners, ",")
1823					if _, seen := missing[key]; !seen {
1824						keys = append(keys, key)
1825					}
1826					missing[key] = append(missing[key], f)
1827				}
1828			}
1829			if len(missing) > 0 {
1830				slices.Sort(keys)
1831				var parts []string
1832				for _, key := range keys {
1833					paths := missing[key]
1834					g.OwnersOutstanding = append(g.OwnersOutstanding, OwnersOut{Files: paths, Owners: strings.Split(key, ",")})
1835					if len(paths) > 3 {
1836						paths = paths[:3]
1837					}
1838					parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), key))
1839				}
1840				g.Unmet = append(g.Unmet, "CODEOWNERS approval missing for: "+strings.Join(parts, "; "))
1841			}
1842		}
1843	}
1844
1845	n, err := st.UnresolvedThreadCount(mr.ID)
1846	if err != nil {
1847		return g, err
1848	}
1849	g.OpenThreads = n
1850	if set.RequireResolved && n > 0 {
1851		g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number))
1852	}
1853
1854	if ff, err := gitutil.IsAncestor(dir, targetSHA, headSHA); err == nil {
1855		g.FastForward = ff
1856	}
1857	return g, nil
1858}
1859
1860func runMRDraft(c *Ctx, args []string) int { return setMRDraft(c, args, true) }
1861func runMRReady(c *Ctx, args []string) int { return setMRDraft(c, args, false) }
1862
1863func setMRDraft(c *Ctx, args []string, draft bool) int {
1864	repo, mr, code := mrRef(c, args, policy.CanRead)
1865	if code >= 0 {
1866		return code
1867	}
1868	if code := refuseArchived(c, repo); code >= 0 {
1869		return code
1870	}
1871	if len(args) != 2 {
1872		return c.usage()
1873	}
1874	if code := authorOrWrite(c, repo, mr.Author, "change this merge request"); code >= 0 {
1875		return code
1876	}
1877	if mr.State != "open" && mr.State != "source_gone" {
1878		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
1879	}
1880	if mr.Draft == draft {
1881		state := "already ready"
1882		if draft {
1883			state = "already a draft"
1884		}
1885		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, state)
1886	}
1887	if err := c.Store.SetMRDraft(mr.ID, draft); err != nil {
1888		return c.fail(protocol.ExitFailure, "%v", err)
1889	}
1890	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft",
1891		fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft))
1892	if !draft {
1893		TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1894	}
1895	// Marking ready is the request for review; going back to draft
1896	// withdraws it and is not worth anyone's inbox.
1897	//
1898	// The targets are the repository's, not the thread's participants.
1899	// Until someone comments or reviews, the only participant is the
1900	// author, who is the actor and excluded — so notifying participants
1901	// here reaches nobody, which is exactly what opening it as a draft
1902	// and then marking it ready would do. Opening a merge request tells
1903	// the repository; so does saying it is finally asking. A review
1904	// request made before ready — or on an earlier revision — reaches its
1905	// target here too: they are exactly who else is being asked.
1906	if !draft {
1907		if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
1908			parts, _ := c.Store.MRParticipants(mr.ID)
1909			reviewers, _ := c.Store.MRReviewRequestIDs(mr.ID)
1910			notify(c, append(append(targets, parts...), reviewers...), notice{repo: repo, kind: "mr", number: mr.Number,
1911				subject: mrSubject(repo, mr.Number, mr.Title),
1912				action:  fmt.Sprintf("marked !%d ready for review", mr.Number),
1913				path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1914		}
1915	}
1916	return c.emit(map[string]any{"number": mr.Number, "draft": draft}, func(w io.Writer) {
1917		fmt.Fprintf(w, "%s!%d is %s\n", repo.Path(), mr.Number, map[bool]string{true: "a draft", false: "ready"}[draft])
1918	})
1919}
1920
1921func runMRClose(c *Ctx, args []string) int {
1922	f, err := c.parseArgs(args, flagSpec{Values: []string{"--by"}, MaxPos: 2,
1923		Usage: "mr close <owner/name> <n> [--by <m>]"})
1924	if err != nil {
1925		return c.fail(protocol.ExitUsage, "%v", err)
1926	}
1927	repo, mr, code := mrRef(c, f.Pos, policy.CanRead)
1928	if code >= 0 {
1929		return code
1930	}
1931	if code := refuseArchived(c, repo); code >= 0 {
1932		return code
1933	}
1934	if len(f.Pos) != 2 {
1935		return c.usage()
1936	}
1937	if code := authorOrWrite(c, repo, mr.Author, "close this merge request"); code >= 0 {
1938		return code
1939	}
1940	if mr.State == "merged" || mr.State == "closed" {
1941		return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
1942	}
1943	var by int64
1944	if f.Has("--by") {
1945		by, code = resolveSupersededBy(c, repo, mr.Number, f.Value("--by"))
1946		if code >= 0 {
1947			return code
1948		}
1949	}
1950	if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil {
1951		return c.fail(protocol.ExitFailure, "%v", err)
1952	}
1953	eventData := fmt.Sprintf(`{"number":%d}`, mr.Number)
1954	if by != 0 {
1955		if err := c.Store.SetSupersededBy(mr.ID, by); err != nil {
1956			return c.fail(protocol.ExitFailure, "%v", err)
1957		}
1958		eventData = fmt.Sprintf(`{"number":%d,"by":%d}`, mr.Number, by)
1959	}
1960	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData)
1961	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1962		notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1963			subject: mrSubject(repo, mr.Number, mr.Title),
1964			action:  fmt.Sprintf("closed !%d", mr.Number),
1965			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1966	}
1967	return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
1968		fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
1969	})
1970}
1971
1972// resolveSupersededBy validates a --superseded-by/--by value against the
1973// merge request it would be set on: it must parse, name another merge
1974// request in the same repository (never itself), and that request must
1975// exist. -1 as the returned code means the value is good to use.
1976func resolveSupersededBy(c *Ctx, repo store.Repo, number int64, v string) (int64, int) {
1977	m, err := strconv.ParseInt(v, 10, 64)
1978	if err != nil {
1979		return 0, c.fail(protocol.ExitUsage, "bad MR number %q", v)
1980	}
1981	if m == number {
1982		return 0, c.fail(protocol.ExitUsage, "a merge request cannot supersede itself")
1983	}
1984	if _, err := c.Store.MRByNumber(repo.ID, m); errors.Is(err, store.ErrNotFound) {
1985		return 0, c.fail(protocol.ExitNotFound, "no merge request !%d on %s", m, repo.Path())
1986	} else if err != nil {
1987		return 0, c.fail(protocol.ExitFailure, "%v", err)
1988	}
1989	return m, -1
1990}
1991
1992// reviewAction is what a review notification says it was. A verdict with
1993// a batch behind it is a different thing from a bare verdict, and the
1994// person reading the mail is deciding whether to open it.
1995func reviewAction(number int64, verdict string, published int64) string {
1996	if published > 0 {
1997		return fmt.Sprintf("reviewed !%d: %s, with %d comment(s)", number, verdict, published)
1998	}
1999	return fmt.Sprintf("reviewed !%d: %s", number, verdict)
2000}
2001
2002// RevisionOut is one head a merge request has had.
2003type RevisionOut struct {
2004	N         int    `json:"n"` // 1 is the first push
2005	SHA       string `json:"sha"`
2006	BaseSHA   string `json:"base_sha,omitempty"`
2007	CreatedAt string `json:"created_at"`
2008	Current   bool   `json:"current,omitempty"`
2009}
2010
2011func mrRevisions(c *Ctx, mr store.MR) ([]RevisionOut, error) {
2012	heads, err := c.Store.MRHeads(mr.ID)
2013	if err != nil {
2014		return nil, err
2015	}
2016	out := make([]RevisionOut, 0, len(heads))
2017	for i, h := range heads {
2018		out = append(out, RevisionOut{N: i + 1, SHA: h.SHA, BaseSHA: h.BaseSHA,
2019			CreatedAt: h.CreatedAt, Current: h.SHA == mr.HeadSHA})
2020	}
2021	return out, nil
2022}
2023
2024func runMRRevisions(c *Ctx, args []string) int {
2025	repo, mr, code := mrRef(c, args, policy.CanRead)
2026	if code >= 0 {
2027		return code
2028	}
2029	if len(args) != 2 {
2030		return c.usage()
2031	}
2032	revs, err := mrRevisions(c, mr)
2033	if err != nil {
2034		return c.fail(protocol.ExitFailure, "%v", err)
2035	}
2036	return c.emit(revs, func(w io.Writer) {
2037		tb := c.table(w, "REV", "SHA", "WHEN")
2038		for _, r := range revs {
2039			mark := " "
2040			if r.Current {
2041				mark = "*"
2042			}
2043			tb.row(cRef(fmt.Sprintf("%s v%d", mark, r.N)), cRef(fmt.Sprintf("%.10s", r.SHA)), cAge(r.CreatedAt))
2044		}
2045		tb.flush()
2046		if len(revs) < 2 {
2047			fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n",
2048				repo.Path(), mr.Number)
2049		}
2050	})
2051}
2052
2053func runMRRangeDiff(c *Ctx, args []string) int {
2054	f, err := c.parseArgs(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: c.Cmd.Usage})
2055	if err != nil {
2056		return c.fail(protocol.ExitUsage, "%v", err)
2057	}
2058	repo, mr, code := mrRef(c, f.Pos, policy.CanRead)
2059	if code >= 0 {
2060		return code
2061	}
2062	if len(f.Pos) != 2 {
2063		return c.usage()
2064	}
2065	revs, err := mrRevisions(c, mr)
2066	if err != nil {
2067		return c.fail(protocol.ExitFailure, "%v", err)
2068	}
2069	// One revision is a merge request nobody has pushed to since it was
2070	// opened. The argv was fine and the answer is "nothing changed", so
2071	// this succeeds with an empty patch rather than failing.
2072	if len(revs) < 2 {
2073		fmt.Fprintf(c.Stderr, "%s!%d has one revision; nothing to compare it against\n",
2074			repo.Path(), mr.Number)
2075		return protocol.ExitOK
2076	}
2077	// Default to the two most recent, which is "what changed since the
2078	// last push" — the question a stale review asks.
2079	from, to := revs[len(revs)-2], revs[len(revs)-1]
2080	pick := func(sha string) (RevisionOut, bool) {
2081		for _, r := range revs {
2082			if strings.HasPrefix(r.SHA, sha) {
2083				return r, true
2084			}
2085		}
2086		return RevisionOut{}, false
2087	}
2088	if v := f.Value("--from"); v != "" {
2089		r, ok := pick(v)
2090		if !ok {
2091			return c.fail(protocol.ExitNotFound, "%.12s is not a revision of !%d; see `mr revisions`", v, mr.Number)
2092		}
2093		from = r
2094	}
2095	if v := f.Value("--to"); v != "" {
2096		r, ok := pick(v)
2097		if !ok {
2098			return c.fail(protocol.ExitNotFound, "%.12s is not a revision of !%d; see `mr revisions`", v, mr.Number)
2099		}
2100		to = r
2101	}
2102	if from.SHA == to.SHA {
2103		return c.fail(protocol.ExitUsage, "--from and --to are the same revision")
2104	}
2105
2106	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
2107	// A revision recorded before its base could be worked out, or by a
2108	// migration backfill, falls back to the target's merge base.
2109	baseOf := func(r RevisionOut) string {
2110		if r.BaseSHA != "" {
2111			return r.BaseSHA
2112		}
2113		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, r.SHA)
2114		if err != nil {
2115			return r.SHA + "^"
2116		}
2117		return b
2118	}
2119	patch, truncated, err := gitutil.RangeDiff(dir, baseOf(from), from.SHA, baseOf(to), to.SHA, 4<<20)
2120	if err != nil {
2121		return c.fail(protocol.ExitFailure,
2122			"%v (the objects for an older revision may have been garbage-collected)", err)
2123	}
2124	fmt.Fprint(c.Stdout, patch)
2125	if truncated {
2126		fmt.Fprintln(c.Stderr, "range-diff truncated at 4 MiB")
2127	}
2128	return protocol.ExitOK
2129}
2130
2131// reviewersWhoCount is the set of reviewers whose verdict decides a merge
2132// gate: those with write access to the repository.
2133//
2134// Write, rather than a separate reviewer role, because it is the same
2135// question the gates already answer — a person who could push this change
2136// themselves is the person whose approval means the repository accepts
2137// it. Someone named in CODEOWNERS without write is a misconfiguration the
2138// owner should fix rather than a case to special-case here: they could
2139// not merge what they approved.
2140// Exported because the web renders the same distinction: a page that
2141// showed an approval the gate ignores would differ from the gate, and the
2142// difference would only surface when a merge was refused.
2143func ReviewersWhoCount(st *store.Store, repo store.Repo, reviews []store.MRReview) map[string]bool {
2144	counts := map[string]bool{}
2145	for _, r := range reviews {
2146		if _, done := counts[r.Reviewer]; done {
2147			continue
2148		}
2149		counts[r.Reviewer] = false
2150		u, err := st.UserByUsername(r.Reviewer)
2151		if err != nil {
2152			continue
2153		}
2154		grant, err := st.AccessRole(repo.ID, u.ID)
2155		if err != nil {
2156			continue
2157		}
2158		counts[r.Reviewer] = policy.CanWrite(u, repo, grant)
2159	}
2160	return counts
2161}