internal/control/mr.go

1ed9fb9399b21da8e6cf45be8389792aac82d5bc
gitbay/internal/control/mr.go history · blame · raw

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