internal/control/mr.go

d4aaf96d88e92486a458b150375b2386ac72fe34
gitbay/internal/control/mr.go history · blame · raw

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