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