package control import ( "errors" "fmt" "io" "slices" "strconv" "strings" "time" "gitbay.org/gitbay/internal/gitutil" "gitbay.org/gitbay/internal/policy" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/sig" "gitbay.org/gitbay/internal/store" ) func init() { register(Command{Path: []string{"repo", "fork"}, Summary: "fork a repository under your account", Usage: "repo fork [--name ]", Run: runRepoFork}) register(Command{Path: []string{"repo", "settings", "require-approvals"}, Summary: "require N fresh approvals to merge", Usage: "repo settings require-approvals (0 = off)", Run: runRequireApprovals}) register(Command{Path: []string{"repo", "settings", "require-resolved"}, Summary: "require all review threads resolved to merge", Usage: "repo settings require-resolved on|off", Run: runRequireResolved}) register(Command{Path: []string{"repo", "settings", "require-codeowners"}, Summary: "require an owner's approval for every file CODEOWNERS covers", Usage: "repo settings require-codeowners on|off", Run: runRequireCodeowners}) register(Command{Path: []string{"repo", "settings", "require-checks"}, Summary: "gate merges on green statuses", Usage: "repo settings require-checks on|off", Run: runRequireChecks}) register(Command{Path: []string{"repo", "settings", "require-signed"}, Summary: "require verified commit signatures", Usage: "repo settings require-signed on|off", Run: runRequireSigned}) register(Command{Path: []string{"mr", "create"}, Summary: "open a merge request", Usage: "mr create --source [owner/name:] --target --title [--body | --file -] [--format md|org] [--draft]", ReadsStdin: true, Run: runMRCreate}) register(Command{Path: []string{"mr", "range-diff"}, Summary: "what changed between two revisions of a merge request", Usage: "mr range-diff [--from ] [--to ]", ReadOnly: true, Run: runMRRangeDiff}) register(Command{Path: []string{"mr", "revisions"}, Summary: "the heads a merge request has had", Usage: "mr revisions ", ReadOnly: true, Run: runMRRevisions}) register(Command{Path: []string{"mr", "draft"}, Summary: "mark a merge request as work in progress", Usage: "mr draft ", Run: runMRDraft}) register(Command{Path: []string{"mr", "ready"}, Summary: "take the draft mark off, so it can merge", Usage: "mr ready ", Run: runMRReady}) register(Command{Path: []string{"mr", "list"}, Summary: "list merge requests", Usage: "mr list [--state open|merged|closed|source_gone|all] [--author ] [--milestone |none] [--search <text>] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList}) register(Command{Path: []string{"mr", "show"}, Summary: "show a merge request", Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) register(Command{Path: []string{"mr", "diff"}, Summary: "show the diff", Usage: "mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) register(Command{Path: []string{"mr", "edit"}, Summary: "edit title or body", Usage: "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]", ReadsStdin: true, Run: runMREdit}) register(Command{Path: []string{"mr", "retarget"}, Summary: "retarget onto another branch", Usage: "mr retarget <owner/name> <n> <branch>", Run: runMRRetarget}) register(Command{Path: []string{"mr", "comment"}, Summary: "comment", Usage: "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]", ReadsStdin: true, Run: runMRComment}) register(Command{Path: []string{"mr", "review"}, Summary: "review", Usage: "mr review <owner/name> <n> --approve|--request-changes|--comment|--discard", Run: runMRReview}) register(Command{Path: []string{"mr", "merge"}, Summary: "merge", Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge}) register(Command{Path: []string{"mr", "close"}, Summary: "close without merging", Usage: "mr close <owner/name> <n>", Run: runMRClose}) } func runRepoFork(c *Ctx, args []string) int { f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } path, name := f.pos(0), f.Value("--name") if path == "" { return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]") } src, code := resolveRepo(c, path, policy.CanRead) if code >= 0 { return code } if name == "" { name = src.Name } if err := policy.ValidateName(name); err != nil { return c.failErr(err) } repoCreateMu.Lock() if code := checkRepoQuota(c); code >= 0 { repoCreateMu.Unlock() return code } id, err := c.Store.CreateFork("user", c.User.ID, name, src.Visibility, src.ID) repoCreateMu.Unlock() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name) srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name) if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil { c.Store.DeleteRepo(id) return c.fail(protocol.ExitFailure, "%v", err) } if desc := gitutil.ReadDescription(srcDir); desc != "" { gitutil.WriteDescription(dstDir, desc) } if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil { // Empty source repos have nothing to fetch; that is fine. if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil { c.Store.DeleteRepo(id) return c.fail(protocol.ExitFailure, "copying refs: %v", err) } } forkPath := c.User.Username + "/" + name return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) { fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath) }) } func runRequireApprovals(c *Ctx, args []string) int { if len(args) != 2 { return c.fail(protocol.ExitUsage, "usage: repo settings require-approvals <owner/name> <n>") } n, err := strconv.Atoi(args[1]) if err != nil || n < 0 || n > 20 { return c.fail(protocol.ExitUsage, "approvals must be 0..20") } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireApprovals = n }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "require_approvals %d on %s\n", n, repo.Path()) }) } func runRequireResolved(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "on" && args[1] != "off") { return c.fail(protocol.ExitUsage, "usage: repo settings require-resolved <owner/name> on|off") } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireResolved = args[1] == "on" }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "require_resolved %s on %s\n", args[1], repo.Path()) }) } func runRequireCodeowners(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "on" && args[1] != "off") { return c.fail(protocol.ExitUsage, "usage: repo settings require-codeowners <owner/name> on|off") } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireCodeowners = args[1] == "on" }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "require_codeowners %s on %s\n", args[1], repo.Path()) }) } func runRequireChecks(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "on" && args[1] != "off") { return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off") } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireChecks = args[1] == "on" }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "require_checks %s on %s\n", args[1], repo.Path()) }) } func runRequireSigned(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "on" && args[1] != "off") { return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off") } repo, code := resolveRepo(c, args[0], policy.CanAdmin) if code >= 0 { return code } s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequireSignedCommits = args[1] == "on" }) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path()) }) } // mrRef parses "<owner/name> <n>" and loads the MR. func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) { repo, n, code := refArgs(c, args, perm, "MR") if code >= 0 { return repo, store.MR{}, code } mr, err := c.Store.MRByNumber(repo.ID, n) if errors.Is(err, store.ErrNotFound) { return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path()) } if err != nil { return repo, mr, c.fail(protocol.ExitFailure, "%v", err) } return repo, mr, -1 } func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) } func runMRCreate(c *Ctx, args []string) int { f, err := parseFlags(args, flagSpec{Values: []string{"--source", "--target", "--title", "--body", "--file", "--format"}, Bools: []string{"--draft"}, MaxPos: 1, Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } path, source, target := f.pos(0), f.Value("--source"), f.Value("--target") title, body, file, format := f.Value("--title"), f.Value("--body"), f.Value("--file"), f.Value("--format") if path == "" || source == "" || title == "" { return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--draft]") } fmtName, err := markupFormat(format) if err != nil { return c.failErr(err) } if fmtName == "" { fmtName = "md" } repo, code := resolveRepo(c, path, policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if target == "" { target = repo.DefaultBranch } // Source is "branch" (same repo) or "owner/name:branch" (a fork). srcRepo := repo srcBranch := source if sp, br, ok := strings.Cut(source, ":"); ok { srcBranch = br var scode int srcRepo, scode = resolveRepo(c, sp, policy.CanRead) if scode >= 0 { return scode } if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID { return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path()) } } srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name) headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch) if err != nil { return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path()) } b, err := bodyFrom(c, body, file) if err != nil { return c.failErr(err) } n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName, f.Has("--draft")) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // Fetch the head into the target so the target owns the objects. dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil { return c.fail(protocol.ExitFailure, "recording MR head: %v", err) } if srcRepo.ID != repo.ID { QueueMRBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL, repo, c.User.ID, n, headSHA) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n)) if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { notify(c, targets, notice{repo: repo, kind: "mr", subject: mrSubject(repo, n, title), action: fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), excerpt: b, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), n)}) } out := MRCreated{Number: n, HeadSHA: headSHA} if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok { out.StackedOn = &stackRef{p.Number, p.Title} } return c.emit(out, func(w io.Writer) { fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target) if out.StackedOn != nil { fmt.Fprintf(w, "stacked on !%d %s\n", out.StackedOn.Number, out.StackedOn.Title) } }) } type mrOut struct { Number int64 `json:"number"` Title string `json:"title"` State string `json:"state"` // Draft is an open merge request not asking to be merged yet. Draft bool `json:"draft,omitempty"` Author string `json:"author"` Source string `json:"source"` // owner/name:branch, or branch, "" if gone TargetRef string `json:"target_ref"` HeadSHA string `json:"head_sha"` Body string `json:"body,omitempty"` BodyFormat string `json:"body_format,omitempty"` Milestone string `json:"milestone,omitempty"` // StackedOn is the open merge request whose source branch this one // targets; Stacked are the open ones targeting this one's source. StackedOn *stackRef `json:"stacked_on,omitempty"` Stacked []stackRef `json:"stacked,omitempty"` CreatedAt string `json:"created_at"` MergedAt string `json:"merged_at,omitempty"` MergedBy string `json:"merged_by,omitempty"` ClosedAt string `json:"closed_at,omitempty"` ClosedBy string `json:"closed_by,omitempty"` } type stackRef struct { Number int64 `json:"number"` Title string `json:"title"` } // stackOf derives the stack around m: the open merge request whose source // branch m targets, and the open ones targeting m's source. Both only // within m's repository; a fork's branch is not a target anything can // stack on. func stackOf(c *Ctx, repo store.Repo, m store.MR) (*stackRef, []stackRef) { if m.State != "open" { return nil, nil } var parent *stackRef if p, ok, err := c.Store.OpenMRBySource(repo.ID, m.TargetRef); err == nil && ok && p.ID != m.ID { parent = &stackRef{p.Number, p.Title} } var children []stackRef if m.SourceRepoID == repo.ID { if kids, err := c.Store.OpenMRsByTarget(repo.ID, m.SourceRef); err == nil { for _, k := range kids { if k.ID != m.ID { children = append(children, stackRef{k.Number, k.Title}) } } } } return parent, children } func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { src := "" if m.SourcePath != "" { if m.SourceRepoID == repo.ID { src = m.SourceRef } else { src = m.SourcePath + ":" + m.SourceRef } } o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Draft: m.Draft, Author: m.Author, Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy, ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy} if withBody { o.Body = m.Body o.BodyFormat = m.BodyFormat } return o } func runMRList(c *Ctx, args []string) int { args, p, code := parsePageFlags(c, args, "mr", true) if code >= 0 { return code } const usage = "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--author <user>] [--milestone <title>|none] [--search <text>] [--limit <n>] [--cursor <c>]" f := store.MRFilter{State: "open"} fl, err := parseFlags(args, flagSpec{Values: []string{"--state", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } path := fl.pos(0) if fl.Has("--state") { f.State = fl.Value("--state") } f.Author, f.Milestone = fl.Value("--author"), fl.Value("--milestone") f.Search = fl.Value("--search") if fl.Has("--search") { if err := validQuery(f.Search); err != nil { return c.failErr(err) } } valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} if path == "" || !valid[f.State] { return c.fail(protocol.ExitUsage, usage) } repo, code := resolveRepo(c, path, policy.CanRead) if code >= 0 { return code } f.Limit, f.Before = p.queryLimit(), p.keyInt() mrs, err := c.Store.QueryMRs(repo.ID, f) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } mrs, next := trimPage(p, mrs, "mr", func(m store.MR) string { return strconv.FormatInt(m.Number, 10) }) var ds []mrOut for _, m := range mrs { o := mrToOut(repo, m, false) o.StackedOn, _ = stackOf(c, repo, m) ds = append(ds, o) } return c.emitPage(p, ds, next, func(w io.Writer) { for _, d := range ds { stacked := "" if d.StackedOn != nil { stacked = fmt.Sprintf("\tstacked on !%d", d.StackedOn.Number) } state := d.State if d.Draft { state = "draft" } fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s%s\n", d.Number, state, d.Title, d.Source, d.TargetRef, stacked) } }) } // byWhom renders " by <user>", or nothing when the actor is unknown — an // imported merge request carries a time but no local account. func byWhom(user string) string { if user == "" { return "" } return " by " + user } func runMRShow(c *Ctx, args []string) int { repo, mr, code := mrRef(c, args, policy.CanRead) if code >= 0 { return code } if len(args) != 2 { return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>") } comments, err := c.Store.ListMRComments(mr.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } reviews, err := c.Store.ListMRReviews(mr.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } statuses, combined, err := c.Store.ChecksForCommit(repo.ID, mr.HeadSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } unresolved, err := c.Store.UnresolvedThreadCount(mr.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } var checks []CheckOut for _, st := range statuses { out := CheckOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt} if st.Duration > 0 { out.Duration = st.Duration.String() } checks = append(checks, out) } var cs []commentOut for _, cm := range comments { cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt}) } var rs []ReviewOut counts := ReviewersWhoCount(c.Store, repo, reviews) for _, r := range reviews { rs = append(rs, ReviewOut{r.Reviewer, r.Verdict, r.Stale, counts[r.Reviewer], r.CreatedAt}) } // The commits this MR carries: base..head, the diff's range. var commits []CommitOut dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) base := mr.MergedBase if base == "" { if b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number)); err == nil { base = b } } if base != "" { if shas, err := gitutil.RevListRange(dir, base, mrHeadRef(mr.Number)); err == nil { for _, sha := range shas { subject := "" if raw, err := gitutil.ReadCommit(dir, sha); err == nil { if parsed, err := sig.ParseCommit(raw); err == nil { subject = parsed.Subject } } commits = append(commits, CommitOut{sha, subject}) } } } d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined, UnresolvedThreads: unresolved, Commits: commits, Comments: cs, Reviews: rs} d.StackedOn, d.Stacked = stackOf(c, repo, mr) return c.emit(d, func(w io.Writer) { state := d.State if d.Draft { state = "draft" } fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, state, d.Author, d.Source, d.TargetRef, d.HeadSHA) if d.StackedOn != nil { fmt.Fprintf(w, "stacked on !%d %s\n", d.StackedOn.Number, d.StackedOn.Title) } for _, k := range d.Stacked { fmt.Fprintf(w, "stacked: !%d %s\n", k.Number, k.Title) } if d.MergedAt != "" { fmt.Fprintf(w, "merged %s%s\n", d.MergedAt, byWhom(d.MergedBy)) } if d.ClosedAt != "" { fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy)) } if d.Body != "" { fmt.Fprintf(w, "\n%s\n", d.Body) } for _, cm := range commits { fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject) } for _, x := range checks { dur := "" if x.Duration != "" { dur = " in " + x.Duration } fmt.Fprintf(w, "check: %s %s at %s%s\n", x.Context, x.State, x.UpdatedAt, dur) } if d.UnresolvedThreads > 0 { fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads) } for _, r := range rs { stale := "" if r.Stale { stale = " (stale)" } advisory := "" if !r.Counts { advisory = " (advisory: no write access)" } fmt.Fprintf(w, "review: %s %s%s%s at %s\n", r.Reviewer, r.Verdict, stale, advisory, r.CreatedAt) } for _, cm := range cs { fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body) } }) } func runMRDiff(c *Ctx, args []string) int { repo, mr, code := mrRef(c, args, policy.CanRead) if code >= 0 { return code } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) head := mrHeadRef(mr.Number) // After a merge (especially fast-forward) the live merge-base equals // the head and the diff would vanish; use the recorded base instead. base := mr.MergedBase if base == "" { b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } base = b } patch, truncated, err := gitutil.Diff(dir, base, head, 4<<20) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } fmt.Fprint(c.Stdout, patch) if truncated { fmt.Fprintln(c.Stderr, "diff truncated at 4 MiB; fetch the branch for the rest") } return protocol.ExitOK } func runMREdit(c *Ctx, args []string) int { rest, title, body, format, code := editText(c, args, "mr") if code >= 0 { return code } repo, mr, code := mrRef(c, rest, policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if code := authorOrWrite(c, repo, mr.Author, "edit this merge request"); code >= 0 { return code } if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.edited", fmt.Sprintf(`{"number":%d}`, mr.Number)) return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number) }) } // runMRRetarget moves an open merge request onto another branch of the // same repository. func runMRRetarget(c *Ctx, args []string) int { if len(args) != 3 { return c.fail(protocol.ExitUsage, "usage: mr retarget <owner/name> <n> <branch>") } repo, mr, code := mrRef(c, args[:2], policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if code := authorOrWrite(c, repo, mr.Author, "retarget this merge request"); code >= 0 { return code } if mr.State == "merged" || mr.State == "closed" { return c.fail(protocol.ExitUsage, "!%d is %s; only an open merge request can be retargeted", mr.Number, mr.State) } target := args[2] if target == mr.TargetRef { return c.fail(protocol.ExitUsage, "!%d already targets %s", mr.Number, target) } if mr.SourceRepoID == repo.ID && target == mr.SourceRef { return c.fail(protocol.ExitUsage, "%s is the source branch of !%d", target, mr.Number) } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) if _, err := gitutil.ResolveRef(dir, "refs/heads/"+target); err != nil { return c.fail(protocol.ExitNotFound, "branch %s not found in %s", target, repo.Path()) } // The diff, the commit list and the merge gates all derive their base // from the target on every read, so the only thing to check here is // that a base exists at all: without one there is nothing to show and // nothing to merge. base, err := gitutil.MergeBase(dir, "refs/heads/"+target, mrHeadRef(mr.Number)) if err != nil || base == "" { return c.fail(protocol.ExitUsage, "%s shares no history with the head of !%d", target, mr.Number) } old := mr.TargetRef if err := c.Store.SetMRTarget(mr.ID, target); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target)) c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted", fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target)) if parts, err := c.Store.MRParticipants(mr.ID); err == nil { notify(c, parts, notice{repo: repo, kind: "mr", subject: mrSubject(repo, mr.Number, mr.Title), action: fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) } return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) { fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base) }) } func runMRComment(c *Ctx, args []string) int { return runComment(c, args, mrThread, "mr", func(rest []string) (store.Repo, int64, int64, string, int) { repo, mr, code := mrRef(c, rest, policy.CanRead) return repo, mr.ID, mr.Number, mr.Title, code }, c.Store.AddMRComment, c.Store.MRParticipants) } func runMRReview(c *Ctx, args []string) int { verdict, discard := "", false var rest []string for _, a := range args { switch a { case "--approve": verdict = "approve" case "--request-changes": verdict = "request_changes" case "--comment": verdict = "comment" case "--discard": discard = true default: rest = append(rest, a) } } const usage = "mr review <owner/name> <n> --approve|--request-changes|--comment|--discard" if discard && verdict != "" { return c.fail(protocol.ExitUsage, "--discard throws the batch away; it takes no verdict") } if verdict == "" && !discard { return c.fail(protocol.ExitUsage, "usage: %s", usage) } repo, mr, code := mrRef(c, rest, policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if mr.State != "open" { return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) } // Throwing the batch away is not a review, so it stops here: no // verdict, no event, nobody told about comments nobody ever saw. if discard { n, err := c.Store.DiscardPendingComments(mr.ID, c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(map[string]any{"number": mr.Number, "discarded": n}, func(w io.Writer) { fmt.Fprintf(w, "discarded %d pending comment(s) on %s!%d\n", n, repo.Path(), mr.Number) }) } if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // The batch the reviewer composed becomes visible with the verdict, // which is what makes it one review rather than a trickle. published, err := c.Store.PublishPendingComments(mr.ID, c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed", fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) if parts, err := c.Store.MRParticipants(mr.ID); err == nil { notify(c, parts, notice{repo: repo, kind: "mr", subject: mrSubject(repo, mr.Number, mr.Title), action: reviewAction(mr.Number, verdict, published), path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) } return c.emit(map[string]any{"number": mr.Number, "verdict": verdict, "published": published}, func(w io.Writer) { fmt.Fprintf(w, "reviewed %s!%d: %s", repo.Path(), mr.Number, verdict) if published > 0 { fmt.Fprintf(w, " (%d comment(s))", published) } fmt.Fprintln(w) }) } func runMRMerge(c *Ctx, args []string) int { f, err := parseFlags(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } strategy, rest := f.Value("--strategy"), f.Pos valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true} if !valid[strategy] { return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase") } repo, mr, code := mrRef(c, rest, policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if mr.State != "open" && mr.State != "source_gone" { return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) targetRef := "refs/heads/" + mr.TargetRef targetSHA, err := gitutil.ResolveRef(dir, targetRef) if err != nil { return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err) } headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number)) if err != nil { return c.fail(protocol.ExitFailure, "MR head ref: %v", err) } // Check gate: with require_checks, the MR head must carry statuses // and every one of them must be green. if repo.Settings.RequireChecks { statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } switch store.CombinedStatus(statuses) { case "success": case "": return c.fail(protocol.ExitDenied, "%s requires green checks and none were reported on %.10s", repo.Path(), headSHA) default: var bad []string for _, st := range statuses { if st.State != "success" { bad = append(bad, st.Context+"="+st.State) } } return c.fail(protocol.ExitDenied, "%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", ")) } } // Review gates: approvals, CODEOWNERS, resolved threads. if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 { return code } upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if upToDate { // The head is already on the target: merged by hand and pushed, or // a merge whose ref update landed and whose record did not. Record // it rather than refuse, so a merge request cannot be stuck open // with no way to close it as merged (#108). if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d}`, mr.Number)) return c.emit(map[string]any{"number": mr.Number, "strategy": "recorded", "sha": headSHA}, func(w io.Writer) { fmt.Fprintf(w, "%s already contains !%d; recorded as merged at %.10s\n", mr.TargetRef, mr.Number, headSHA) }) } ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // Signature policy matrix: with require_signed_commits, only // fast-forward is allowed — squash, rebase-replay, and merge commits // are all server-created and unsigned, violating the branch's own // policy — and every landed commit must be verified. An explicit // rebase when fast-forward is already possible IS a fast-forward // (nothing is rewritten), so it stays legal. if repo.Settings.RequireSignedCommits { if strategy == "merge" || strategy == "squash" || !ffPossible { return c.fail(protocol.ExitDenied, "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again", repo.Path(), mr.SourceRef, mr.TargetRef) } strategy = "ff" commits, err := gitutil.RevListRange(dir, targetSHA, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } for _, sha := range commits { raw, err := gitutil.ReadCommit(dir, sha) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } parsed, err := sigParse(raw) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } res, err := VerifyCommitCached(c.Store, repo, parsed, sha) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if res.State != "verified" { return c.fail(protocol.ExitDenied, "%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State) } } } if strategy == "" { if ffPossible { strategy = "ff" } else { strategy = "merge" } } if strategy == "rebase" && ffPossible { // Nothing to rewrite: a rebase onto an ancestor is a fast-forward, // and taking it keeps the original commits and their signatures. strategy = "ff" } // Every server-created commit needs the merger's verified identity. mergerEmail := "" if strategy != "ff" { email, err := c.Store.PrimaryVerifiedEmail(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if email == "" { return c.fail(protocol.ExitDenied, "%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy) } mergerEmail = email } var newSHA string switch strategy { case "ff": if !ffPossible { return c.fail(protocol.ExitUsage, "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef) } newSHA = headSHA case "merge": tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if conflict { return c.fail(protocol.ExitUsage, "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef) } msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef) newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } case "squash": // One new commit with the merged tree. Authorship credit goes to // the MR author (their verified identity when they have one); the // committer is the merger. tree := "" if ffPossible { t, err := gitutil.ResolveTree(dir, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } tree = t } else { t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if conflict { return c.fail(protocol.ExitUsage, "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef) } tree = t } authorName, authorEmail := c.User.Username, mergerEmail if author, err := c.Store.UserByUsername(mr.Author); err == nil { if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" { authorName, authorEmail = author.Username, ae } } msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number) if mr.Body != "" { msg += "\n\n" + mr.Body } var err error newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA}, authorName, authorEmail, "", c.User.Username, mergerEmail, msg) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } case "rebase": commits, err := gitutil.RevListRange(dir, targetSHA, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // Oldest first. for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 { commits[i], commits[j] = commits[j], commits[i] } onto := targetSHA for _, sha := range commits { parents, err := gitutil.CommitParents(dir, sha) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if len(parents) > 1 { return c.fail(protocol.ExitUsage, "the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha) } base := onto // root commit: replay against the new tip itself if len(parents) == 1 { base = parents[0] } tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if conflict { return c.fail(protocol.ExitUsage, "commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef) } aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } msg, err := gitutil.CommitMessage(dir, sha) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto}, aName, aEmail, aDate, c.User.Username, mergerEmail, msg) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } } newSHA = onto } // A stacked merge request's diff is against this branch. After a // fast-forward or merge commit the same commits are on the target and // its diff is unchanged there; after a squash or rebase they are not, // and the stack would carry this merge request's changes a second // time. Refuse rather than leave the stack wrong. var stack []store.MR if mr.SourceRepoID == repo.ID { stack, _ = c.Store.OpenMRsByTarget(repo.ID, mr.SourceRef) } if len(stack) > 0 && (strategy == "squash" || strategy == "rebase") { var nums []string for _, k := range stack { nums = append(nums, fmt.Sprintf("!%d", k.Number)) } return c.fail(protocol.ExitUsage, "%s is stacked on by %s; a %s merge rewrites the commits they build on. Merge with --strategy ff or merge, or merge the stack into %s first", fmt.Sprintf("!%d", mr.Number), strings.Join(nums, ", "), strategy, mr.SourceRef) } // CAS so a concurrent push between our read and this write fails the // merge instead of silently discarding the push. if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil { return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err) } if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) // The stack moves up: whatever targeted this branch now targets what // it merged into, reviews intact, since that diff is the one they // were of. for _, k := range stack { if err := c.Store.RetargetKeepingReviews(k.ID, mr.TargetRef); err != nil { continue } c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number)) if parts, err := c.Store.MRParticipants(k.ID); err == nil { notify(c, parts, notice{repo: repo, kind: "mr", subject: mrSubject(repo, k.Number, k.Title), action: fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number), path: fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)}) } } // Merges bypass receive-pack, so the commit-message issue actions // (closes #N, references) run here for the newly landed commits. The // description is scanned after them, so a commit wins the attribution // when both name the same issue. if mr.TargetRef == repo.DefaultBranch { ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA) ProcessMRDescription(c.Store, repo, mr, c.User.ID) RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA) } // A merge moves the ref directly, so it never reaches post-receive and // none of the ref-update work fires on its own. The event webhooks // subscribe to, and the branch's CI jobs, happen here instead. c.Store.RecordEvent(repo.ID, c.User.ID, "push", fmt.Sprintf( `{"ref":%q,"old":%q,"new":%q,"forced":false,"deleted":false}`, targetRef, targetSHA, newSHA)) QueueBranchBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL, repo, c.User.ID, mr.TargetRef, newSHA, time.Now()) c.Store.MarkMirrorsDirty(repo.ID, "push") if parts, err := c.Store.MRParticipants(mr.ID); err == nil { notify(c, parts, notice{repo: repo, kind: "mr", subject: mrSubject(repo, mr.Number, mr.Title), action: fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) } return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) { fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA) }) } // reviewGates enforces require_approvals (fresh, non-author, latest review // per reviewer; a fresh request-changes blocks), require_codeowners, and // require_resolved. Returns -1 to proceed. func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int { // A draft is open but not asking. This gate is unconditional — no // setting turns it off — because the author said so themselves. if mr.Draft { return c.fail(protocol.ExitDenied, "!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number) } set := repo.Settings reviews, err := c.Store.ListMRReviews(mr.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // Latest fresh review per reviewer decides their stance — but only // from someone the repository trusts to write to it. Reviewing is // open to any reader, which is what makes an outside opinion on a // public change possible; deciding a merge gate is not the same // thing, and counting every verdict let anyone with an account // satisfy require_approvals or block a merge indefinitely (#147). counts := ReviewersWhoCount(c.Store, repo, reviews) latest := map[string]string{} for _, r := range reviews { if r.Stale || r.Reviewer == mr.Author || !counts[r.Reviewer] { continue } latest[r.Reviewer] = r.Verdict } var approvers []string var blockers []string for who, verdict := range latest { switch verdict { case "approve": approvers = append(approvers, who) case "request_changes": blockers = append(blockers, who) } } if set.RequireApprovals > 0 { if len(blockers) > 0 { slices.Sort(blockers) return c.fail(protocol.ExitDenied, "%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number) } if len(approvers) < set.RequireApprovals { return c.fail(protocol.ExitDenied, "%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers)) } } // CODEOWNERS: every owned changed file needs an approval from one of // its owners. require_codeowners is the opt-in — a repository can // carry the file as documentation of who to ask without it gating // merges — and it does not wait on require_approvals (#99). if set.RequireCodeowners { content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20) if err != nil { content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20) } if err != nil || len(content) == 0 { return c.fail(protocol.ExitDenied, "%s requires CODEOWNERS approval but %s carries no CODEOWNERS file", repo.Path(), mr.TargetRef) } rules := policy.ParseCodeowners(string(content)) base, err := gitutil.MergeBase(dir, targetSHA, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } files, err := gitutil.DiffFiles(dir, base, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } approved := map[string]bool{} for _, a := range approvers { approved[a] = true } missing := map[string][]string{} // owner-set key -> example paths for _, f := range files { owners := policy.OwnersFor(rules, f) if owners == nil { continue } ok := false for _, o := range owners { if approved[o] { ok = true break } } if !ok { key := strings.Join(owners, ",") if len(missing[key]) < 3 { missing[key] = append(missing[key], f) } } } if len(missing) > 0 { var parts []string for owners, paths := range missing { parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners)) } slices.Sort(parts) return c.fail(protocol.ExitDenied, "CODEOWNERS approval missing for: %s", strings.Join(parts, "; ")) } } if set.RequireResolved { n, err := c.Store.UnresolvedThreadCount(mr.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if n > 0 { return c.fail(protocol.ExitDenied, "%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number) } } return -1 } func runMRDraft(c *Ctx, args []string) int { return setMRDraft(c, args, true) } func runMRReady(c *Ctx, args []string) int { return setMRDraft(c, args, false) } func setMRDraft(c *Ctx, args []string, draft bool) int { verb := "ready" if draft { verb = "draft" } repo, mr, code := mrRef(c, args, policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if len(args) != 2 { return c.fail(protocol.ExitUsage, "usage: mr %s <owner/name> <n>", verb) } if code := authorOrWrite(c, repo, mr.Author, "change this merge request"); code >= 0 { return code } if mr.State != "open" && mr.State != "source_gone" { return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) } if mr.Draft == draft { state := "already ready" if draft { state = "already a draft" } return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, state) } if err := c.Store.SetMRDraft(mr.ID, draft); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft", fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft)) // Marking ready is the request for review; going back to draft // withdraws it and is not worth anyone's inbox. // // The targets are the repository's, not the thread's participants. // Until someone comments or reviews, the only participant is the // author, who is the actor and excluded — so notifying participants // here reaches nobody, which is exactly what opening it as a draft // and then marking it ready would do. Opening a merge request tells // the repository; so does saying it is finally asking. if !draft { if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { parts, _ := c.Store.MRParticipants(mr.ID) notify(c, append(targets, parts...), notice{repo: repo, kind: "mr", subject: mrSubject(repo, mr.Number, mr.Title), action: fmt.Sprintf("marked !%d ready for review", mr.Number), path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) } } return c.emit(map[string]any{"number": mr.Number, "draft": draft}, func(w io.Writer) { fmt.Fprintf(w, "%s!%d is %s\n", repo.Path(), mr.Number, map[bool]string{true: "a draft", false: "ready"}[draft]) }) } func runMRClose(c *Ctx, args []string) int { repo, mr, code := mrRef(c, args, policy.CanRead) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } if len(args) != 2 { return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>") } if code := authorOrWrite(c, repo, mr.Author, "close this merge request"); code >= 0 { return code } if mr.State == "merged" || mr.State == "closed" { return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State) } if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", fmt.Sprintf(`{"number":%d}`, mr.Number)) if parts, err := c.Store.MRParticipants(mr.ID); err == nil { notify(c, parts, notice{repo: repo, kind: "mr", subject: mrSubject(repo, mr.Number, mr.Title), action: fmt.Sprintf("closed !%d", mr.Number), path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) } return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) { fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number) }) } // reviewAction is what a review notification says it was. A verdict with // a batch behind it is a different thing from a bare verdict, and the // person reading the mail is deciding whether to open it. func reviewAction(number int64, verdict string, published int64) string { if published > 0 { return fmt.Sprintf("reviewed !%d: %s, with %d comment(s)", number, verdict, published) } return fmt.Sprintf("reviewed !%d: %s", number, verdict) } // RevisionOut is one head a merge request has had. type RevisionOut struct { N int `json:"n"` // 1 is the first push SHA string `json:"sha"` BaseSHA string `json:"base_sha,omitempty"` CreatedAt string `json:"created_at"` Current bool `json:"current,omitempty"` } func mrRevisions(c *Ctx, mr store.MR) ([]RevisionOut, error) { heads, err := c.Store.MRHeads(mr.ID) if err != nil { return nil, err } out := make([]RevisionOut, 0, len(heads)) for i, h := range heads { out = append(out, RevisionOut{N: i + 1, SHA: h.SHA, BaseSHA: h.BaseSHA, CreatedAt: h.CreatedAt, Current: h.SHA == mr.HeadSHA}) } return out, nil } func runMRRevisions(c *Ctx, args []string) int { repo, mr, code := mrRef(c, args, policy.CanRead) if code >= 0 { return code } if len(args) != 2 { return c.fail(protocol.ExitUsage, "usage: mr revisions <owner/name> <n>") } revs, err := mrRevisions(c, mr) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(revs, func(w io.Writer) { for _, r := range revs { mark := " " if r.Current { mark = "*" } fmt.Fprintf(w, "%s v%d\t%.10s\t%s\n", mark, r.N, r.SHA, r.CreatedAt) } if len(revs) < 2 { fmt.Fprintf(w, "\nonly one revision; %s!%d has not been pushed to since it was opened\n", repo.Path(), mr.Number) } }) } func runMRRangeDiff(c *Ctx, args []string) int { const usage = "mr range-diff <owner/name> <n> [--from <sha>] [--to <sha>]" f, err := parseFlags(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: usage}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } repo, mr, code := mrRef(c, f.Pos, policy.CanRead) if code >= 0 { return code } if len(f.Pos) != 2 { return c.fail(protocol.ExitUsage, "usage: %s", usage) } revs, err := mrRevisions(c, mr) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // One revision is a merge request nobody has pushed to since it was // opened. The argv was fine and the answer is "nothing changed", so // this succeeds with an empty patch rather than failing. if len(revs) < 2 { fmt.Fprintf(c.Stderr, "%s!%d has one revision; nothing to compare it against\n", repo.Path(), mr.Number) return protocol.ExitOK } // Default to the two most recent, which is "what changed since the // last push" — the question a stale review asks. from, to := revs[len(revs)-2], revs[len(revs)-1] pick := func(sha string) (RevisionOut, bool) { for _, r := range revs { if strings.HasPrefix(r.SHA, sha) { return r, true } } return RevisionOut{}, false } if v := f.Value("--from"); v != "" { r, ok := pick(v) if !ok { return c.fail(protocol.ExitNotFound, "%.12s is not a revision of !%d; see `mr revisions`", v, mr.Number) } from = r } if v := f.Value("--to"); v != "" { r, ok := pick(v) if !ok { return c.fail(protocol.ExitNotFound, "%.12s is not a revision of !%d; see `mr revisions`", v, mr.Number) } to = r } if from.SHA == to.SHA { return c.fail(protocol.ExitUsage, "--from and --to are the same revision") } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) // A revision recorded before its base could be worked out, or by a // migration backfill, falls back to the target's merge base. baseOf := func(r RevisionOut) string { if r.BaseSHA != "" { return r.BaseSHA } b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, r.SHA) if err != nil { return r.SHA + "^" } return b } patch, truncated, err := gitutil.RangeDiff(dir, baseOf(from), from.SHA, baseOf(to), to.SHA, 4<<20) if err != nil { return c.fail(protocol.ExitFailure, "%v (the objects for an older revision may have been garbage-collected)", err) } fmt.Fprint(c.Stdout, patch) if truncated { fmt.Fprintln(c.Stderr, "range-diff truncated at 4 MiB") } return protocol.ExitOK } // reviewersWhoCount is the set of reviewers whose verdict decides a merge // gate: those with write access to the repository. // // Write, rather than a separate reviewer role, because it is the same // question the gates already answer — a person who could push this change // themselves is the person whose approval means the repository accepts // it. Someone named in CODEOWNERS without write is a misconfiguration the // owner should fix rather than a case to special-case here: they could // not merge what they approved. // Exported because the web renders the same distinction: a page that // showed an approval the gate ignores would differ from the gate, and the // difference would only surface when a merge was refused. func ReviewersWhoCount(st *store.Store, repo store.Repo, reviews []store.MRReview) map[string]bool { counts := map[string]bool{} for _, r := range reviews { if _, done := counts[r.Reviewer]; done { continue } counts[r.Reviewer] = false u, err := st.UserByUsername(r.Reviewer) if err != nil { continue } grant, err := st.AccessRole(repo.ID, u.ID) if err != nil { continue } counts[r.Reviewer] = policy.CanWrite(u, repo, grant) } return counts }