package control import ( "errors" "fmt" "io" "slices" "strconv" "strings" "time" "gitbay.org/gitbay/internal/ci" "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 [--owner ] [--name ]", Flags: []Flag{ {"--owner", "", "fork under this user or org, default your account", ""}, {"--name", "", "name the fork", "the source's name"}, }, Examples: []string{"repo fork krz/gitbay"}, Run: runRepoFork}) register(Command{Path: []string{"repo", "settings", "require-approvals"}, Summary: "require N fresh approvals to merge", Usage: "repo settings require-approvals (0 = off)", Examples: []string{"repo settings require-approvals krz/gitbay 1"}, 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", Examples: []string{"repo settings require-resolved krz/gitbay on"}, 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", Examples: []string{"repo settings require-codeowners krz/gitbay on"}, Run: runRequireCodeowners}) register(Command{Path: []string{"repo", "settings", "require-checks"}, Summary: "gate merges on green statuses", Usage: "repo settings require-checks on|off", Examples: []string{"repo settings require-checks krz/gitbay on"}, Run: runRequireChecks}) register(Command{Path: []string{"repo", "settings", "require-mr"}, Summary: "protected branches take changes through merge requests only", Usage: "repo settings require-mr on|off", Examples: []string{"repo settings require-mr krz/gitbay on"}, Run: runRequireMR}) register(Command{Path: []string{"repo", "settings", "require-signed"}, Summary: "require verified commit signatures", Usage: "repo settings require-signed on|off", Examples: []string{"repo settings require-signed krz/gitbay on"}, 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]", Flags: []Flag{ {"--source", "[owner/name:]", "the branch to merge, from a fork with owner/name:", ""}, {"--target", "", "the branch to merge into", ""}, {"--title", "", "the merge request's title", ""}, {"--body", "", "the merge request's body", ""}, {"--file", "-", "read the body from stdin", ""}, {"--format", "md|org", "the body's markup", "md"}, {"--draft", "", "open it as work in progress", ""}, }, Examples: []string{ `mr create krz/gitbay --source cli-output-help --target main --title "control: flag help"`, "mr create krz/gitbay --source cli-output-help --target main --title notes --file - < notes.md", }, 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 ]", Flags: []Flag{ {"--from", "", "earlier revision, default the one before --to", ""}, {"--to", "", "later revision, default the head", ""}, }, Examples: []string{"mr range-diff krz/gitbay 431"}, ReadOnly: true, Run: runMRRangeDiff}) register(Command{Path: []string{"mr", "revisions"}, Summary: "the heads a merge request has had", Usage: "mr revisions ", Examples: []string{"mr revisions krz/gitbay 431"}, ReadOnly: true, Run: runMRRevisions}) register(Command{Path: []string{"mr", "draft"}, Summary: "mark a merge request as work in progress", Usage: "mr draft ", Examples: []string{"mr draft krz/gitbay 431"}, Run: runMRDraft}) register(Command{Path: []string{"mr", "ready"}, Summary: "take the draft mark off, so it can merge", Usage: "mr ready ", Examples: []string{"mr ready krz/gitbay 431"}, Run: runMRReady}) register(Command{Path: []string{"mr", "list"}, Summary: "list merge requests", Usage: "mr list [--state open|merged|closed|source_gone|all] [--label ] [--author ] [--milestone |none] [--search <text>] [--limit <n>] [--cursor <c>]", Flags: []Flag{ {"--state", "open|merged|closed|source_gone|all", "which merge requests", "open"}, {"--label", "<l>", "only MRs carrying this label", ""}, {"--author", "<user>", "only MRs opened by this user", ""}, {"--milestone", "<title>|none", "only MRs in this milestone, or in none", ""}, {"--search", "<text>", "match title and body", ""}, {"--limit", "<n>", "rows per page", ""}, {"--cursor", "<c>", "continue from the previous page", ""}, }, Examples: []string{ "mr list krz/gitbay --state open", "mr list krz/gitbay --author cmc --state all", }, ReadOnly: true, Run: runMRList}) register(Command{Path: []string{"mr", "show"}, Summary: "show a merge request", Usage: "mr show <owner/name> <n>", Examples: []string{"mr show krz/gitbay 431"}, ReadOnly: true, Run: runMRShow}) register(Command{Path: []string{"mr", "diff"}, Summary: "show the diff", Usage: "mr diff <owner/name> <n>", Examples: []string{"mr diff krz/gitbay 431"}, 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] [--superseded-by <m>|none]", Flags: []Flag{ {"--title", "<t>", "the merge request's new title", ""}, {"--body", "<b>", "the merge request's new body", ""}, {"--file", "-", "read the new body from stdin", ""}, {"--format", "md|org", "the body's markup", ""}, {"--superseded-by", "<m>|none", "the MR replacing this one, or none to clear", ""}, }, Examples: []string{`mr edit krz/gitbay 431 --title "control: flag help, take two"`}, ReadsStdin: true, Run: runMREdit}) register(Command{Path: []string{"mr", "retarget"}, Summary: "retarget onto another branch", Usage: "mr retarget <owner/name> <n> <branch>", Examples: []string{"mr retarget krz/gitbay 431 main"}, Run: runMRRetarget}) register(Command{Path: []string{"mr", "comment"}, Summary: "add a comment", Usage: "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]", Flags: []Flag{ {"--message", "<m>", "the comment's text", ""}, {"--file", "-", "read the comment from stdin", ""}, {"--format", "md|org", "the comment's markup", "md"}, }, Examples: []string{`mr comment krz/gitbay 431 --message "looks good"`}, ReadsStdin: true, Run: runMRComment}) register(Command{Path: []string{"mr", "review"}, Summary: "record a review verdict", Usage: "mr review <owner/name> <n> --approve|--request-changes|--comment|--discard", Flags: []Flag{ {"--approve", "", "approve the merge request", ""}, {"--request-changes", "", "ask for changes", ""}, {"--comment", "", "submit pending diff comments without a verdict", ""}, {"--discard", "", "throw away pending diff comments", ""}, }, Examples: []string{"mr review krz/gitbay 431 --approve"}, Run: runMRReview}) register(Command{Path: []string{"mr", "review", "request"}, Summary: "ask specific people for a review", Usage: "mr review request <owner/name> <n> [--add <user>]... [--remove <user>]...", Flags: []Flag{ {"--add", "<user>", "reviewer to add, may repeat", ""}, {"--remove", "<user>", "reviewer to remove, may repeat", ""}, }, Examples: []string{"mr review request krz/gitbay 431 --add cmc"}, Run: runMRReviewRequest}) register(Command{Path: []string{"mr", "label"}, Summary: "add or remove labels", Usage: "mr label <owner/name> <n> [--add <l>]... [--remove <l>]...", Flags: []Flag{ {"--add", "<l>", "label to add, may repeat", ""}, {"--remove", "<l>", "label to remove, may repeat", ""}, }, Examples: []string{"mr label krz/gitbay 431 --add needs-review"}, Run: runMRLabel}) register(Command{Path: []string{"mr", "merge"}, Summary: "merge", Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Flags: []Flag{ {"--strategy", "ff|merge|squash|rebase", "how to merge", ""}, }, Examples: []string{"mr merge krz/gitbay 431 --strategy ff"}, Run: runMRMerge}) register(Command{Path: []string{"mr", "close"}, Summary: "close without merging", Usage: "mr close <owner/name> <n> [--by <m>]", Flags: []Flag{ {"--by", "<m>", "the MR that supersedes this one", ""}, }, Examples: []string{"mr close krz/gitbay 431"}, Run: runMRClose}) } // ForkOut is what `repo fork` emits: where the fork landed, and what it // came from. Named so the web can send a person to the new repository. type ForkOut struct { Path string `json:"path"` ForkOf string `json:"fork_of"` } func runRepoFork(c *Ctx, args []string) int { f, err := c.parseArgs(args, flagSpec{Values: []string{"--name", "--owner"}, MaxPos: 1, Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]"}) if err != nil { return c.fail(protocol.ExitUsage, "%v", err) } path, name, owner := f.pos(0), f.Value("--name"), f.Value("--owner") if path == "" { return c.usage() } 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.failInput(err) } if owner == "" { owner = c.User.Username } ownerKind, ownerID, code := resolveNewRepoOwner(c, owner) if code >= 0 { return code } repoCreateMu.Lock() // An organization's repositories are not counted against the quota, // the same as repo create. if ownerKind == "user" { if code := checkRepoQuota(c); code >= 0 { repoCreateMu.Unlock() return code } } id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID) repoCreateMu.Unlock() if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } dstDir := RepoDir(c.Cfg.Server.Root, owner, 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 := owner + "/" + name return c.emit(ForkOut{Path: forkPath, ForkOf: 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.usage() } 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.usage() } 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.usage() } 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.usage() } 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 runRequireMR(c *Ctx, args []string) int { if len(args) != 2 || (args[1] != "on" && args[1] != "off") { return c.usage() } 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.RequireMR = 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_mr %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.usage() } 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 := c.parseArgs(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.usage() } fmtName, err := markupFormat(format) if err != nil { return c.failInput(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.failInput(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)}) } if created, err := c.Store.MRByNumber(repo.ID, n); err == nil { notifyMentions(c, repo, mrThread, created.ID, n, title, b) } 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"` Labels []string `json:"labels,omitempty"` // ReviewRequests is who has been asked, directly, for a review. ReviewRequests []string `json:"review_requests,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"` // SupersededBy is the merge request, by number, this one was closed // in favour of. 0 means none. SupersededBy int64 `json:"superseded_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, Labels: m.Labels, ReviewRequests: m.ReviewRequests, CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy, ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy} 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 } f := store.MRFilter{State: "open"} fl, err := c.parseArgs(args, flagSpec{Values: []string{"--state", "--label", "--author", "--milestone", "--search"}, MaxPos: 1, Usage: c.Cmd.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.Label, f.Author, f.Milestone = fl.Value("--label"), fl.Value("--author"), fl.Value("--milestone") f.Search = fl.Value("--search") if fl.Has("--search") { if err := validQuery(f.Search); err != nil { return c.failInput(err) } } valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true} if path == "" || !valid[f.State] { return c.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) { tb := c.table(w, "!", "STATE", "TITLE", "REF") for _, d := range ds { state := d.State if d.Draft { state = "draft" } cells := []cell{cRef(fmt.Sprintf("!%d", d.Number)), cState(state), cFlex(d.Title), cText(fmt.Sprintf("%s -> %s", d.Source, d.TargetRef))} if d.StackedOn != nil { cells = append(cells, cText(fmt.Sprintf("stacked on !%d", d.StackedOn.Number))) } tb.row(cells...) } tb.flush() }) } // 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.usage() } 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, cm.Kind}) } 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) if mr.State == "open" || mr.State == "source_gone" { if targetSHA, err := gitutil.ResolveRef(dir, "refs/heads/"+mr.TargetRef); err == nil { if g, err := MergeGates(c.Store, repo, mr, dir, targetSHA, mr.HeadSHA); err == nil { d.Gates = &g } } } return c.emit(d, func(w io.Writer) { state := d.State if d.Draft { state = "draft" } v := c.view(w) v.title(fmt.Sprintf("!%d", d.Number), d.Title, state) stackedOn, stacked := "", "" if d.StackedOn != nil { stackedOn = fmt.Sprintf("!%d %s", d.StackedOn.Number, d.StackedOn.Title) } for _, k := range d.Stacked { if stacked != "" { stacked += ", " } stacked += fmt.Sprintf("!%d %s", k.Number, k.Title) } merged, closed, superseded := "", "", "" if d.MergedAt != "" { merged = c.when(d.MergedAt) + byWhom(d.MergedBy) } if d.ClosedAt != "" { closed = c.when(d.ClosedAt) + byWhom(d.ClosedBy) } if d.SupersededBy != 0 { superseded = fmt.Sprintf("!%d", d.SupersededBy) } gates := "" if g := d.Gates; g != nil { ff := "fast-forward possible" if !g.FastForward { ff = "fast-forward not possible" } if len(g.Unmet) == 0 { gates = "met; " + ff } else { gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff) } } unresolved := "" if d.UnresolvedThreads > 0 { unresolved = fmt.Sprintf("%d", d.UnresolvedThreads) } // One fields call for every one-row fact, including the unmet // gates and the commit/check/review that only has a single row // to show: separate calls each compute their own key width, so // keeping them in one call keeps every key aligned. kv := []string{ "author", d.Author + ", " + c.when(d.CreatedAt), "source", fmt.Sprintf("%s -> %s", d.Source, d.TargetRef), "head", fmt.Sprintf("%.10s", d.HeadSHA), "milestone", d.Milestone, "labels", strings.Join(d.Labels, ", "), "reviewers", strings.Join(d.ReviewRequests, ", "), "stacked on", stackedOn, "stacked", stacked, "merged", merged, "closed", closed, "superseded by", superseded, "unresolved threads", unresolved, "gates", gates, } if g := d.Gates; g != nil { for _, u := range g.Unmet { kv = append(kv, "unmet", u) } } if len(commits) == 1 { kv = append(kv, "commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject)) } if len(checks) == 1 { x := checks[0] dur := "" if x.Duration != "" { dur = " in " + x.Duration } kv = append(kv, "check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur)) } if len(rs) == 1 { kv = append(kv, "review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt)) } kv = append(kv, "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))) v.fields(kv...) v.body(d.Body, d.BodyFormat) if len(commits) > 1 { v.section(fmt.Sprintf("commits (%d)", len(commits))) tb := c.table(w, "SHA", "SUBJECT") for _, cm := range commits { tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject)) } tb.flush() } if len(checks) > 1 { v.section(fmt.Sprintf("checks (%d)", len(checks))) tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED") for _, x := range checks { tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt))) } tb.flush() } if len(rs) > 1 { v.section(fmt.Sprintf("reviews (%d)", len(rs))) tb := c.table(w, "REVIEWER", "VERDICT", "WHEN") for _, r := range rs { verdict := r.Verdict if r.Stale { verdict += " (stale)" } if !r.Counts { verdict += " (advisory)" } tb.row(cText(r.Reviewer), cState(verdict), cText(c.when(r.CreatedAt))) } tb.flush() } events := false for _, cm := range cs { if cm.Kind != "system" { continue } if !events { io.WriteString(w, "\n") events = true } v.event(cm.Body, cm.BodyFormat, cm.CreatedAt) } for _, cm := range cs { if cm.Kind == "system" { continue } v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) } }) } // reviewLine renders one review as fields prose: "reviewer verdict // (stale) (advisory) at when". func reviewLine(r ReviewOut) string { s := r.Reviewer + " " + r.Verdict if r.Stale { s += " (stale)" } if !r.Counts { s += " (advisory: no write access)" } return s } 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) if _, err := gitutil.ResolveRef(dir, head); err != nil { return c.fail(protocol.ExitFailure, "the head of !%d is no longer in the repository; its diff is not available", 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, fl, code := editText(c, args, "mr", "--superseded-by") 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 } var clearSuperseded bool var supersededBy int64 if fl.Has("--superseded-by") { if mr.State != "closed" { return c.fail(protocol.ExitUsage, "only a closed merge request can be superseded") } if v := fl.Value("--superseded-by"); v == "none" { clearSuperseded = true } else { supersededBy, code = resolveSupersededBy(c, repo, mr.Number, v) if code >= 0 { return code } } } if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if clearSuperseded { if err := c.Store.SetSupersededBy(mr.ID, 0); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } } else if supersededBy != 0 { if err := c.Store.SetSupersededBy(mr.ID, supersededBy); 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.usage() } 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) } } if discard && verdict != "" { return c.fail(protocol.ExitUsage, "--discard throws the batch away; it takes no verdict") } if verdict == "" && !discard { return c.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)}) } // Whether the merge gates will count this verdict, said now rather // than at the refusal (#199). counts := ReviewersWhoCount(c.Store, repo, []store.MRReview{{Reviewer: c.User.Username}})[c.User.Username] return c.emit(map[string]any{"number": mr.Number, "verdict": verdict, "published": published, "counts": counts}, 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) } if !counts { fmt.Fprintf(w, " (advisory: no write access on %s, so the merge gates do not count it)", repo.Path()) } fmt.Fprintln(w) }) } // runMRReviewRequest is issue assign's counterpart for merge requests: it // pushes a merge request into a specific person's review queue and inbox // directly, rather than waiting for them to be otherwise involved (#145). func runMRReviewRequest(c *Ctx, args []string) int { rest, adds, removes, err := addRemoveFlags(args) if err != nil { return c.failInput(err) } if len(adds)+len(removes) == 0 { return c.usage() } repo, mr, code := mrRef(c, rest, policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } resolve := func(name string) (store.User, int) { u, err := c.Store.UserByUsername(name) if errors.Is(err, store.ErrNotFound) { return u, c.fail(protocol.ExitNotFound, "no such user %q", name) } if err != nil { return u, c.fail(protocol.ExitFailure, "%v", err) } return u, -1 } // Notified on every return, not just success: a name later in --add // that fails to resolve or lacks access must not silence the people // already added earlier in the same call. var added []store.User defer func() { if len(added) == 0 { return } ids := make([]int64, len(added)) for i, u := range added { ids[i] = u.ID } notify(c, ids, notice{repo: repo, kind: "mr", subject: mrSubject(repo, mr.Number, mr.Title), action: fmt.Sprintf("asked for a review on !%d", mr.Number), path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) }() for _, name := range adds { u, code := resolve(name) if code >= 0 { return code } // A review request that lands nowhere the recipient can see it is // worse than useless: it looks like the ask went through. grant, err := c.Store.AccessRole(repo.ID, u.ID) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if !policy.CanRead(u, repo, grant) { return c.fail(protocol.ExitDenied, "%s cannot read %s", name, repo.Path()) } if err := c.Store.SetMRReviewRequest(mr.ID, u.ID, true); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } added = append(added, u) } for _, name := range removes { u, code := resolve(name) if code >= 0 { return code } if err := c.Store.SetMRReviewRequest(mr.ID, u.ID, false); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "%s is not a requested reviewer", name) } return c.fail(protocol.ExitFailure, "%v", err) } } updated, err := c.Store.MRByNumber(repo.ID, mr.Number) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.review_requested", fmt.Sprintf(`{"number":%d,"reviewers":%s}`, mr.Number, jsonStrings(updated.ReviewRequests))) return c.emit(map[string]any{"number": mr.Number, "reviewers": updated.ReviewRequests}, func(w io.Writer) { fmt.Fprintf(w, "requested reviewers on %s!%d: %s\n", repo.Path(), mr.Number, strings.Join(updated.ReviewRequests, ", ")) }) } // runMRLabel is issue label's counterpart for merge requests: the label // set is the repository's (or its org's), shared with the issues (#231). func runMRLabel(c *Ctx, args []string) int { rest, adds, removes, err := addRemoveFlags(args) if err != nil { return c.failInput(err) } if len(adds)+len(removes) == 0 { return c.usage() } repo, mr, code := mrRef(c, rest, policy.CanWrite) if code >= 0 { return code } if code := refuseArchived(c, repo); code >= 0 { return code } for _, l := range adds { if err := c.Store.SetMRLabel(repo, mr.ID, l, true); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } } for _, l := range removes { if err := c.Store.SetMRLabel(repo, mr.ID, l, false); err != nil { if errors.Is(err, store.ErrNotFound) { return c.fail(protocol.ExitNotFound, "%v", err) } return c.fail(protocol.ExitFailure, "%v", err) } } updated, err := c.Store.MRByNumber(repo.ID, mr.Number) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.labeled", fmt.Sprintf(`{"number":%d,"labels":%s}`, mr.Number, jsonStrings(updated.Labels))) return c.emit(map[string]any{"number": mr.Number, "labels": updated.Labels}, func(w io.Writer) { fmt.Fprintf(w, "labels on %s!%d: %s\n", repo.Path(), mr.Number, strings.Join(updated.Labels, ", ")) }) } func runMRMerge(c *Ctx, args []string) int { f, err := c.parseArgs(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) } // Merge gates: draft, checks, approvals, CODEOWNERS, resolved threads, // all reported at once. 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; merge with the merge strategy, or rebase and push again", 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 — choose the merge or squash strategy", 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 the fast-forward or merge strategy, 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, c.Scope, targetSHA, newSHA) ProcessMRDescription(c.Store, repo, mr, c.User.ID, c.Scope) 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, targetSHA, 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 refuses a merge whose gates are not all met, naming every // unmet one. Returns -1 to proceed. func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int { g, err := MergeGates(c.Store, repo, mr, dir, targetSHA, headSHA) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } if len(g.Unmet) > 0 { return c.fail(protocol.ExitDenied, "%s", strings.Join(g.Unmet, "; ")) } return -1 } // checksExpected reports whether anything was going to report a status // on this head. A repository with no CI configuration and no history of // statuses can never satisfy require_checks, and refusing its merges // leaves no remedy but turning the setting off. Two things say a report // was coming: a .gitbay/ci.yml at the head with a job a push runs, and a // status having ever been recorded in the repository, which is how a // repository reporting from outside through `status set` looks. func checksExpected(st *store.Store, repoID int64, dir, headSHA string) bool { if seen, err := st.RepoHasStatuses(repoID); err != nil || seen { return true } return headRunsJobs(dir, headSHA) } // headRunsJobs reports whether a push of this head would have queued or // skipped a job, and so left it a status. A configuration that will not // parse counts as running jobs: the push recorded a ci/config failure // for it, so the head is not silent and this is not the branch that // decides. func headRunsJobs(dir, headSHA string) bool { raw, err := gitutil.ReadBlob(dir, headSHA, ci.ConfigPath, 1<<16) if err != nil { return false } jobs, err := ci.Parse(raw) if err != nil { return true } for _, j := range jobs { if j.Tags == "" && j.Schedule == "" { return true } } return false } // MergeGates computes where a merge request stands against its // repository's gates: draft, require_checks, require_approvals (fresh, // non-author, latest review per reviewer from someone who can write; a // fresh request-changes blocks), require_codeowners and require_resolved. // Unmet carries one sentence per gate not passed. Fast-forward is // reported, not gated: whether it matters depends on the strategy. func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) (GatesOut, error) { set := repo.Settings g := GatesOut{Draft: mr.Draft, ApprovalsRequired: set.RequireApprovals, CodeownersRequired: set.RequireCodeowners, ResolvedRequired: set.RequireResolved, ChecksRequired: set.RequireChecks} // 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 { g.Unmet = append(g.Unmet, fmt.Sprintf("!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number)) } // Checks: with require_checks, every status the head carries must be // green, and a head something was going to report on must carry some. statuses, err := st.ListCommitStatuses(repo.ID, headSHA) if err != nil { return g, err } g.Checks = store.CombinedStatus(statuses) if set.RequireChecks { switch g.Checks { case "success": case "": if checksExpected(st, repo.ID, dir, headSHA) { g.Unmet = append(g.Unmet, fmt.Sprintf("%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) } } g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))) } } reviews, err := st.ListMRReviews(mr.ID) if err != nil { return g, 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(st, 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 } for who, verdict := range latest { switch verdict { case "approve": g.Approvals = append(g.Approvals, who) case "request_changes": g.ChangesRequested = append(g.ChangesRequested, who) } } slices.Sort(g.Approvals) slices.Sort(g.ChangesRequested) if set.RequireApprovals > 0 { if len(g.ChangesRequested) > 0 { g.Unmet = append(g.Unmet, fmt.Sprintf("%s requested changes on !%d; resolve their review before merging", strings.Join(g.ChangesRequested, ", "), mr.Number)) } if len(g.Approvals) < set.RequireApprovals { g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(g.Approvals))) } } // 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 { g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires CODEOWNERS approval but %s carries no CODEOWNERS file", repo.Path(), mr.TargetRef)) } else { rules := policy.ParseCodeowners(string(content)) base, err := gitutil.MergeBase(dir, targetSHA, headSHA) if err != nil { return g, err } files, err := gitutil.DiffFiles(dir, base, headSHA) if err != nil { return g, err } approved := map[string]bool{} for _, a := range g.Approvals { approved[a] = true } missing := map[string][]string{} // owner-set key -> paths var keys []string 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 _, seen := missing[key]; !seen { keys = append(keys, key) } missing[key] = append(missing[key], f) } } if len(missing) > 0 { slices.Sort(keys) var parts []string for _, key := range keys { paths := missing[key] g.OwnersOutstanding = append(g.OwnersOutstanding, OwnersOut{Files: paths, Owners: strings.Split(key, ",")}) if len(paths) > 3 { paths = paths[:3] } parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), key)) } g.Unmet = append(g.Unmet, "CODEOWNERS approval missing for: "+strings.Join(parts, "; ")) } } } n, err := st.UnresolvedThreadCount(mr.ID) if err != nil { return g, err } g.OpenThreads = n if set.RequireResolved && n > 0 { g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number)) } if ff, err := gitutil.IsAncestor(dir, targetSHA, headSHA); err == nil { g.FastForward = ff } return g, nil } 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 { 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.usage() } 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. A review // request made before ready — or on an earlier revision — reaches its // target here too: they are exactly who else is being asked. if !draft { if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { parts, _ := c.Store.MRParticipants(mr.ID) reviewers, _ := c.Store.MRReviewRequestIDs(mr.ID) notify(c, append(append(targets, parts...), reviewers...), 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 { f, err := c.parseArgs(args, flagSpec{Values: []string{"--by"}, MaxPos: 2, Usage: "mr close <owner/name> <n> [--by <m>]"}) 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 code := refuseArchived(c, repo); code >= 0 { return code } if len(f.Pos) != 2 { return c.usage() } 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) } var by int64 if f.Has("--by") { by, code = resolveSupersededBy(c, repo, mr.Number, f.Value("--by")) if code >= 0 { return code } } if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } eventData := fmt.Sprintf(`{"number":%d}`, mr.Number) if by != 0 { if err := c.Store.SetSupersededBy(mr.ID, by); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } eventData = fmt.Sprintf(`{"number":%d,"by":%d}`, mr.Number, by) } c.Store.RecordEvent(repo.ID, c.User.ID, "mr.closed", eventData) 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) }) } // resolveSupersededBy validates a --superseded-by/--by value against the // merge request it would be set on: it must parse, name another merge // request in the same repository (never itself), and that request must // exist. -1 as the returned code means the value is good to use. func resolveSupersededBy(c *Ctx, repo store.Repo, number int64, v string) (int64, int) { m, err := strconv.ParseInt(v, 10, 64) if err != nil { return 0, c.fail(protocol.ExitUsage, "bad MR number %q", v) } if m == number { return 0, c.fail(protocol.ExitUsage, "a merge request cannot supersede itself") } if _, err := c.Store.MRByNumber(repo.ID, m); errors.Is(err, store.ErrNotFound) { return 0, c.fail(protocol.ExitNotFound, "no merge request !%d on %s", m, repo.Path()) } else if err != nil { return 0, c.fail(protocol.ExitFailure, "%v", err) } return m, -1 } // 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.usage() } revs, err := mrRevisions(c, mr) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } return c.emit(revs, func(w io.Writer) { tb := c.table(w, "REV", "SHA", "WHEN") for _, r := range revs { mark := " " if r.Current { mark = "*" } tb.row(cRef(fmt.Sprintf("%s v%d", mark, r.N)), cRef(fmt.Sprintf("%.10s", r.SHA)), cAge(r.CreatedAt)) } tb.flush() if len(revs) < 2 { fmt.Fprintf(c.Stderr, "only 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 { f, err := c.parseArgs(args, flagSpec{Values: []string{"--from", "--to"}, MaxPos: 2, Usage: c.Cmd.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.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 }