internal/control/mr.go

1140 lines · 38669 bytes

   1package control
   2
   3import (
   4	"errors"
   5	"fmt"
   6	"io"
   7	"slices"
   8	"strconv"
   9	"strings"
  10
  11	"gitbay.org/gitbay/internal/gitutil"
  12	"gitbay.org/gitbay/internal/policy"
  13	"gitbay.org/gitbay/internal/protocol"
  14	"gitbay.org/gitbay/internal/sig"
  15	"gitbay.org/gitbay/internal/store"
  16)
  17
  18func init() {
  19	register(Command{Path: []string{"repo", "fork"},
  20		Summary: "fork a repository under your account",
  21		Usage:   "repo fork <owner/name> [--name <n>]", Run: runRepoFork})
  22	register(Command{Path: []string{"repo", "settings", "require-approvals"},
  23		Summary: "require N fresh approvals to merge",
  24		Usage:   "repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals})
  25	register(Command{Path: []string{"repo", "settings", "require-resolved"},
  26		Summary: "require all review threads resolved to merge",
  27		Usage:   "repo settings require-resolved <owner/name> on|off", Run: runRequireResolved})
  28	register(Command{Path: []string{"repo", "settings", "require-checks"},
  29		Summary: "gate merges on green statuses",
  30		Usage:   "repo settings require-checks <owner/name> on|off", Run: runRequireChecks})
  31	register(Command{Path: []string{"repo", "settings", "require-signed"},
  32		Summary: "require verified commit signatures",
  33		Usage:   "repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
  34	register(Command{Path: []string{"mr", "create"},
  35		Summary:    "open a merge request",
  36		Usage:      "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -] [--format md|org]",
  37		ReadsStdin: true, Run: runMRCreate})
  38	register(Command{Path: []string{"mr", "list"},
  39		Summary: "list merge requests",
  40		Usage:   "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList})
  41	register(Command{Path: []string{"mr", "show"},
  42		Summary: "show a merge request",
  43		Usage:   "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
  44	register(Command{Path: []string{"mr", "diff"},
  45		Summary: "show the diff",
  46		Usage:   "mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
  47	register(Command{Path: []string{"mr", "edit"},
  48		Summary:    "edit title or body",
  49		Usage:      "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
  50		ReadsStdin: true, Run: runMREdit})
  51	register(Command{Path: []string{"mr", "retarget"},
  52		Summary: "retarget onto another branch",
  53		Usage:   "mr retarget <owner/name> <n> <branch>", Run: runMRRetarget})
  54	register(Command{Path: []string{"mr", "comment"},
  55		Summary:    "comment",
  56		Usage:      "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
  57		ReadsStdin: true, Run: runMRComment})
  58	register(Command{Path: []string{"mr", "review"},
  59		Summary: "review",
  60		Usage:   "mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
  61	register(Command{Path: []string{"mr", "merge"},
  62		Summary: "merge",
  63		Usage:   "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
  64	register(Command{Path: []string{"mr", "close"},
  65		Summary: "close without merging",
  66		Usage:   "mr close <owner/name> <n>", Run: runMRClose})
  67}
  68
  69func runRepoFork(c *Ctx, args []string) int {
  70	var path, name string
  71	for i := 0; i < len(args); i++ {
  72		switch args[i] {
  73		case "--name":
  74			if i+1 >= len(args) {
  75				return c.fail(protocol.ExitUsage, "--name requires a value")
  76			}
  77			name = args[i+1]
  78			i++
  79		default:
  80			if path != "" {
  81				return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
  82			}
  83			path = args[i]
  84		}
  85	}
  86	if path == "" {
  87		return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
  88	}
  89	src, code := resolveRepo(c, path, policy.CanRead)
  90	if code >= 0 {
  91		return code
  92	}
  93	if name == "" {
  94		name = src.Name
  95	}
  96	if err := policy.ValidateName(name); err != nil {
  97		return c.fail(protocol.ExitUsage, "%v", err)
  98	}
  99	id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
 100	if err != nil {
 101		return c.fail(protocol.ExitFailure, "%v", err)
 102	}
 103	if err := c.Store.SetForkOf(id, src.ID); err != nil {
 104		return c.fail(protocol.ExitFailure, "%v", err)
 105	}
 106	dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name)
 107	srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
 108	if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
 109		c.Store.DeleteRepo(id)
 110		return c.fail(protocol.ExitFailure, "%v", err)
 111	}
 112	if desc := gitutil.ReadDescription(srcDir); desc != "" {
 113		gitutil.WriteDescription(dstDir, desc)
 114	}
 115	if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
 116		// Empty source repos have nothing to fetch; that is fine.
 117		if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
 118			c.Store.DeleteRepo(id)
 119			return c.fail(protocol.ExitFailure, "copying refs: %v", err)
 120		}
 121	}
 122	forkPath := c.User.Username + "/" + name
 123	return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) {
 124		fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
 125	})
 126}
 127
 128func runRequireApprovals(c *Ctx, args []string) int {
 129	if len(args) != 2 {
 130		return c.fail(protocol.ExitUsage, "usage: repo settings require-approvals <owner/name> <n>")
 131	}
 132	n, err := strconv.Atoi(args[1])
 133	if err != nil || n < 0 || n > 20 {
 134		return c.fail(protocol.ExitUsage, "approvals must be 0..20")
 135	}
 136	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 137	if code >= 0 {
 138		return code
 139	}
 140	s := repo.Settings
 141	s.RequireApprovals = n
 142	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
 143		return c.fail(protocol.ExitFailure, "%v", err)
 144	}
 145	return c.emit(s, func(w io.Writer) {
 146		fmt.Fprintf(w, "require_approvals %d on %s\n", n, repo.Path())
 147	})
 148}
 149
 150func runRequireResolved(c *Ctx, args []string) int {
 151	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 152		return c.fail(protocol.ExitUsage, "usage: repo settings require-resolved <owner/name> on|off")
 153	}
 154	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 155	if code >= 0 {
 156		return code
 157	}
 158	s := repo.Settings
 159	s.RequireResolved = args[1] == "on"
 160	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
 161		return c.fail(protocol.ExitFailure, "%v", err)
 162	}
 163	return c.emit(s, func(w io.Writer) {
 164		fmt.Fprintf(w, "require_resolved %s on %s\n", args[1], repo.Path())
 165	})
 166}
 167
 168func runRequireChecks(c *Ctx, args []string) int {
 169	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 170		return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off")
 171	}
 172	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 173	if code >= 0 {
 174		return code
 175	}
 176	s := repo.Settings
 177	s.RequireChecks = args[1] == "on"
 178	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
 179		return c.fail(protocol.ExitFailure, "%v", err)
 180	}
 181	return c.emit(s, func(w io.Writer) {
 182		fmt.Fprintf(w, "require_checks %s on %s\n", args[1], repo.Path())
 183	})
 184}
 185
 186func runRequireSigned(c *Ctx, args []string) int {
 187	if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
 188		return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off")
 189	}
 190	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 191	if code >= 0 {
 192		return code
 193	}
 194	s := repo.Settings
 195	s.RequireSignedCommits = args[1] == "on"
 196	if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
 197		return c.fail(protocol.ExitFailure, "%v", err)
 198	}
 199	return c.emit(s, func(w io.Writer) {
 200		fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path())
 201	})
 202}
 203
 204// mrRef parses "<owner/name> <n>" and loads the MR.
 205func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) {
 206	if len(args) < 2 {
 207		return store.Repo{}, store.MR{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
 208	}
 209	repo, code := resolveRepo(c, args[0], perm)
 210	if code >= 0 {
 211		return repo, store.MR{}, code
 212	}
 213	n, err := strconv.ParseInt(args[1], 10, 64)
 214	if err != nil {
 215		return repo, store.MR{}, c.fail(protocol.ExitUsage, "bad MR number %q", args[1])
 216	}
 217	mr, err := c.Store.MRByNumber(repo.ID, n)
 218	if errors.Is(err, store.ErrNotFound) {
 219		return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
 220	}
 221	if err != nil {
 222		return repo, mr, c.fail(protocol.ExitFailure, "%v", err)
 223	}
 224	return repo, mr, -1
 225}
 226
 227func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
 228
 229func runMRCreate(c *Ctx, args []string) int {
 230	var path, source, target, title, body, file, format string
 231	for i := 0; i < len(args); i++ {
 232		switch args[i] {
 233		case "--source", "--target", "--title", "--body", "--file", "--format":
 234			if i+1 >= len(args) {
 235				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 236			}
 237			v := args[i+1]
 238			switch args[i] {
 239			case "--source":
 240				source = v
 241			case "--target":
 242				target = v
 243			case "--title":
 244				title = v
 245			case "--body":
 246				body = v
 247			case "--file":
 248				file = v
 249			case "--format":
 250				format = v
 251			}
 252			i++
 253		default:
 254			if path != "" {
 255				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
 256			}
 257			path = args[i]
 258		}
 259	}
 260	if path == "" || source == "" || title == "" {
 261		return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
 262	}
 263	fmtName, err := markupFormat(format)
 264	if err != nil {
 265		return c.fail(protocol.ExitUsage, "%v", err)
 266	}
 267	if fmtName == "" {
 268		fmtName = "md"
 269	}
 270	repo, code := resolveRepo(c, path, policy.CanRead)
 271	if code >= 0 {
 272		return code
 273	}
 274	if code := refuseArchived(c, repo); code >= 0 {
 275		return code
 276	}
 277	if target == "" {
 278		target = repo.DefaultBranch
 279	}
 280
 281	// Source is "branch" (same repo) or "owner/name:branch" (a fork).
 282	srcRepo := repo
 283	srcBranch := source
 284	if sp, br, ok := strings.Cut(source, ":"); ok {
 285		srcBranch = br
 286		var scode int
 287		srcRepo, scode = resolveRepo(c, sp, policy.CanRead)
 288		if scode >= 0 {
 289			return scode
 290		}
 291		if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID {
 292			return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path())
 293		}
 294	}
 295	srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
 296	headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch)
 297	if err != nil {
 298		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path())
 299	}
 300	b, err := bodyFrom(c, body, file)
 301	if err != nil {
 302		return c.fail(protocol.ExitUsage, "%v", err)
 303	}
 304	n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName)
 305	if err != nil {
 306		return c.fail(protocol.ExitFailure, "%v", err)
 307	}
 308	// Fetch the head into the target so the target owns the objects.
 309	dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 310	if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
 311		return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
 312	}
 313	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
 314	if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
 315		notifyUsers(c, targets, mrSubject(repo, n, title),
 316			notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n)))
 317	}
 318	return c.emit(map[string]any{"number": n, "head_sha": headSHA}, func(w io.Writer) {
 319		fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
 320	})
 321}
 322
 323type mrOut struct {
 324	Number     int64  `json:"number"`
 325	Title      string `json:"title"`
 326	State      string `json:"state"`
 327	Author     string `json:"author"`
 328	Source     string `json:"source"` // owner/name:branch, or branch, "" if gone
 329	TargetRef  string `json:"target_ref"`
 330	HeadSHA    string `json:"head_sha"`
 331	Body       string `json:"body,omitempty"`
 332	BodyFormat string `json:"body_format,omitempty"`
 333	Milestone  string `json:"milestone,omitempty"`
 334	CreatedAt  string `json:"created_at"`
 335}
 336
 337func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
 338	src := ""
 339	if m.SourcePath != "" {
 340		if m.SourceRepoID == repo.ID {
 341			src = m.SourceRef
 342		} else {
 343			src = m.SourcePath + ":" + m.SourceRef
 344		}
 345	}
 346	o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
 347		Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
 348		CreatedAt: m.CreatedAt}
 349	if withBody {
 350		o.Body = m.Body
 351		o.BodyFormat = m.BodyFormat
 352	}
 353	return o
 354}
 355
 356func runMRList(c *Ctx, args []string) int {
 357	args, p, code := parsePageFlags(c, args, "mr", true)
 358	if code >= 0 {
 359		return code
 360	}
 361	state := "open"
 362	var path string
 363	for i := 0; i < len(args); i++ {
 364		switch args[i] {
 365		case "--state":
 366			if i+1 >= len(args) {
 367				return c.fail(protocol.ExitUsage, "--state requires a value")
 368			}
 369			state = args[i+1]
 370			i++
 371		default:
 372			if path != "" {
 373				return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
 374			}
 375			path = args[i]
 376		}
 377	}
 378	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
 379	if path == "" || !valid[state] {
 380		return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]")
 381	}
 382	repo, code := resolveRepo(c, path, policy.CanRead)
 383	if code >= 0 {
 384		return code
 385	}
 386	mrs, err := c.Store.ListMRs(repo.ID, state, p.queryLimit(), p.keyInt())
 387	if err != nil {
 388		return c.fail(protocol.ExitFailure, "%v", err)
 389	}
 390	mrs, next := trimPage(p, mrs, "mr", func(m store.MR) string {
 391		return strconv.FormatInt(m.Number, 10)
 392	})
 393	var ds []mrOut
 394	for _, m := range mrs {
 395		ds = append(ds, mrToOut(repo, m, false))
 396	}
 397	return c.emitPage(p, ds, next, func(w io.Writer) {
 398		for _, d := range ds {
 399			fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef)
 400		}
 401	})
 402}
 403
 404func runMRShow(c *Ctx, args []string) int {
 405	repo, mr, code := mrRef(c, args, policy.CanRead)
 406	if code >= 0 {
 407		return code
 408	}
 409	if len(args) != 2 {
 410		return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>")
 411	}
 412	comments, err := c.Store.ListMRComments(mr.ID)
 413	if err != nil {
 414		return c.fail(protocol.ExitFailure, "%v", err)
 415	}
 416	reviews, err := c.Store.ListMRReviews(mr.ID)
 417	if err != nil {
 418		return c.fail(protocol.ExitFailure, "%v", err)
 419	}
 420	statuses, err := c.Store.ListCommitStatuses(repo.ID, mr.HeadSHA)
 421	if err != nil {
 422		return c.fail(protocol.ExitFailure, "%v", err)
 423	}
 424	unresolved, err := c.Store.UnresolvedThreadCount(mr.ID)
 425	if err != nil {
 426		return c.fail(protocol.ExitFailure, "%v", err)
 427	}
 428	type commentOut struct {
 429		Author     string `json:"author"`
 430		Body       string `json:"body"`
 431		BodyFormat string `json:"body_format,omitempty"`
 432		CreatedAt  string `json:"created_at"`
 433	}
 434	type reviewOut struct {
 435		Reviewer string `json:"reviewer"`
 436		Verdict  string `json:"verdict"`
 437		Stale    bool   `json:"stale"`
 438	}
 439	type checkOut struct {
 440		Context string `json:"context"`
 441		State   string `json:"state"`
 442		URL     string `json:"url,omitempty"`
 443	}
 444	var checks []checkOut
 445	for _, st := range statuses {
 446		checks = append(checks, checkOut{st.Context, st.State, st.TargetURL})
 447	}
 448	var cs []commentOut
 449	for _, cm := range comments {
 450		cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
 451	}
 452	var rs []reviewOut
 453	for _, r := range reviews {
 454		rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale})
 455	}
 456	// The commits this MR carries: base..head, the diff's range.
 457	type commitOut struct {
 458		SHA     string `json:"sha"`
 459		Subject string `json:"subject"`
 460	}
 461	var commits []commitOut
 462	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 463	base := mr.MergedBase
 464	if base == "" {
 465		if b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number)); err == nil {
 466			base = b
 467		}
 468	}
 469	if base != "" {
 470		if shas, err := gitutil.RevListRange(dir, base, mrHeadRef(mr.Number)); err == nil {
 471			for _, sha := range shas {
 472				subject := ""
 473				if raw, err := gitutil.ReadCommit(dir, sha); err == nil {
 474					if parsed, err := sig.ParseCommit(raw); err == nil {
 475						subject = parsed.Subject
 476					}
 477				}
 478				commits = append(commits, commitOut{sha, subject})
 479			}
 480		}
 481	}
 482	d := struct {
 483		mrOut
 484		Checks            []checkOut   `json:"checks,omitempty"`
 485		Combined          string       `json:"checks_combined,omitempty"`
 486		UnresolvedThreads int          `json:"unresolved_threads,omitempty"`
 487		Commits           []commitOut  `json:"commits,omitempty"`
 488		Comments          []commentOut `json:"comments,omitempty"`
 489		Reviews           []reviewOut  `json:"reviews,omitempty"`
 490	}{mrToOut(repo, mr, true), checks, store.CombinedStatus(statuses), unresolved, commits, cs, rs}
 491	return c.emit(d, func(w io.Writer) {
 492		fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA)
 493		if d.Body != "" {
 494			fmt.Fprintf(w, "\n%s\n", d.Body)
 495		}
 496		for _, cm := range commits {
 497			fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject)
 498		}
 499		for _, x := range checks {
 500			fmt.Fprintf(w, "check: %s %s\n", x.Context, x.State)
 501		}
 502		if d.UnresolvedThreads > 0 {
 503			fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads)
 504		}
 505		for _, r := range rs {
 506			stale := ""
 507			if r.Stale {
 508				stale = " (stale)"
 509			}
 510			fmt.Fprintf(w, "review: %s %s%s\n", r.Reviewer, r.Verdict, stale)
 511		}
 512		for _, cm := range cs {
 513			fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
 514		}
 515	})
 516}
 517
 518func runMRDiff(c *Ctx, args []string) int {
 519	repo, mr, code := mrRef(c, args, policy.CanRead)
 520	if code >= 0 {
 521		return code
 522	}
 523	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 524	head := mrHeadRef(mr.Number)
 525	// After a merge (especially fast-forward) the live merge-base equals
 526	// the head and the diff would vanish; use the recorded base instead.
 527	base := mr.MergedBase
 528	if base == "" {
 529		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
 530		if err != nil {
 531			return c.fail(protocol.ExitFailure, "%v", err)
 532		}
 533		base = b
 534	}
 535	patch, err := gitutil.Diff(dir, base, head, 4<<20)
 536	if err != nil {
 537		return c.fail(protocol.ExitFailure, "%v", err)
 538	}
 539	fmt.Fprint(c.Stdout, patch)
 540	return protocol.ExitOK
 541}
 542
 543func runMREdit(c *Ctx, args []string) int {
 544	rest, title, body, format, code := editText(c, args, "mr")
 545	if code >= 0 {
 546		return code
 547	}
 548	repo, mr, code := mrRef(c, rest, policy.CanRead)
 549	if code >= 0 {
 550		return code
 551	}
 552	if code := refuseArchived(c, repo); code >= 0 {
 553		return code
 554	}
 555	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
 556	if err != nil {
 557		return c.fail(protocol.ExitFailure, "%v", err)
 558	}
 559	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
 560		return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request")
 561	}
 562	if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
 563		return c.fail(protocol.ExitFailure, "%v", err)
 564	}
 565	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
 566		fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
 567	})
 568}
 569
 570// runMRRetarget moves an open merge request onto another branch of the
 571// same repository.
 572func runMRRetarget(c *Ctx, args []string) int {
 573	if len(args) != 3 {
 574		return c.fail(protocol.ExitUsage, "usage: mr retarget <owner/name> <n> <branch>")
 575	}
 576	repo, mr, code := mrRef(c, args[:2], policy.CanRead)
 577	if code >= 0 {
 578		return code
 579	}
 580	if code := refuseArchived(c, repo); code >= 0 {
 581		return code
 582	}
 583	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
 584	if err != nil {
 585		return c.fail(protocol.ExitFailure, "%v", err)
 586	}
 587	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
 588		return c.fail(protocol.ExitDenied, "only the author or users with write access can retarget this merge request")
 589	}
 590	if mr.State == "merged" || mr.State == "closed" {
 591		return c.fail(protocol.ExitUsage, "!%d is %s; only an open merge request can be retargeted", mr.Number, mr.State)
 592	}
 593	target := args[2]
 594	if target == mr.TargetRef {
 595		return c.fail(protocol.ExitUsage, "!%d already targets %s", mr.Number, target)
 596	}
 597	if mr.SourceRepoID == repo.ID && target == mr.SourceRef {
 598		return c.fail(protocol.ExitUsage, "%s is the source branch of !%d", target, mr.Number)
 599	}
 600	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 601	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+target); err != nil {
 602		return c.fail(protocol.ExitNotFound, "branch %s not found in %s", target, repo.Path())
 603	}
 604	// The diff, the commit list and the merge gates all derive their base
 605	// from the target on every read, so the only thing to check here is
 606	// that a base exists at all: without one there is nothing to show and
 607	// nothing to merge.
 608	base, err := gitutil.MergeBase(dir, "refs/heads/"+target, mrHeadRef(mr.Number))
 609	if err != nil || base == "" {
 610		return c.fail(protocol.ExitUsage, "%s shares no history with the head of !%d", target, mr.Number)
 611	}
 612	old := mr.TargetRef
 613	if err := c.Store.SetMRTarget(mr.ID, target); err != nil {
 614		return c.fail(protocol.ExitFailure, "%v", err)
 615	}
 616	c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
 617	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 618		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 619			notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), "",
 620				fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 621	}
 622	return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) {
 623		fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base)
 624	})
 625}
 626
 627func runMRComment(c *Ctx, args []string) int {
 628	var rest []string
 629	var message, file, format string
 630	for i := 0; i < len(args); i++ {
 631		switch args[i] {
 632		case "--message", "--file", "--format":
 633			if i+1 >= len(args) {
 634				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 635			}
 636			switch args[i] {
 637			case "--message":
 638				message = args[i+1]
 639			case "--file":
 640				file = args[i+1]
 641			case "--format":
 642				format = args[i+1]
 643			}
 644			i++
 645		default:
 646			rest = append(rest, args[i])
 647		}
 648	}
 649	fmtName, err := markupFormat(format)
 650	if err != nil {
 651		return c.fail(protocol.ExitUsage, "%v", err)
 652	}
 653	if fmtName == "" {
 654		fmtName = "md"
 655	}
 656	repo, mr, code := mrRef(c, rest, policy.CanRead)
 657	if code >= 0 {
 658		return code
 659	}
 660	if code := refuseArchived(c, repo); code >= 0 {
 661		return code
 662	}
 663	body, err := bodyFrom(c, message, file)
 664	if err != nil {
 665		return c.fail(protocol.ExitUsage, "%v", err)
 666	}
 667	if strings.TrimSpace(body) == "" {
 668		return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
 669	}
 670	if err := c.Store.AddMRComment(mr.ID, c.User.ID, body, fmtName); err != nil {
 671		return c.fail(protocol.ExitFailure, "%v", err)
 672	}
 673	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number))
 674	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 675		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 676			notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 677	}
 678	return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
 679		fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
 680	})
 681}
 682
 683func runMRReview(c *Ctx, args []string) int {
 684	verdict := ""
 685	var rest []string
 686	for _, a := range args {
 687		switch a {
 688		case "--approve":
 689			verdict = "approve"
 690		case "--request-changes":
 691			verdict = "request_changes"
 692		case "--comment":
 693			verdict = "comment"
 694		default:
 695			rest = append(rest, a)
 696		}
 697	}
 698	if verdict == "" {
 699		return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
 700	}
 701	repo, mr, code := mrRef(c, rest, policy.CanRead)
 702	if code >= 0 {
 703		return code
 704	}
 705	if code := refuseArchived(c, repo); code >= 0 {
 706		return code
 707	}
 708	if mr.State != "open" {
 709		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
 710	}
 711	if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
 712		return c.fail(protocol.ExitFailure, "%v", err)
 713	}
 714	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 715		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 716			notifyBody(c, fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
 717	}
 718	return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
 719		fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
 720	})
 721}
 722
 723func runMRMerge(c *Ctx, args []string) int {
 724	strategy := ""
 725	var rest []string
 726	for i := 0; i < len(args); i++ {
 727		if args[i] == "--strategy" {
 728			if i+1 >= len(args) {
 729				return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
 730			}
 731			strategy = args[i+1]
 732			i++
 733			continue
 734		}
 735		rest = append(rest, args[i])
 736	}
 737	valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
 738	if !valid[strategy] {
 739		return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
 740	}
 741	repo, mr, code := mrRef(c, rest, policy.CanWrite)
 742	if code >= 0 {
 743		return code
 744	}
 745	if code := refuseArchived(c, repo); code >= 0 {
 746		return code
 747	}
 748	if mr.State != "open" && mr.State != "source_gone" {
 749		return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
 750	}
 751
 752	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 753	targetRef := "refs/heads/" + mr.TargetRef
 754	targetSHA, err := gitutil.ResolveRef(dir, targetRef)
 755	if err != nil {
 756		return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
 757	}
 758	headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
 759	if err != nil {
 760		return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
 761	}
 762
 763	// Check gate: with require_checks, the MR head must carry statuses
 764	// and every one of them must be green.
 765	if repo.Settings.RequireChecks {
 766		statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA)
 767		if err != nil {
 768			return c.fail(protocol.ExitFailure, "%v", err)
 769		}
 770		switch store.CombinedStatus(statuses) {
 771		case "success":
 772		case "":
 773			return c.fail(protocol.ExitDenied,
 774				"%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)
 775		default:
 776			var bad []string
 777			for _, st := range statuses {
 778				if st.State != "success" {
 779					bad = append(bad, st.Context+"="+st.State)
 780				}
 781			}
 782			return c.fail(protocol.ExitDenied,
 783				"%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))
 784		}
 785	}
 786
 787	// Review gates: approvals, CODEOWNERS, resolved threads.
 788	if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
 789		return code
 790	}
 791
 792	upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
 793	if err != nil {
 794		return c.fail(protocol.ExitFailure, "%v", err)
 795	}
 796	if upToDate {
 797		return c.fail(protocol.ExitUsage, "target already contains the MR head")
 798	}
 799	ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
 800	if err != nil {
 801		return c.fail(protocol.ExitFailure, "%v", err)
 802	}
 803
 804	// Signature policy matrix: with require_signed_commits, only
 805	// fast-forward is allowed — squash, rebase-replay, and merge commits
 806	// are all server-created and unsigned, violating the branch's own
 807	// policy — and every landed commit must be verified. An explicit
 808	// rebase when fast-forward is already possible IS a fast-forward
 809	// (nothing is rewritten), so it stays legal.
 810	if repo.Settings.RequireSignedCommits {
 811		if strategy == "merge" || strategy == "squash" || !ffPossible {
 812			return c.fail(protocol.ExitDenied,
 813				"%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
 814				repo.Path(), mr.SourceRef, mr.TargetRef)
 815		}
 816		strategy = "ff"
 817		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
 818		if err != nil {
 819			return c.fail(protocol.ExitFailure, "%v", err)
 820		}
 821		for _, sha := range commits {
 822			raw, err := gitutil.ReadCommit(dir, sha)
 823			if err != nil {
 824				return c.fail(protocol.ExitFailure, "%v", err)
 825			}
 826			parsed, err := sigParse(raw)
 827			if err != nil {
 828				return c.fail(protocol.ExitFailure, "%v", err)
 829			}
 830			res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
 831			if err != nil {
 832				return c.fail(protocol.ExitFailure, "%v", err)
 833			}
 834			if res.State != "verified" {
 835				return c.fail(protocol.ExitDenied,
 836					"%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
 837			}
 838		}
 839	}
 840	if strategy == "" {
 841		if ffPossible {
 842			strategy = "ff"
 843		} else {
 844			strategy = "merge"
 845		}
 846	}
 847	if strategy == "rebase" && ffPossible {
 848		// Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
 849		// and taking it keeps the original commits and their signatures.
 850		strategy = "ff"
 851	}
 852
 853	// Every server-created commit needs the merger's verified identity.
 854	mergerEmail := ""
 855	if strategy != "ff" {
 856		email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
 857		if err != nil {
 858			return c.fail(protocol.ExitFailure, "%v", err)
 859		}
 860		if email == "" {
 861			return c.fail(protocol.ExitDenied,
 862				"%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
 863		}
 864		mergerEmail = email
 865	}
 866
 867	var newSHA string
 868	switch strategy {
 869	case "ff":
 870		if !ffPossible {
 871			return c.fail(protocol.ExitUsage,
 872				"fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
 873		}
 874		newSHA = headSHA
 875
 876	case "merge":
 877		tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
 878		if err != nil {
 879			return c.fail(protocol.ExitFailure, "%v", err)
 880		}
 881		if conflict {
 882			return c.fail(protocol.ExitUsage,
 883				"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
 884		}
 885		msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
 886		newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
 887		if err != nil {
 888			return c.fail(protocol.ExitFailure, "%v", err)
 889		}
 890
 891	case "squash":
 892		// One new commit with the merged tree. Authorship credit goes to
 893		// the MR author (their verified identity when they have one); the
 894		// committer is the merger.
 895		tree := ""
 896		if ffPossible {
 897			t, err := gitutil.ResolveTree(dir, headSHA)
 898			if err != nil {
 899				return c.fail(protocol.ExitFailure, "%v", err)
 900			}
 901			tree = t
 902		} else {
 903			t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
 904			if err != nil {
 905				return c.fail(protocol.ExitFailure, "%v", err)
 906			}
 907			if conflict {
 908				return c.fail(protocol.ExitUsage,
 909					"merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
 910			}
 911			tree = t
 912		}
 913		authorName, authorEmail := c.User.Username, mergerEmail
 914		if author, err := c.Store.UserByUsername(mr.Author); err == nil {
 915			if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
 916				authorName, authorEmail = author.Username, ae
 917			}
 918		}
 919		msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
 920		if mr.Body != "" {
 921			msg += "\n\n" + mr.Body
 922		}
 923		var err error
 924		newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
 925			authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
 926		if err != nil {
 927			return c.fail(protocol.ExitFailure, "%v", err)
 928		}
 929
 930	case "rebase":
 931		commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
 932		if err != nil {
 933			return c.fail(protocol.ExitFailure, "%v", err)
 934		}
 935		// Oldest first.
 936		for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
 937			commits[i], commits[j] = commits[j], commits[i]
 938		}
 939		onto := targetSHA
 940		for _, sha := range commits {
 941			parents, err := gitutil.CommitParents(dir, sha)
 942			if err != nil {
 943				return c.fail(protocol.ExitFailure, "%v", err)
 944			}
 945			if len(parents) > 1 {
 946				return c.fail(protocol.ExitUsage,
 947					"the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
 948			}
 949			base := onto // root commit: replay against the new tip itself
 950			if len(parents) == 1 {
 951				base = parents[0]
 952			}
 953			tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
 954			if err != nil {
 955				return c.fail(protocol.ExitFailure, "%v", err)
 956			}
 957			if conflict {
 958				return c.fail(protocol.ExitUsage,
 959					"commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
 960			}
 961			aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
 962			if err != nil {
 963				return c.fail(protocol.ExitFailure, "%v", err)
 964			}
 965			msg, err := gitutil.CommitMessage(dir, sha)
 966			if err != nil {
 967				return c.fail(protocol.ExitFailure, "%v", err)
 968			}
 969			onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
 970				aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
 971			if err != nil {
 972				return c.fail(protocol.ExitFailure, "%v", err)
 973			}
 974		}
 975		newSHA = onto
 976	}
 977
 978	// CAS so a concurrent push between our read and this write fails the
 979	// merge instead of silently discarding the push.
 980	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
 981		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
 982	}
 983	if err := c.Store.MarkMerged(mr.ID, targetSHA); err != nil {
 984		return c.fail(protocol.ExitFailure, "%v", err)
 985	}
 986	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
 987	// Merges bypass receive-pack, so the commit-message issue actions
 988	// (closes #N, references) run here for the newly landed commits. The
 989	// description is scanned after them, so a commit wins the attribution
 990	// when both name the same issue.
 991	if mr.TargetRef == repo.DefaultBranch {
 992		ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
 993		ProcessMRDescription(c.Store, repo, mr, c.User.ID)
 994		RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
 995	}
 996	c.Store.MarkMirrorsDirty(repo.ID, "push")
 997	if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
 998		notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
 999			notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
1000	}
1001	return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
1002		fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
1003	})
1004}
1005
1006// reviewGates enforces require_approvals (fresh, non-author, latest review
1007// per reviewer; a fresh request-changes blocks), CODEOWNERS coverage, and
1008// require_resolved. Returns -1 to proceed.
1009func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
1010	set := repo.Settings
1011	if set.RequireApprovals == 0 && !set.RequireResolved {
1012		return -1
1013	}
1014
1015	if set.RequireApprovals > 0 {
1016		reviews, err := c.Store.ListMRReviews(mr.ID)
1017		if err != nil {
1018			return c.fail(protocol.ExitFailure, "%v", err)
1019		}
1020		// Latest fresh review per reviewer decides their stance.
1021		latest := map[string]string{}
1022		for _, r := range reviews {
1023			if r.Stale || r.Reviewer == mr.Author {
1024				continue
1025			}
1026			latest[r.Reviewer] = r.Verdict
1027		}
1028		var approvers []string
1029		var blockers []string
1030		for who, verdict := range latest {
1031			switch verdict {
1032			case "approve":
1033				approvers = append(approvers, who)
1034			case "request_changes":
1035				blockers = append(blockers, who)
1036			}
1037		}
1038		if len(blockers) > 0 {
1039			slices.Sort(blockers)
1040			return c.fail(protocol.ExitDenied,
1041				"%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number)
1042		}
1043		if len(approvers) < set.RequireApprovals {
1044			return c.fail(protocol.ExitDenied,
1045				"%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers))
1046		}
1047
1048		// CODEOWNERS: every owned changed file needs an approval from one
1049		// of its owners.
1050		content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20)
1051		if err != nil {
1052			content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
1053		}
1054		if err == nil && len(content) > 0 {
1055			rules := policy.ParseCodeowners(string(content))
1056			base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
1057			if err != nil {
1058				return c.fail(protocol.ExitFailure, "%v", err)
1059			}
1060			files, err := gitutil.DiffFiles(dir, base, headSHA)
1061			if err != nil {
1062				return c.fail(protocol.ExitFailure, "%v", err)
1063			}
1064			approved := map[string]bool{}
1065			for _, a := range approvers {
1066				approved[a] = true
1067			}
1068			missing := map[string][]string{} // owner-set key -> example paths
1069			for _, f := range files {
1070				owners := policy.OwnersFor(rules, f)
1071				if owners == nil {
1072					continue
1073				}
1074				ok := false
1075				for _, o := range owners {
1076					if approved[o] {
1077						ok = true
1078						break
1079					}
1080				}
1081				if !ok {
1082					key := strings.Join(owners, ",")
1083					if len(missing[key]) < 3 {
1084						missing[key] = append(missing[key], f)
1085					}
1086				}
1087			}
1088			if len(missing) > 0 {
1089				var parts []string
1090				for owners, paths := range missing {
1091					parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners))
1092				}
1093				slices.Sort(parts)
1094				return c.fail(protocol.ExitDenied,
1095					"CODEOWNERS approval missing for: %s", strings.Join(parts, "; "))
1096			}
1097		}
1098	}
1099
1100	if set.RequireResolved {
1101		n, err := c.Store.UnresolvedThreadCount(mr.ID)
1102		if err != nil {
1103			return c.fail(protocol.ExitFailure, "%v", err)
1104		}
1105		if n > 0 {
1106			return c.fail(protocol.ExitDenied,
1107				"%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number)
1108		}
1109	}
1110	return -1
1111}
1112
1113func runMRClose(c *Ctx, args []string) int {
1114	repo, mr, code := mrRef(c, args, policy.CanRead)
1115	if code >= 0 {
1116		return code
1117	}
1118	if code := refuseArchived(c, repo); code >= 0 {
1119		return code
1120	}
1121	if len(args) != 2 {
1122		return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
1123	}
1124	grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1125	if err != nil {
1126		return c.fail(protocol.ExitFailure, "%v", err)
1127	}
1128	if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
1129		return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
1130	}
1131	if mr.State == "merged" || mr.State == "closed" {
1132		return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
1133	}
1134	if err := c.Store.SetMRState(mr.ID, "closed"); err != nil {
1135		return c.fail(protocol.ExitFailure, "%v", err)
1136	}
1137	return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
1138		fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
1139	})
1140}