internal/control/mr.go

2198 lines · 79372 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.emitPage(p, ds, next, func(w io.Writer) {
 689		if c.Term.Cols > 0 {
 690			mrListTerm(c, w, repo, mrs, ds, checks, review)
 691			return
 692		}
 693		tb := c.table(w, "!", "STATE", "TITLE", "REF")
 694		for _, d := range ds {
 695			state := d.State
 696			if d.Draft {
 697				state = "draft"
 698			}
 699			cells := []cell{cRef(fmt.Sprintf("!%d", d.Number)), cState(state), cFlex(d.Title), cText(fmt.Sprintf("%s -> %s", d.Source, d.TargetRef))}
 700			if d.StackedOn != nil {
 701				cells = append(cells, cText(fmt.Sprintf("stacked on !%d", d.StackedOn.Number)))
 702			}
 703			tb.row(cells...)
 704		}
 705		tb.flush()
 706	})
 707}
 708
 709// mrListTerm is mr list at a terminal: where checks and review stand
 710// for each open merge request, and when it last changed. The source
 711// branch alone names a merge request into the default branch.
 712func mrListTerm(c *Ctx, w io.Writer, repo store.Repo, mrs []store.MR, ds []mrOut, checks, review map[int64]cell) {
 713	tb := c.table(w, "!", "STATE", "TITLE", "BRANCH", "CHECKS", "REVIEW", "UPDATED")
 714	for i, d := range ds {
 715		state := d.State
 716		if d.Draft {
 717			state = "draft"
 718		}
 719		ref := d.Source
 720		if d.TargetRef != repo.DefaultBranch {
 721			ref += " -> " + d.TargetRef
 722		}
 723		if d.StackedOn != nil {
 724			ref += fmt.Sprintf(" (on !%d)", d.StackedOn.Number)
 725		}
 726		m := mrs[i]
 727		tb.row(cLink(fmt.Sprintf("!%d", d.Number), c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))), cState(state), cFlex(d.Title), cText(ref),
 728			checks[m.ID], review[m.ID], cAge(m.UpdatedAt))
 729	}
 730	tb.flush()
 731}
 732
 733// byWhom renders " by <user>", or nothing when the actor is unknown — an
 734// imported merge request carries a time but no local account.
 735func byWhom(user string) string {
 736	if user == "" {
 737		return ""
 738	}
 739	return " by " + user
 740}
 741
 742func runMRShow(c *Ctx, args []string) int {
 743	repo, mr, code := mrRef(c, args, policy.CanRead)
 744	if code >= 0 {
 745		return code
 746	}
 747	if len(args) != 2 {
 748		return c.usage()
 749	}
 750	comments, err := c.Store.ListMRComments(mr.ID)
 751	if err != nil {
 752		return c.fail(protocol.ExitFailure, "%v", err)
 753	}
 754	reviews, err := c.Store.ListMRReviews(mr.ID)
 755	if err != nil {
 756		return c.fail(protocol.ExitFailure, "%v", err)
 757	}
 758	statuses, combined, err := c.Store.ChecksForCommit(repo.ID, mr.HeadSHA)
 759	if err != nil {
 760		return c.fail(protocol.ExitFailure, "%v", err)
 761	}
 762	unresolved, err := c.Store.UnresolvedThreadCount(mr.ID)
 763	if err != nil {
 764		return c.fail(protocol.ExitFailure, "%v", err)
 765	}
 766	var checks []CheckOut
 767	for _, st := range statuses {
 768		out := CheckOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt}
 769		if st.Duration > 0 {
 770			out.Duration = st.Duration.String()
 771		}
 772		checks = append(checks, out)
 773	}
 774	rx, err := c.Store.ReactionCounts("mr", mr.ID, c.User.ID)
 775	if err != nil {
 776		return c.fail(protocol.ExitFailure, "%v", err)
 777	}
 778	var cs []commentOut
 779	for _, cm := range comments {
 780		cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])})
 781	}
 782	var rs []ReviewOut
 783	counts := ReviewersWhoCount(c.Store, repo, reviews)
 784	for _, r := range reviews {
 785		rs = append(rs, ReviewOut{r.Reviewer, r.Verdict, r.Stale, counts[r.Reviewer], r.CreatedAt})
 786	}
 787	// The commits this MR carries: base..head, the diff's range.
 788	var commits []CommitOut
 789	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 790	base := mr.MergedBase
 791	if base == "" {
 792		if b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number)); err == nil {
 793			base = b
 794		}
 795	}
 796	if base != "" {
 797		if shas, err := gitutil.RevListRange(dir, base, mrHeadRef(mr.Number)); err == nil {
 798			for _, sha := range shas {
 799				subject := ""
 800				if raw, err := gitutil.ReadCommit(dir, sha); err == nil {
 801					if parsed, err := sig.ParseCommit(raw); err == nil {
 802						subject = parsed.Subject
 803					}
 804				}
 805				commits = append(commits, CommitOut{sha, subject})
 806			}
 807		}
 808	}
 809	d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined,
 810		UnresolvedThreads: unresolved, Commits: commits, Reactions: reactionsOut(rx[0]), Comments: cs, Reviews: rs}
 811	d.StackedOn, d.Stacked = stackOf(c, repo, mr)
 812	if mr.State == "open" || mr.State == "source_gone" {
 813		if targetSHA, err := gitutil.ResolveRef(dir, "refs/heads/"+mr.TargetRef); err == nil {
 814			if g, err := MergeGates(c.Store, repo, mr, dir, targetSHA, mr.HeadSHA); err == nil {
 815				d.Gates = &g
 816			}
 817		}
 818	}
 819	return c.emit(d, func(w io.Writer) {
 820		state := d.State
 821		if d.Draft {
 822			state = "draft"
 823		}
 824		v := c.view(w)
 825		v.title(fmt.Sprintf("!%d", d.Number), d.Title, state)
 826
 827		stackedOn, stacked := "", ""
 828		if d.StackedOn != nil {
 829			stackedOn = fmt.Sprintf("!%d %s", d.StackedOn.Number, d.StackedOn.Title)
 830		}
 831		for _, k := range d.Stacked {
 832			if stacked != "" {
 833				stacked += ", "
 834			}
 835			stacked += fmt.Sprintf("!%d %s", k.Number, k.Title)
 836		}
 837		merged, closed, superseded := "", "", ""
 838		if d.MergedAt != "" {
 839			merged = c.when(d.MergedAt) + byWhom(d.MergedBy)
 840		}
 841		if d.ClosedAt != "" {
 842			closed = c.when(d.ClosedAt) + byWhom(d.ClosedBy)
 843		}
 844		if d.SupersededBy != 0 {
 845			superseded = fmt.Sprintf("!%d", d.SupersededBy)
 846		}
 847		gates := ""
 848		if g := d.Gates; g != nil {
 849			ff := "fast-forward possible"
 850			if !g.FastForward {
 851				ff = "fast-forward not possible"
 852			}
 853			if len(g.Unmet) == 0 {
 854				gates = "met; " + ff
 855			} else {
 856				gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff)
 857			}
 858		}
 859		queued, waiting := "", ""
 860		if q := d.Queued; q != nil {
 861			queued = "by " + q.By
 862			if q.Strategy != "" {
 863				queued += " (" + q.Strategy + ")"
 864			}
 865			queued += ", " + c.when(q.QueuedAt)
 866			waiting = q.Reason
 867		}
 868		unresolved := ""
 869		if d.UnresolvedThreads > 0 {
 870			unresolved = fmt.Sprintf("%d", d.UnresolvedThreads)
 871		}
 872		// One fields call for every one-row fact, including the unmet
 873		// gates and the commit/check/review that only has a single row
 874		// to show: separate calls each compute their own key width, so
 875		// keeping them in one call keeps every key aligned.
 876		kv := []string{
 877			"author", d.Author + ", " + c.when(d.CreatedAt),
 878			"source", fmt.Sprintf("%s -> %s", d.Source, d.TargetRef),
 879			"head", fmt.Sprintf("%.10s", d.HeadSHA),
 880			"milestone", d.Milestone,
 881			"labels", strings.Join(d.Labels, ", "),
 882			"reviewers", strings.Join(d.ReviewRequests, ", "),
 883			"stacked on", stackedOn,
 884			"stacked", stacked,
 885			"merged", merged,
 886			"closed", closed,
 887			"superseded by", superseded,
 888			"queued to merge", queued,
 889			"waiting on", waiting,
 890			"unresolved threads", unresolved,
 891			"gates", gates,
 892		}
 893		if g := d.Gates; g != nil {
 894			for _, u := range g.Unmet {
 895				kv = append(kv, "unmet", u)
 896			}
 897		}
 898		if len(commits) == 1 {
 899			kv = append(kv, "commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject))
 900		}
 901		if len(checks) == 1 {
 902			x := checks[0]
 903			dur := ""
 904			if x.Duration != "" {
 905				dur = " in " + x.Duration
 906			}
 907			kv = append(kv, "check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur))
 908		}
 909		if len(rs) == 1 {
 910			kv = append(kv, "review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt))
 911		}
 912		kv = append(kv, "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10)))
 913		v.fields(kv...)
 914
 915		v.body(d.Body, d.BodyFormat)
 916		v.reactions(d.Reactions)
 917
 918		if len(commits) > 1 {
 919			v.section(fmt.Sprintf("commits (%d)", len(commits)))
 920			tb := c.table(w, "SHA", "SUBJECT")
 921			for _, cm := range commits {
 922				tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject))
 923			}
 924			tb.flush()
 925		}
 926
 927		if len(checks) > 1 {
 928			v.section(fmt.Sprintf("checks (%d)", len(checks)))
 929			tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
 930			for _, x := range checks {
 931				tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt)))
 932			}
 933			tb.flush()
 934		}
 935
 936		if len(rs) > 1 {
 937			v.section(fmt.Sprintf("reviews (%d)", len(rs)))
 938			tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
 939			for _, r := range rs {
 940				verdict := r.Verdict
 941				if r.Stale {
 942					verdict += " (stale)"
 943				}
 944				if !r.Counts {
 945					verdict += " (advisory)"
 946				}
 947				tb.row(cText(r.Reviewer), cState(verdict), cText(c.when(r.CreatedAt)))
 948			}
 949			tb.flush()
 950		}
 951
 952		events := false
 953		for _, cm := range cs {
 954			if cm.Kind != "system" {
 955				continue
 956			}
 957			if !events {
 958				io.WriteString(w, "\n")
 959				events = true
 960			}
 961			v.event(cm.Body, cm.BodyFormat, cm.CreatedAt)
 962		}
 963		for _, cm := range cs {
 964			if cm.Kind == "system" {
 965				continue
 966			}
 967			v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
 968			v.reactions(cm.Reactions)
 969		}
 970	})
 971}
 972
 973// reviewLine renders one review as fields prose: "reviewer verdict
 974// (stale) (advisory) at when".
 975func reviewLine(r ReviewOut) string {
 976	s := r.Reviewer + " " + r.Verdict
 977	if r.Stale {
 978		s += " (stale)"
 979	}
 980	if !r.Counts {
 981		s += " (advisory: no write access)"
 982	}
 983	return s
 984}
 985
 986func runMRDiff(c *Ctx, args []string) int {
 987	repo, mr, code := mrRef(c, args, policy.CanRead)
 988	if code >= 0 {
 989		return code
 990	}
 991	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 992	head := mrHeadRef(mr.Number)
 993	if _, err := gitutil.ResolveRef(dir, head); err != nil {
 994		return c.fail(protocol.ExitFailure, "the head of !%d is no longer in the repository; its diff is not available", mr.Number)
 995	}
 996	// After a merge (especially fast-forward) the live merge-base equals
 997	// the head and the diff would vanish; use the recorded base instead.
 998	base := mr.MergedBase
 999	if base == "" {
1000		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
1001		if err != nil {
1002			return c.fail(protocol.ExitFailure, "%v", err)
1003		}
1004		base = b
1005	}
1006	patch, truncated, err := gitutil.Diff(dir, base, head, 4<<20)
1007	if err != nil {
1008		return c.fail(protocol.ExitFailure, "%v", err)
1009	}
1010	fmt.Fprint(c.Stdout, c.Term.diff(patch))
1011	if truncated {
1012		fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB; fetch the branch for the rest")
1013	}
1014	return protocol.ExitOK
1015}
1016
1017func runMREdit(c *Ctx, args []string) int {
1018	rest, title, body, format, fl, code := editText(c, args, "mr", "--superseded-by")
1019	if code >= 0 {
1020		return code
1021	}
1022	repo, mr, code := mrRef(c, rest, policy.CanRead)
1023	if code >= 0 {
1024		return code
1025	}
1026	if code := refuseArchived(c, repo); code >= 0 {
1027		return code
1028	}
1029	if code := authorOrWrite(c, repo, mr.Author, "edit this merge request"); code >= 0 {
1030		return code
1031	}
1032	var clearSuperseded bool
1033	var supersededBy int64
1034	if fl.Has("--superseded-by") {
1035		if mr.State != "closed" {
1036			return c.fail(protocol.ExitUsage, "only a closed merge request can be superseded")
1037		}
1038		if v := fl.Value("--superseded-by"); v == "none" {
1039			clearSuperseded = true
1040		} else {
1041			supersededBy, code = resolveSupersededBy(c, repo, mr.Number, v)
1042			if code >= 0 {
1043				return code
1044			}
1045		}
1046	}
1047	if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
1048		return c.fail(protocol.ExitFailure, "%v", err)
1049	}
1050	if clearSuperseded {
1051		if err := c.Store.SetSupersededBy(mr.ID, 0); err != nil {
1052			return c.fail(protocol.ExitFailure, "%v", err)
1053		}
1054	} else if supersededBy != 0 {
1055		if err := c.Store.SetSupersededBy(mr.ID, supersededBy); err != nil {
1056			return c.fail(protocol.ExitFailure, "%v", err)
1057		}
1058	}
1059	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.edited", fmt.Sprintf(`{"number":%d}`, mr.Number))
1060	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
1061		fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
1062	})
1063}
1064
1065// runMRRetarget moves an open merge request onto another branch of the
1066// same repository.
1067func runMRRetarget(c *Ctx, args []string) int {
1068	if len(args) != 3 {
1069		return c.usage()
1070	}
1071	repo, mr, code := mrRef(c, args[:2], policy.CanRead)
1072	if code >= 0 {
1073		return code
1074	}
1075	if code := refuseArchived(c, repo); code >= 0 {
1076		return code
1077	}
1078	if code := authorOrWrite(c, repo, mr.Author, "retarget this merge request"); code >= 0 {
1079		return code
1080	}
1081	if mr.State == "merged" || mr.State == "closed" {
1082		return c.fail(protocol.ExitUsage, "!%d is %s; only an open merge request can be retargeted", mr.Number, mr.State)
1083	}
1084	target := args[2]
1085	if target == mr.TargetRef {
1086		return c.fail(protocol.ExitUsage, "!%d already targets %s", mr.Number, target)
1087	}
1088	if mr.SourceRepoID == repo.ID && target == mr.SourceRef {
1089		return c.fail(protocol.ExitUsage, "%s is the source branch of !%d", target, mr.Number)
1090	}
1091	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1092	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+target); err != nil {
1093		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", target, repo.Path())
1094	}
1095	// The diff, the commit list and the merge gates all derive their base
1096	// from the target on every read, so the only thing to check here is
1097	// that a base exists at all: without one there is nothing to show and
1098	// nothing to merge.
1099	base, err := gitutil.MergeBase(dir, "refs/heads/"+target, mrHeadRef(mr.Number))
1100	if err != nil || base == "" {
1101		return c.fail(protocol.ExitUsage, "%s shares no history with the head of !%d", target, mr.Number)
1102	}
1103	old := mr.TargetRef
1104	if err := c.Store.SetMRTarget(mr.ID, target); err != nil {
1105		return c.fail(protocol.ExitFailure, "%v", err)
1106	}
1107	c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
1108	// A queued merge was asked for against the old target. Someone who
1109	// could not merge it themselves does not get to point it elsewhere.
1110	if mr.QueuedAt != "" {
1111		grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1112		if err != nil || !policy.CanWrite(c.User, repo, grant) {
1113			mergeQueueMu.Lock()
1114			dequeueWithReason(c.Store, mr, fmt.Sprintf("%s retargeted it to %s and cannot merge into %s", c.User.Username, target, repo.Path()))
1115			mergeQueueMu.Unlock()
1116		}
1117	}
1118	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted",
1119		fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target))
1120	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1121		notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1122			subject: mrSubject(repo, mr.Number, mr.Title),
1123			action:  fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target),
1124			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1125	}
1126	return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) {
1127		fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base)
1128	})
1129}
1130
1131func runMRComment(c *Ctx, args []string) int {
1132	return runComment(c, args, mrThread, "mr",
1133		func(rest []string) (store.Repo, int64, int64, string, int) {
1134			repo, mr, code := mrRef(c, rest, policy.CanRead)
1135			return repo, mr.ID, mr.Number, mr.Title, code
1136		},
1137		c.Store.AddMRComment, c.Store.MRParticipants)
1138}
1139
1140func runMRReview(c *Ctx, args []string) int {
1141	verdict, discard := "", false
1142	var rest []string
1143	for _, a := range args {
1144		switch a {
1145		case "--approve":
1146			verdict = "approve"
1147		case "--request-changes":
1148			verdict = "request_changes"
1149		case "--comment":
1150			verdict = "comment"
1151		case "--discard":
1152			discard = true
1153		default:
1154			rest = append(rest, a)
1155		}
1156	}
1157	if discard && verdict != "" {
1158		return c.fail(protocol.ExitUsage, "--discard throws the batch away; it takes no verdict")
1159	}
1160	if verdict == "" && !discard {
1161		return c.usage()
1162	}
1163	repo, mr, code := mrRef(c, rest, policy.CanRead)
1164	if code >= 0 {
1165		return code
1166	}
1167	if code := refuseArchived(c, repo); code >= 0 {
1168		return code
1169	}
1170	if mr.State != "open" {
1171		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
1172	}
1173	// Throwing the batch away is not a review, so it stops here: no
1174	// verdict, no event, nobody told about comments nobody ever saw.
1175	if discard {
1176		n, err := c.Store.DiscardPendingComments(mr.ID, c.User.ID)
1177		if err != nil {
1178			return c.fail(protocol.ExitFailure, "%v", err)
1179		}
1180		return c.emit(map[string]any{"number": mr.Number, "discarded": n}, func(w io.Writer) {
1181			fmt.Fprintf(w, "discarded %d pending comment(s) on %s!%d\n", n, repo.Path(), mr.Number)
1182		})
1183	}
1184	if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
1185		return c.fail(protocol.ExitFailure, "%v", err)
1186	}
1187	// The batch the reviewer composed becomes visible with the verdict,
1188	// which is what makes it one review rather than a trickle.
1189	published, err := c.Store.PublishPendingComments(mr.ID, c.User.ID)
1190	if err != nil {
1191		return c.fail(protocol.ExitFailure, "%v", err)
1192	}
1193	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed",
1194		fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict))
1195	TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1196	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1197		notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1198			subject: mrSubject(repo, mr.Number, mr.Title),
1199			action:  reviewAction(mr.Number, verdict, published),
1200			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1201	}
1202	// Whether the merge gates will count this verdict, said now rather
1203	// than at the refusal (#199).
1204	counts := ReviewersWhoCount(c.Store, repo, []store.MRReview{{Reviewer: c.User.Username}})[c.User.Username]
1205	return c.emit(map[string]any{"number": mr.Number, "verdict": verdict, "published": published, "counts": counts}, func(w io.Writer) {
1206		fmt.Fprintf(w, "reviewed %s!%d: %s", repo.Path(), mr.Number, verdict)
1207		if published > 0 {
1208			fmt.Fprintf(w, " (%d comment(s))", published)
1209		}
1210		if !counts {
1211			fmt.Fprintf(w, " (advisory: no write access on %s, so the merge gates do not count it)", repo.Path())
1212		}
1213		fmt.Fprintln(w)
1214	})
1215}
1216
1217// runMRReviewRequest is issue assign's counterpart for merge requests: it
1218// pushes a merge request into a specific person's review queue and inbox
1219// directly, rather than waiting for them to be otherwise involved (#145).
1220func runMRReviewRequest(c *Ctx, args []string) int {
1221	rest, adds, removes, err := addRemoveFlags(args)
1222	if err != nil {
1223		return c.failInput(err)
1224	}
1225	if len(adds)+len(removes) == 0 {
1226		return c.usage()
1227	}
1228	repo, mr, code := mrRef(c, rest, policy.CanWrite)
1229	if code >= 0 {
1230		return code
1231	}
1232	if code := refuseArchived(c, repo); code >= 0 {
1233		return code
1234	}
1235	resolve := func(name string) (store.User, int) {
1236		u, err := c.Store.UserByUsername(name)
1237		if errors.Is(err, store.ErrNotFound) {
1238			return u, c.fail(protocol.ExitNotFound, "no such user %q", name)
1239		}
1240		if err != nil {
1241			return u, c.fail(protocol.ExitFailure, "%v", err)
1242		}
1243		return u, -1
1244	}
1245	// Notified on every return, not just success: a name later in --add
1246	// that fails to resolve or lacks access must not silence the people
1247	// already added earlier in the same call.
1248	var added []store.User
1249	defer func() {
1250		if len(added) == 0 {
1251			return
1252		}
1253		ids := make([]int64, len(added))
1254		for i, u := range added {
1255			ids[i] = u.ID
1256		}
1257		notify(c, ids, notice{repo: repo, kind: "mr", number: mr.Number,
1258			subject: mrSubject(repo, mr.Number, mr.Title),
1259			action:  fmt.Sprintf("asked for a review on !%d", mr.Number),
1260			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1261	}()
1262	for _, name := range adds {
1263		u, code := resolve(name)
1264		if code >= 0 {
1265			return code
1266		}
1267		// A review request that lands nowhere the recipient can see it is
1268		// worse than useless: it looks like the ask went through.
1269		grant, err := c.Store.AccessRole(repo.ID, u.ID)
1270		if err != nil {
1271			return c.fail(protocol.ExitFailure, "%v", err)
1272		}
1273		if !policy.CanRead(u, repo, grant) {
1274			return c.fail(protocol.ExitDenied, "%s cannot read %s", name, repo.Path())
1275		}
1276		if err := c.Store.SetMRReviewRequest(mr.ID, u.ID, true); err != nil {
1277			return c.fail(protocol.ExitFailure, "%v", err)
1278		}
1279		added = append(added, u)
1280	}
1281	for _, name := range removes {
1282		u, code := resolve(name)
1283		if code >= 0 {
1284			return code
1285		}
1286		if err := c.Store.SetMRReviewRequest(mr.ID, u.ID, false); err != nil {
1287			if errors.Is(err, store.ErrNotFound) {
1288				return c.fail(protocol.ExitNotFound, "%s is not a requested reviewer", name)
1289			}
1290			return c.fail(protocol.ExitFailure, "%v", err)
1291		}
1292	}
1293	updated, err := c.Store.MRByNumber(repo.ID, mr.Number)
1294	if err != nil {
1295		return c.fail(protocol.ExitFailure, "%v", err)
1296	}
1297	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.review_requested",
1298		fmt.Sprintf(`{"number":%d,"reviewers":%s}`, mr.Number, jsonStrings(updated.ReviewRequests)))
1299	return c.emit(map[string]any{"number": mr.Number, "reviewers": updated.ReviewRequests}, func(w io.Writer) {
1300		fmt.Fprintf(w, "requested reviewers on %s!%d: %s\n", repo.Path(), mr.Number, strings.Join(updated.ReviewRequests, ", "))
1301	})
1302}
1303
1304// runMRLabel is issue label's counterpart for merge requests: the label
1305// set is the repository's (or its org's), shared with the issues (#231).
1306func runMRLabel(c *Ctx, args []string) int {
1307	rest, adds, removes, err := addRemoveFlags(args)
1308	if err != nil {
1309		return c.failInput(err)
1310	}
1311	if len(adds)+len(removes) == 0 {
1312		return c.usage()
1313	}
1314	repo, mr, code := mrRef(c, rest, policy.CanWrite)
1315	if code >= 0 {
1316		return code
1317	}
1318	if code := refuseArchived(c, repo); code >= 0 {
1319		return code
1320	}
1321	for _, l := range adds {
1322		if err := c.Store.SetMRLabel(repo, mr.ID, l, true); err != nil {
1323			return c.fail(protocol.ExitFailure, "%v", err)
1324		}
1325	}
1326	for _, l := range removes {
1327		if err := c.Store.SetMRLabel(repo, mr.ID, l, false); err != nil {
1328			if errors.Is(err, store.ErrNotFound) {
1329				return c.fail(protocol.ExitNotFound, "%v", err)
1330			}
1331			return c.fail(protocol.ExitFailure, "%v", err)
1332		}
1333	}
1334	updated, err := c.Store.MRByNumber(repo.ID, mr.Number)
1335	if err != nil {
1336		return c.fail(protocol.ExitFailure, "%v", err)
1337	}
1338	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.labeled",
1339		fmt.Sprintf(`{"number":%d,"labels":%s}`, mr.Number, jsonStrings(updated.Labels)))
1340	return c.emit(map[string]any{"number": mr.Number, "labels": updated.Labels}, func(w io.Writer) {
1341		fmt.Fprintf(w, "labels on %s!%d: %s\n", repo.Path(), mr.Number, strings.Join(updated.Labels, ", "))
1342	})
1343}
1344
1345func runMRMerge(c *Ctx, args []string) int {
1346	f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, Bools: []string{"--when-ready", "--cancel"},
1347		MaxPos: -1, Usage: c.Cmd.Usage})
1348	if err != nil {
1349		return c.fail(protocol.ExitUsage, "%v", err)
1350	}
1351	strategy, rest := f.Value("--strategy"), f.Pos
1352	valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
1353	if !valid[strategy] {
1354		return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
1355	}
1356	if f.Has("--cancel") && (f.Has("--when-ready") || f.Has("--strategy")) {
1357		return c.fail(protocol.ExitUsage, "--cancel takes no other flag")
1358	}
1359	repo, mr, code := mrRef(c, rest, policy.CanWrite)
1360	if code >= 0 {
1361		return code
1362	}
1363	switch {
1364	case f.Has("--cancel"):
1365		return cancelQueuedMerge(c, repo, mr)
1366	case f.Has("--when-ready"):
1367		return queueMerge(c, repo, mr, strategy)
1368	}
1369	return mergeMR(c, repo, mr, strategy)
1370}
1371
1372// mergeMR merges mr now as c.User, or refuses saying why. The queue runs
1373// it too, as the user who queued the merge.
1374func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
1375	if code := refuseArchived(c, repo); code >= 0 {
1376		return code
1377	}
1378	if mr.State != "open" && mr.State != "source_gone" {
1379		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
1380	}
1381
1382	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1383	targetRef := "refs/heads/" + mr.TargetRef
1384	targetSHA, err := gitutil.ResolveRef(dir, targetRef)
1385	if err != nil {
1386		return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
1387	}
1388	headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
1389	if err != nil {
1390		return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
1391	}
1392
1393	// Merge gates: draft, checks, approvals, CODEOWNERS, resolved threads,
1394	// all reported at once.
1395	if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
1396		return code
1397	}
1398
1399	upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
1400	if err != nil {
1401		return c.fail(protocol.ExitFailure, "%v", err)
1402	}
1403	if upToDate {
1404		// The head is already on the target: merged by hand and pushed, or
1405		// a merge whose ref update landed and whose record did not. Record
1406		// it rather than refuse, so a merge request cannot be stuck open
1407		// with no way to close it as merged (#108).
1408		if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil {
1409			return c.fail(protocol.ExitFailure, "%v", err)
1410		}
1411		c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d}`, mr.Number))
1412		return c.emit(map[string]any{"number": mr.Number, "strategy": "recorded", "sha": headSHA}, func(w io.Writer) {
1413			fmt.Fprintf(w, "%s already contains !%d; recorded as merged at %.10s\n", mr.TargetRef, mr.Number, headSHA)
1414		})
1415	}
1416	ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
1417	if err != nil {
1418		return c.fail(protocol.ExitFailure, "%v", err)
1419	}
1420
1421	// Signature policy matrix: with require_signed_commits, only
1422	// fast-forward is allowed — squash, rebase-replay, and merge commits
1423	// are all server-created and unsigned, violating the branch's own
1424	// policy — and every landed commit must be verified. An explicit
1425	// rebase when fast-forward is already possible IS a fast-forward
1426	// (nothing is rewritten), so it stays legal.
1427	if repo.Settings.RequireSignedCommits {
1428		if c.Source == queueSource && !ffPossible {
1429			return c.fail(protocol.ExitDenied,
1430				"%s is behind %s and %s requires signed commits, so the server cannot rebase it; rebase and push, and the merge stays queued",
1431				mr.SourceRef, mr.TargetRef, repo.Path())
1432		}
1433		if strategy == "merge" || strategy == "squash" || !ffPossible {
1434			return c.fail(protocol.ExitDenied,
1435				"%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
1436				repo.Path(), mr.SourceRef, mr.TargetRef)
1437		}
1438		strategy = "ff"
1439		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
1440		if err != nil {
1441			return c.fail(protocol.ExitFailure, "%v", err)
1442		}
1443		for _, sha := range commits {
1444			raw, err := gitutil.ReadCommit(dir, sha)
1445			if err != nil {
1446				return c.fail(protocol.ExitFailure, "%v", err)
1447			}
1448			parsed, err := sigParse(raw)
1449			if err != nil {
1450				return c.fail(protocol.ExitFailure, "%v", err)
1451			}
1452			res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
1453			if err != nil {
1454				return c.fail(protocol.ExitFailure, "%v", err)
1455			}
1456			if res.State != "verified" {
1457				return c.fail(protocol.ExitDenied,
1458					"%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
1459			}
1460		}
1461	}
1462	if strategy == "" {
1463		if ffPossible {
1464			strategy = "ff"
1465		} else {
1466			strategy = "merge"
1467		}
1468	}
1469	if strategy == "rebase" && ffPossible {
1470		// Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
1471		// and taking it keeps the original commits and their signatures.
1472		strategy = "ff"
1473	}
1474
1475	// Every server-created commit needs the merger's verified identity.
1476	mergerEmail := ""
1477	if strategy != "ff" {
1478		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
1479		if err != nil {
1480			return c.fail(protocol.ExitFailure, "%v", err)
1481		}
1482		if email == "" {
1483			return c.fail(protocol.ExitDenied,
1484				"%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
1485		}
1486		mergerEmail = email
1487	}
1488
1489	var newSHA string
1490	switch strategy {
1491	case "ff":
1492		if !ffPossible {
1493			return c.fail(protocol.ExitUsage,
1494				"fast-forward not possible: %s has diverged from the MR head; merge with the merge strategy, or rebase and push again", mr.TargetRef)
1495		}
1496		newSHA = headSHA
1497
1498	case "merge":
1499		tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
1500		if err != nil {
1501			return c.fail(protocol.ExitFailure, "%v", err)
1502		}
1503		if conflict {
1504			return c.fail(protocol.ExitUsage,
1505				"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
1506		}
1507		msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
1508		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
1509		if err != nil {
1510			return c.fail(protocol.ExitFailure, "%v", err)
1511		}
1512
1513	case "squash":
1514		// One new commit with the merged tree. Authorship credit goes to
1515		// the MR author (their verified identity when they have one); the
1516		// committer is the merger.
1517		tree := ""
1518		if ffPossible {
1519			t, err := gitutil.ResolveTree(dir, headSHA)
1520			if err != nil {
1521				return c.fail(protocol.ExitFailure, "%v", err)
1522			}
1523			tree = t
1524		} else {
1525			t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
1526			if err != nil {
1527				return c.fail(protocol.ExitFailure, "%v", err)
1528			}
1529			if conflict {
1530				return c.fail(protocol.ExitUsage,
1531					"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
1532			}
1533			tree = t
1534		}
1535		authorName, authorEmail := c.User.Username, mergerEmail
1536		if author, err := c.Store.UserByUsername(mr.Author); err == nil {
1537			if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
1538				authorName, authorEmail = author.Username, ae
1539			}
1540		}
1541		msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
1542		if mr.Body != "" {
1543			msg += "\n\n" + mr.Body
1544		}
1545		var err error
1546		newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
1547			authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
1548		if err != nil {
1549			return c.fail(protocol.ExitFailure, "%v", err)
1550		}
1551
1552	case "rebase":
1553		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
1554		if err != nil {
1555			return c.fail(protocol.ExitFailure, "%v", err)
1556		}
1557		// Oldest first.
1558		for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
1559			commits[i], commits[j] = commits[j], commits[i]
1560		}
1561		onto := targetSHA
1562		for _, sha := range commits {
1563			parents, err := gitutil.CommitParents(dir, sha)
1564			if err != nil {
1565				return c.fail(protocol.ExitFailure, "%v", err)
1566			}
1567			if len(parents) > 1 {
1568				return c.fail(protocol.ExitUsage,
1569					"the MR contains merge commit %.10s; a rebase merge needs linear history — choose the merge or squash strategy", sha)
1570			}
1571			base := onto // root commit: replay against the new tip itself
1572			if len(parents) == 1 {
1573				base = parents[0]
1574			}
1575			tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
1576			if err != nil {
1577				return c.fail(protocol.ExitFailure, "%v", err)
1578			}
1579			if conflict {
1580				return c.fail(protocol.ExitUsage,
1581					"commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
1582			}
1583			aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
1584			if err != nil {
1585				return c.fail(protocol.ExitFailure, "%v", err)
1586			}
1587			msg, err := gitutil.CommitMessage(dir, sha)
1588			if err != nil {
1589				return c.fail(protocol.ExitFailure, "%v", err)
1590			}
1591			onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
1592				aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
1593			if err != nil {
1594				return c.fail(protocol.ExitFailure, "%v", err)
1595			}
1596		}
1597		newSHA = onto
1598	}
1599
1600	// A stacked merge request's diff is against this branch. After a
1601	// fast-forward or merge commit the same commits are on the target and
1602	// its diff is unchanged there; after a squash or rebase they are not,
1603	// and the stack would carry this merge request's changes a second
1604	// time. Refuse rather than leave the stack wrong.
1605	var stack []store.MR
1606	if mr.SourceRepoID == repo.ID {
1607		stack, _ = c.Store.OpenMRsByTarget(repo.ID, mr.SourceRef)
1608	}
1609	if len(stack) > 0 && (strategy == "squash" || strategy == "rebase") {
1610		var nums []string
1611		for _, k := range stack {
1612			nums = append(nums, fmt.Sprintf("!%d", k.Number))
1613		}
1614		return c.fail(protocol.ExitUsage,
1615			"%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",
1616			fmt.Sprintf("!%d", mr.Number), strings.Join(nums, ", "), strategy, mr.SourceRef)
1617	}
1618
1619	// CAS so a concurrent push between our read and this write fails the
1620	// merge instead of silently discarding the push.
1621	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
1622		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
1623	}
1624	if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil {
1625		return c.fail(protocol.ExitFailure, "%v", err)
1626	}
1627	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
1628	// The stack moves up: whatever targeted this branch now targets what
1629	// it merged into, reviews intact, since that diff is the one they
1630	// were of.
1631	for _, k := range stack {
1632		if err := c.Store.RetargetKeepingReviews(k.ID, mr.TargetRef); err != nil {
1633			continue
1634		}
1635		c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number))
1636		if parts, err := c.Store.MRParticipants(k.ID); err == nil {
1637			notify(c, parts, notice{repo: repo, kind: "mr", number: k.Number,
1638				subject: mrSubject(repo, k.Number, k.Title),
1639				action:  fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number),
1640				path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)})
1641		}
1642	}
1643	// Merges bypass receive-pack, so the commit-message issue actions
1644	// (closes #N, references) run here for the newly landed commits. The
1645	// description is scanned after them, so a commit wins the attribution
1646	// when both name the same issue.
1647	if mr.TargetRef == repo.DefaultBranch {
1648		ProcessCommitMessages(c.Store, dir, repo, c.User.ID, c.Scope, targetSHA, newSHA)
1649		ProcessMRDescription(c.Store, repo, mr, c.User.ID, c.Scope)
1650		RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
1651		c.Store.RequestSymbolIndex(repo.ID, false)
1652	}
1653	// A merge moves the ref directly, so it never reaches post-receive and
1654	// none of the ref-update work fires on its own. The event webhooks
1655	// subscribe to, and the branch's CI jobs, happen here instead.
1656	c.Store.RecordEvent(repo.ID, c.User.ID, "push", fmt.Sprintf(
1657		`{"ref":%q,"old":%q,"new":%q,"forced":false,"deleted":false}`,
1658		targetRef, targetSHA, newSHA))
1659	QueueBranchBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL,
1660		repo, c.User.ID, mr.TargetRef, targetSHA, newSHA, time.Now())
1661	c.Store.MarkMirrorsDirty(repo.ID, "push")
1662	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1663		notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
1664			subject: mrSubject(repo, mr.Number, mr.Title),
1665			action:  fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy),
1666			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1667	}
1668	return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
1669		fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
1670	})
1671}
1672
1673// reviewGates refuses a merge whose gates are not all met, naming every
1674// unmet one. Returns -1 to proceed.
1675func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
1676	g, err := MergeGates(c.Store, repo, mr, dir, targetSHA, headSHA)
1677	if err != nil {
1678		return c.fail(protocol.ExitFailure, "%v", err)
1679	}
1680	if len(g.Unmet) > 0 {
1681		return c.fail(protocol.ExitDenied, "%s", strings.Join(g.Unmet, "; "))
1682	}
1683	return -1
1684}
1685
1686// checksExpected reports whether anything was going to report a status
1687// on this head. A repository with no CI configuration and no history of
1688// statuses can never satisfy require_checks, and refusing its merges
1689// leaves no remedy but turning the setting off. Two things say a report
1690// was coming: a .gitbay/ci.yml at the head with a job a push runs, and a
1691// status having ever been recorded in the repository, which is how a
1692// repository reporting from outside through `status set` looks.
1693func checksExpected(st *store.Store, repoID int64, dir, headSHA string) bool {
1694	if seen, err := st.RepoHasStatuses(repoID); err != nil || seen {
1695		return true
1696	}
1697	return headRunsJobs(dir, headSHA)
1698}
1699
1700// headRunsJobs reports whether a push of this head would have queued or
1701// skipped a job, and so left it a status. A configuration that will not
1702// parse counts as running jobs: the push recorded a ci/config failure
1703// for it, so the head is not silent and this is not the branch that
1704// decides.
1705func headRunsJobs(dir, headSHA string) bool {
1706	raw, err := gitutil.ReadBlob(dir, headSHA, ci.ConfigPath, 1<<16)
1707	if err != nil {
1708		return false
1709	}
1710	jobs, err := ci.Parse(raw)
1711	if err != nil {
1712		return true
1713	}
1714	for _, j := range jobs {
1715		if j.Tags == "" && j.Schedule == "" {
1716			return true
1717		}
1718	}
1719	return false
1720}
1721
1722// MergeGates computes where a merge request stands against its
1723// repository's gates: draft, require_checks, require_approvals (fresh,
1724// non-author, latest review per reviewer from someone who can write; a
1725// fresh request-changes blocks), require_codeowners and require_resolved.
1726// Unmet carries one sentence per gate not passed. Fast-forward is
1727// reported, not gated: whether it matters depends on the strategy.
1728func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) (GatesOut, error) {
1729	set := repo.Settings
1730	g := GatesOut{Draft: mr.Draft, ApprovalsRequired: set.RequireApprovals,
1731		CodeownersRequired: set.RequireCodeowners, ResolvedRequired: set.RequireResolved,
1732		ChecksRequired: set.RequireChecks}
1733	// A draft is open but not asking. This gate is unconditional — no
1734	// setting turns it off — because the author said so themselves.
1735	if mr.Draft {
1736		g.Unmet = append(g.Unmet, fmt.Sprintf("!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number))
1737	}
1738
1739	// Checks: with require_checks, every status the head carries must be
1740	// green, a head something was going to report on must carry some, and
1741	// every required context must have reported: one that has not is
1742	// pending whatever the others say (#258). Setting contexts turns
1743	// require_checks on; turned off again, the list is kept and unread.
1744	statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
1745	if err != nil {
1746		return g, err
1747	}
1748	g.Checks = store.CombinedStatus(statuses)
1749	if set.RequireChecks {
1750		reported := map[string]bool{}
1751		for _, s := range statuses {
1752			reported[s.Context] = true
1753		}
1754		for _, want := range set.RequiredContexts {
1755			if !reported[want] {
1756				g.ChecksMissing = append(g.ChecksMissing, want)
1757			}
1758		}
1759		if len(g.ChecksMissing) > 0 && (g.Checks == "" || g.Checks == "success") {
1760			g.Checks = "pending"
1761		}
1762		switch g.Checks {
1763		case "success":
1764		case "":
1765			if checksExpected(st, repo.ID, dir, headSHA) {
1766				g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA))
1767			}
1768		default:
1769			var bad []string
1770			for _, st := range statuses {
1771				if st.State != "success" {
1772					bad = append(bad, st.Context+"="+st.State)
1773				}
1774			}
1775			for _, m := range g.ChecksMissing {
1776				bad = append(bad, m+"=missing")
1777			}
1778			g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", ")))
1779		}
1780	}
1781
1782	reviews, err := st.ListMRReviews(mr.ID)
1783	if err != nil {
1784		return g, err
1785	}
1786	// Latest fresh review per reviewer decides their stance — but only
1787	// from someone the repository trusts to write to it. Reviewing is
1788	// open to any reader, which is what makes an outside opinion on a
1789	// public change possible; deciding a merge gate is not the same
1790	// thing, and counting every verdict let anyone with an account
1791	// satisfy require_approvals or block a merge indefinitely (#147).
1792	counts := ReviewersWhoCount(st, repo, reviews)
1793	latest := map[string]string{}
1794	for _, r := range reviews {
1795		if r.Stale || r.Reviewer == mr.Author || !counts[r.Reviewer] {
1796			continue
1797		}
1798		latest[r.Reviewer] = r.Verdict
1799	}
1800	for who, verdict := range latest {
1801		switch verdict {
1802		case "approve":
1803			g.Approvals = append(g.Approvals, who)
1804		case "request_changes":
1805			g.ChangesRequested = append(g.ChangesRequested, who)
1806		}
1807	}
1808	slices.Sort(g.Approvals)
1809	slices.Sort(g.ChangesRequested)
1810	if set.RequireApprovals > 0 {
1811		if len(g.ChangesRequested) > 0 {
1812			g.Unmet = append(g.Unmet, fmt.Sprintf("%s requested changes on !%d; resolve their review before merging", strings.Join(g.ChangesRequested, ", "), mr.Number))
1813		}
1814		if len(g.Approvals) < set.RequireApprovals {
1815			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)))
1816		}
1817	}
1818
1819	// CODEOWNERS: every owned changed file needs an approval from one of
1820	// its owners. require_codeowners is the opt-in — a repository can
1821	// carry the file as documentation of who to ask without it gating
1822	// merges — and it does not wait on require_approvals (#99).
1823	if set.RequireCodeowners {
1824		content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20)
1825		if err != nil {
1826			content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
1827		}
1828		if err != nil || len(content) == 0 {
1829			g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires CODEOWNERS approval but %s carries no CODEOWNERS file", repo.Path(), mr.TargetRef))
1830		} else {
1831			rules := policy.ParseCodeowners(string(content))
1832			base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
1833			if err != nil {
1834				return g, err
1835			}
1836			files, err := gitutil.DiffFiles(dir, base, headSHA)
1837			if err != nil {
1838				return g, err
1839			}
1840			approved := map[string]bool{}
1841			for _, a := range g.Approvals {
1842				approved[a] = true
1843			}
1844			missing := map[string][]string{} // owner-set key -> paths
1845			var keys []string
1846			for _, f := range files {
1847				owners := policy.OwnersFor(rules, f)
1848				if owners == nil {
1849					continue
1850				}
1851				ok := false
1852				for _, o := range owners {
1853					if approved[o] {
1854						ok = true
1855						break
1856					}
1857				}
1858				if !ok {
1859					key := strings.Join(owners, ",")
1860					if _, seen := missing[key]; !seen {
1861						keys = append(keys, key)
1862					}
1863					missing[key] = append(missing[key], f)
1864				}
1865			}
1866			if len(missing) > 0 {
1867				slices.Sort(keys)
1868				var parts []string
1869				for _, key := range keys {
1870					paths := missing[key]
1871					g.OwnersOutstanding = append(g.OwnersOutstanding, OwnersOut{Files: paths, Owners: strings.Split(key, ",")})
1872					if len(paths) > 3 {
1873						paths = paths[:3]
1874					}
1875					parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), key))
1876				}
1877				g.Unmet = append(g.Unmet, "CODEOWNERS approval missing for: "+strings.Join(parts, "; "))
1878			}
1879		}
1880	}
1881
1882	n, err := st.UnresolvedThreadCount(mr.ID)
1883	if err != nil {
1884		return g, err
1885	}
1886	g.OpenThreads = n
1887	if set.RequireResolved && n > 0 {
1888		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))
1889	}
1890
1891	if ff, err := gitutil.IsAncestor(dir, targetSHA, headSHA); err == nil {
1892		g.FastForward = ff
1893	}
1894	return g, nil
1895}
1896
1897func runMRDraft(c *Ctx, args []string) int { return setMRDraft(c, args, true) }
1898func runMRReady(c *Ctx, args []string) int { return setMRDraft(c, args, false) }
1899
1900func setMRDraft(c *Ctx, args []string, draft bool) int {
1901	repo, mr, code := mrRef(c, args, policy.CanRead)
1902	if code >= 0 {
1903		return code
1904	}
1905	if code := refuseArchived(c, repo); code >= 0 {
1906		return code
1907	}
1908	if len(args) != 2 {
1909		return c.usage()
1910	}
1911	if code := authorOrWrite(c, repo, mr.Author, "change this merge request"); code >= 0 {
1912		return code
1913	}
1914	if mr.State != "open" && mr.State != "source_gone" {
1915		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
1916	}
1917	if mr.Draft == draft {
1918		state := "already ready"
1919		if draft {
1920			state = "already a draft"
1921		}
1922		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, state)
1923	}
1924	if err := c.Store.SetMRDraft(mr.ID, draft); err != nil {
1925		return c.fail(protocol.ExitFailure, "%v", err)
1926	}
1927	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft",
1928		fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft))
1929	if !draft {
1930		TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1931	}
1932	// Marking ready is the request for review; going back to draft
1933	// withdraws it and is not worth anyone's inbox.
1934	//
1935	// The targets are the repository's, not the thread's participants.
1936	// Until someone comments or reviews, the only participant is the
1937	// author, who is the actor and excluded — so notifying participants
1938	// here reaches nobody, which is exactly what opening it as a draft
1939	// and then marking it ready would do. Opening a merge request tells
1940	// the repository; so does saying it is finally asking. A review
1941	// request made before ready — or on an earlier revision — reaches its
1942	// target here too: they are exactly who else is being asked.
1943	if !draft {
1944		if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
1945			parts, _ := c.Store.MRParticipants(mr.ID)
1946			reviewers, _ := c.Store.MRReviewRequestIDs(mr.ID)
1947			notify(c, append(append(targets, parts...), reviewers...), notice{repo: repo, kind: "mr", number: mr.Number,
1948				subject: mrSubject(repo, mr.Number, mr.Title),
1949				action:  fmt.Sprintf("marked !%d ready for review", mr.Number),
1950				path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1951		}
1952	}
1953	return c.emit(map[string]any{"number": mr.Number, "draft": draft}, func(w io.Writer) {
1954		fmt.Fprintf(w, "%s!%d is %s\n", repo.Path(), mr.Number, map[bool]string{true: "a draft", false: "ready"}[draft])
1955	})
1956}
1957
1958func runMRClose(c *Ctx, args []string) int {
1959	f, err := c.parseArgs(args, flagSpec{Values: []string{"--by"}, MaxPos: 2,
1960		Usage: "mr close <owner/name> <n> [--by <m>]"})
1961	if err != nil {
1962		return c.fail(protocol.ExitUsage, "%v", err)
1963	}
1964	repo, mr, code := mrRef(c, f.Pos, policy.CanRead)
1965	if code >= 0 {
1966		return code
1967	}
1968	if code := refuseArchived(c, repo); code >= 0 {
1969		return code
1970	}
1971	if len(f.Pos) != 2 {
1972		return c.usage()
1973	}
1974	if code := authorOrWrite(c, repo, mr.Author, "close this merge request"); code >= 0 {
1975		return code
1976	}
1977	if mr.State == "merged" || mr.State == "closed" {
1978		return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
1979	}
1980	var by int64
1981	if f.Has("--by") {
1982		by, code = resolveSupersededBy(c, repo, mr.Number, f.Value("--by"))
1983		if code >= 0 {
1984			return code
1985		}
1986	}
1987	if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil {
1988		return c.fail(protocol.ExitFailure, "%v", err)
1989	}
1990	eventData := fmt.Sprintf(`{"number":%d}`, mr.Number)
1991	if by != 0 {
1992		if err := c.Store.SetSupersededBy(mr.ID, by); err != nil {
1993			return c.fail(protocol.ExitFailure, "%v", err)
1994		}
1995		eventData = fmt.Sprintf(`{"number":%d,"by":%d}`, mr.Number, by)
1996	}
1997	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData)
1998	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1999		notify(c, parts, notice{repo: repo, kind: "mr", number: mr.Number,
2000			subject: mrSubject(repo, mr.Number, mr.Title),
2001			action:  fmt.Sprintf("closed !%d", mr.Number),
2002			path:    fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
2003	}
2004	return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
2005		fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
2006	})
2007}
2008
2009// resolveSupersededBy validates a --superseded-by/--by value against the
2010// merge request it would be set on: it must parse, name another merge
2011// request in the same repository (never itself), and that request must
2012// exist. -1 as the returned code means the value is good to use.
2013func resolveSupersededBy(c *Ctx, repo store.Repo, number int64, v string) (int64, int) {
2014	m, err := strconv.ParseInt(v, 10, 64)
2015	if err != nil {
2016		return 0, c.fail(protocol.ExitUsage, "bad MR number %q", v)
2017	}
2018	if m == number {
2019		return 0, c.fail(protocol.ExitUsage, "a merge request cannot supersede itself")
2020	}
2021	if _, err := c.Store.MRByNumber(repo.ID, m); errors.Is(err, store.ErrNotFound) {
2022		return 0, c.fail(protocol.ExitNotFound, "no merge request !%d on %s", m, repo.Path())
2023	} else if err != nil {
2024		return 0, c.fail(protocol.ExitFailure, "%v", err)
2025	}
2026	return m, -1
2027}
2028
2029// reviewAction is what a review notification says it was. A verdict with
2030// a batch behind it is a different thing from a bare verdict, and the
2031// person reading the mail is deciding whether to open it.
2032func reviewAction(number int64, verdict string, published int64) string {
2033	if published > 0 {
2034		return fmt.Sprintf("reviewed !%d: %s, with %d comment(s)", number, verdict, published)
2035	}
2036	return fmt.Sprintf("reviewed !%d: %s", number, verdict)
2037}
2038
2039// RevisionOut is one head a merge request has had.
2040type RevisionOut struct {
2041	N         int    `json:"n"` // 1 is the first push
2042	SHA       string `json:"sha"`
2043	BaseSHA   string `json:"base_sha,omitempty"`
2044	CreatedAt string `json:"created_at"`
2045	Current   bool   `json:"current,omitempty"`
2046}
2047
2048func mrRevisions(c *Ctx, mr store.MR) ([]RevisionOut, error) {
2049	heads, err := c.Store.MRHeads(mr.ID)
2050	if err != nil {
2051		return nil, err
2052	}
2053	out := make([]RevisionOut, 0, len(heads))
2054	for i, h := range heads {
2055		out = append(out, RevisionOut{N: i + 1, SHA: h.SHA, BaseSHA: h.BaseSHA,
2056			CreatedAt: h.CreatedAt, Current: h.SHA == mr.HeadSHA})
2057	}
2058	return out, nil
2059}
2060
2061func runMRRevisions(c *Ctx, args []string) int {
2062	repo, mr, code := mrRef(c, args, policy.CanRead)
2063	if code >= 0 {
2064		return code
2065	}
2066	if len(args) != 2 {
2067		return c.usage()
2068	}
2069	revs, err := mrRevisions(c, mr)
2070	if err != nil {
2071		return c.fail(protocol.ExitFailure, "%v", err)
2072	}
2073	return c.emit(revs, func(w io.Writer) {
2074		tb := c.table(w, "REV", "SHA", "WHEN")
2075		for _, r := range revs {
2076			mark := " "
2077			if r.Current {
2078				mark = "*"
2079			}
2080			tb.row(cRef(fmt.Sprintf("%s v%d", mark, r.N)), cRef(fmt.Sprintf("%.10s", r.SHA)), cAge(r.CreatedAt))
2081		}
2082		tb.flush()
2083		if len(revs) < 2 {
2084			fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n",
2085				repo.Path(), mr.Number)
2086		}
2087	})
2088}
2089
2090func runMRRangeDiff(c *Ctx, args []string) int {
2091	f, err := c.parseArgs(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: c.Cmd.Usage})
2092	if err != nil {
2093		return c.fail(protocol.ExitUsage, "%v", err)
2094	}
2095	repo, mr, code := mrRef(c, f.Pos, policy.CanRead)
2096	if code >= 0 {
2097		return code
2098	}
2099	if len(f.Pos) != 2 {
2100		return c.usage()
2101	}
2102	revs, err := mrRevisions(c, mr)
2103	if err != nil {
2104		return c.fail(protocol.ExitFailure, "%v", err)
2105	}
2106	// One revision is a merge request nobody has pushed to since it was
2107	// opened. The argv was fine and the answer is "nothing changed", so
2108	// this succeeds with an empty patch rather than failing.
2109	if len(revs) < 2 {
2110		fmt.Fprintf(c.Stderr, "%s!%d has one revision; nothing to compare it against\n",
2111			repo.Path(), mr.Number)
2112		return protocol.ExitOK
2113	}
2114	// Default to the two most recent, which is "what changed since the
2115	// last push" — the question a stale review asks.
2116	from, to := revs[len(revs)-2], revs[len(revs)-1]
2117	pick := func(sha string) (RevisionOut, bool) {
2118		for _, r := range revs {
2119			if strings.HasPrefix(r.SHA, sha) {
2120				return r, true
2121			}
2122		}
2123		return RevisionOut{}, false
2124	}
2125	if v := f.Value("--from"); v != "" {
2126		r, ok := pick(v)
2127		if !ok {
2128			return c.fail(protocol.ExitNotFound, "%.12s is not a revision of !%d; see `mr revisions`", v, mr.Number)
2129		}
2130		from = r
2131	}
2132	if v := f.Value("--to"); v != "" {
2133		r, ok := pick(v)
2134		if !ok {
2135			return c.fail(protocol.ExitNotFound, "%.12s is not a revision of !%d; see `mr revisions`", v, mr.Number)
2136		}
2137		to = r
2138	}
2139	if from.SHA == to.SHA {
2140		return c.fail(protocol.ExitUsage, "--from and --to are the same revision")
2141	}
2142
2143	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
2144	// A revision recorded before its base could be worked out, or by a
2145	// migration backfill, falls back to the target's merge base.
2146	baseOf := func(r RevisionOut) string {
2147		if r.BaseSHA != "" {
2148			return r.BaseSHA
2149		}
2150		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, r.SHA)
2151		if err != nil {
2152			return r.SHA + "^"
2153		}
2154		return b
2155	}
2156	patch, truncated, err := gitutil.RangeDiff(dir, baseOf(from), from.SHA, baseOf(to), to.SHA, 4<<20)
2157	if err != nil {
2158		return c.fail(protocol.ExitFailure,
2159			"%v (the objects for an older revision may have been garbage-collected)", err)
2160	}
2161	fmt.Fprint(c.Stdout, c.Term.diff(patch))
2162	if truncated {
2163		fmt.Fprintln(c.Stderr, "range-diff truncated at 4 MiB")
2164	}
2165	return protocol.ExitOK
2166}
2167
2168// reviewersWhoCount is the set of reviewers whose verdict decides a merge
2169// gate: those with write access to the repository.
2170//
2171// Write, rather than a separate reviewer role, because it is the same
2172// question the gates already answer — a person who could push this change
2173// themselves is the person whose approval means the repository accepts
2174// it. Someone named in CODEOWNERS without write is a misconfiguration the
2175// owner should fix rather than a case to special-case here: they could
2176// not merge what they approved.
2177// Exported because the web renders the same distinction: a page that
2178// showed an approval the gate ignores would differ from the gate, and the
2179// difference would only surface when a merge was refused.
2180func ReviewersWhoCount(st *store.Store, repo store.Repo, reviews []store.MRReview) map[string]bool {
2181	counts := map[string]bool{}
2182	for _, r := range reviews {
2183		if _, done := counts[r.Reviewer]; done {
2184			continue
2185		}
2186		counts[r.Reviewer] = false
2187		u, err := st.UserByUsername(r.Reviewer)
2188		if err != nil {
2189			continue
2190		}
2191		grant, err := st.AccessRole(repo.ID, u.ID)
2192		if err != nil {
2193			continue
2194		}
2195		counts[r.Reviewer] = policy.CanWrite(u, repo, grant)
2196	}
2197	return counts
2198}