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