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