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