internal/control/mr.go

3bcdce33fb9a2309312854331359d376171c7368
gitbay/internal/control/mr.go history · blame · raw

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