internal/control/mr.go

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