internal/control/mr.go

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