internal/control/mr.go

2321 lines · 84000 bytes

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