internal/control/mr.go
1269 lines · 43765 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/gitutil"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/sig"
16 "gitbay.org/gitbay/internal/store"
17)
18
19func init() {
20 register(Command{Path: []string{"repo", "fork"},
21 Summary: "fork a repository under your account",
22 Usage: "repo fork <owner/name> [--name <n>]", Run: runRepoFork})
23 register(Command{Path: []string{"repo", "settings", "require-approvals"},
24 Summary: "require N fresh approvals to merge",
25 Usage: "repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals})
26 register(Command{Path: []string{"repo", "settings", "require-resolved"},
27 Summary: "require all review threads resolved to merge",
28 Usage: "repo settings require-resolved <owner/name> on|off", Run: runRequireResolved})
29 register(Command{Path: []string{"repo", "settings", "require-checks"},
30 Summary: "gate merges on green statuses",
31 Usage: "repo settings require-checks <owner/name> on|off", Run: runRequireChecks})
32 register(Command{Path: []string{"repo", "settings", "require-signed"},
33 Summary: "require verified commit signatures",
34 Usage: "repo settings require-signed <owner/name> on|off", Run: runRequireSigned})
35 register(Command{Path: []string{"mr", "create"},
36 Summary: "open a merge request",
37 Usage: "mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -] [--format md|org]",
38 ReadsStdin: true, Run: runMRCreate})
39 register(Command{Path: []string{"mr", "list"},
40 Summary: "list merge requests",
41 Usage: "mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runMRList})
42 register(Command{Path: []string{"mr", "show"},
43 Summary: "show a merge request",
44 Usage: "mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow})
45 register(Command{Path: []string{"mr", "diff"},
46 Summary: "show the diff",
47 Usage: "mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff})
48 register(Command{Path: []string{"mr", "edit"},
49 Summary: "edit title or body",
50 Usage: "mr edit <owner/name> <n> [--title <t>] [--body <b> | --file -] [--format md|org]",
51 ReadsStdin: true, Run: runMREdit})
52 register(Command{Path: []string{"mr", "retarget"},
53 Summary: "retarget onto another branch",
54 Usage: "mr retarget <owner/name> <n> <branch>", Run: runMRRetarget})
55 register(Command{Path: []string{"mr", "comment"},
56 Summary: "comment",
57 Usage: "mr comment <owner/name> <n> [--message <m> | --file -] [--format md|org]",
58 ReadsStdin: true, Run: runMRComment})
59 register(Command{Path: []string{"mr", "review"},
60 Summary: "review",
61 Usage: "mr review <owner/name> <n> --approve|--request-changes|--comment", Run: runMRReview})
62 register(Command{Path: []string{"mr", "merge"},
63 Summary: "merge",
64 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]", Run: runMRMerge})
65 register(Command{Path: []string{"mr", "close"},
66 Summary: "close without merging",
67 Usage: "mr close <owner/name> <n>", Run: runMRClose})
68}
69
70func runRepoFork(c *Ctx, args []string) int {
71 var path, name string
72 for i := 0; i < len(args); i++ {
73 switch args[i] {
74 case "--name":
75 if i+1 >= len(args) {
76 return c.fail(protocol.ExitUsage, "--name requires a value")
77 }
78 name = args[i+1]
79 i++
80 default:
81 if path != "" {
82 return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
83 }
84 path = args[i]
85 }
86 }
87 if path == "" {
88 return c.fail(protocol.ExitUsage, "usage: repo fork <owner/name> [--name <n>]")
89 }
90 src, code := resolveRepo(c, path, policy.CanRead)
91 if code >= 0 {
92 return code
93 }
94 if name == "" {
95 name = src.Name
96 }
97 if err := policy.ValidateName(name); err != nil {
98 return c.fail(protocol.ExitUsage, "%v", err)
99 }
100 id, err := c.Store.CreateRepo("user", c.User.ID, name, src.Visibility)
101 if err != nil {
102 return c.fail(protocol.ExitFailure, "%v", err)
103 }
104 if err := c.Store.SetForkOf(id, src.ID); err != nil {
105 return c.fail(protocol.ExitFailure, "%v", err)
106 }
107 dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name)
108 srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
109 if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
110 c.Store.DeleteRepo(id)
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 if desc := gitutil.ReadDescription(srcDir); desc != "" {
114 gitutil.WriteDescription(dstDir, desc)
115 }
116 if err := gitutil.FetchInto(dstDir, srcDir, "refs/heads/*", "refs/heads/*"); err != nil {
117 // Empty source repos have nothing to fetch; that is fine.
118 if _, rerr := gitutil.ResolveRef(srcDir, src.DefaultBranch); rerr == nil {
119 c.Store.DeleteRepo(id)
120 return c.fail(protocol.ExitFailure, "copying refs: %v", err)
121 }
122 }
123 forkPath := c.User.Username + "/" + name
124 return c.emit(map[string]string{"path": forkPath, "fork_of": src.Path()}, func(w io.Writer) {
125 fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
126 })
127}
128
129func runRequireApprovals(c *Ctx, args []string) int {
130 if len(args) != 2 {
131 return c.fail(protocol.ExitUsage, "usage: repo settings require-approvals <owner/name> <n>")
132 }
133 n, err := strconv.Atoi(args[1])
134 if err != nil || n < 0 || n > 20 {
135 return c.fail(protocol.ExitUsage, "approvals must be 0..20")
136 }
137 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
138 if code >= 0 {
139 return code
140 }
141 s := repo.Settings
142 s.RequireApprovals = n
143 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
144 return c.fail(protocol.ExitFailure, "%v", err)
145 }
146 return c.emit(s, func(w io.Writer) {
147 fmt.Fprintf(w, "require_approvals %d on %s\n", n, repo.Path())
148 })
149}
150
151func runRequireResolved(c *Ctx, args []string) int {
152 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
153 return c.fail(protocol.ExitUsage, "usage: repo settings require-resolved <owner/name> on|off")
154 }
155 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
156 if code >= 0 {
157 return code
158 }
159 s := repo.Settings
160 s.RequireResolved = args[1] == "on"
161 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
162 return c.fail(protocol.ExitFailure, "%v", err)
163 }
164 return c.emit(s, func(w io.Writer) {
165 fmt.Fprintf(w, "require_resolved %s on %s\n", args[1], repo.Path())
166 })
167}
168
169func runRequireChecks(c *Ctx, args []string) int {
170 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
171 return c.fail(protocol.ExitUsage, "usage: repo settings require-checks <owner/name> on|off")
172 }
173 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
174 if code >= 0 {
175 return code
176 }
177 s := repo.Settings
178 s.RequireChecks = args[1] == "on"
179 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
180 return c.fail(protocol.ExitFailure, "%v", err)
181 }
182 return c.emit(s, func(w io.Writer) {
183 fmt.Fprintf(w, "require_checks %s on %s\n", args[1], repo.Path())
184 })
185}
186
187func runRequireSigned(c *Ctx, args []string) int {
188 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
189 return c.fail(protocol.ExitUsage, "usage: repo settings require-signed <owner/name> on|off")
190 }
191 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
192 if code >= 0 {
193 return code
194 }
195 s := repo.Settings
196 s.RequireSignedCommits = args[1] == "on"
197 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
198 return c.fail(protocol.ExitFailure, "%v", err)
199 }
200 return c.emit(s, func(w io.Writer) {
201 fmt.Fprintf(w, "require_signed_commits %s on %s\n", args[1], repo.Path())
202 })
203}
204
205// mrRef parses "<owner/name> <n>" and loads the MR.
206func mrRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.MR, int) {
207 if len(args) < 2 {
208 return store.Repo{}, store.MR{}, c.fail(protocol.ExitUsage, "expected <owner/name> <number>")
209 }
210 repo, code := resolveRepo(c, args[0], perm)
211 if code >= 0 {
212 return repo, store.MR{}, code
213 }
214 n, err := strconv.ParseInt(args[1], 10, 64)
215 if err != nil {
216 return repo, store.MR{}, c.fail(protocol.ExitUsage, "bad MR number %q", args[1])
217 }
218 mr, err := c.Store.MRByNumber(repo.ID, n)
219 if errors.Is(err, store.ErrNotFound) {
220 return repo, mr, c.fail(protocol.ExitNotFound, "MR !%d not found in %s", n, repo.Path())
221 }
222 if err != nil {
223 return repo, mr, c.fail(protocol.ExitFailure, "%v", err)
224 }
225 return repo, mr, -1
226}
227
228func mrHeadRef(n int64) string { return fmt.Sprintf("refs/merge-requests/%d/head", n) }
229
230func runMRCreate(c *Ctx, args []string) int {
231 var path, source, target, title, body, file, format string
232 for i := 0; i < len(args); i++ {
233 switch args[i] {
234 case "--source", "--target", "--title", "--body", "--file", "--format":
235 if i+1 >= len(args) {
236 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
237 }
238 v := args[i+1]
239 switch args[i] {
240 case "--source":
241 source = v
242 case "--target":
243 target = v
244 case "--title":
245 title = v
246 case "--body":
247 body = v
248 case "--file":
249 file = v
250 case "--format":
251 format = v
252 }
253 i++
254 default:
255 if path != "" {
256 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
257 }
258 path = args[i]
259 }
260 }
261 if path == "" || source == "" || title == "" {
262 return c.fail(protocol.ExitUsage, "usage: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t>")
263 }
264 fmtName, err := markupFormat(format)
265 if err != nil {
266 return c.fail(protocol.ExitUsage, "%v", err)
267 }
268 if fmtName == "" {
269 fmtName = "md"
270 }
271 repo, code := resolveRepo(c, path, policy.CanRead)
272 if code >= 0 {
273 return code
274 }
275 if code := refuseArchived(c, repo); code >= 0 {
276 return code
277 }
278 if target == "" {
279 target = repo.DefaultBranch
280 }
281
282 // Source is "branch" (same repo) or "owner/name:branch" (a fork).
283 srcRepo := repo
284 srcBranch := source
285 if sp, br, ok := strings.Cut(source, ":"); ok {
286 srcBranch = br
287 var scode int
288 srcRepo, scode = resolveRepo(c, sp, policy.CanRead)
289 if scode >= 0 {
290 return scode
291 }
292 if srcRepo.ForkOf != repo.ID && srcRepo.ID != repo.ID {
293 return c.fail(protocol.ExitUsage, "%s is not a fork of %s", srcRepo.Path(), repo.Path())
294 }
295 }
296 srcDir := RepoDir(c.Cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
297 headSHA, err := gitutil.ResolveRef(srcDir, "refs/heads/"+srcBranch)
298 if err != nil {
299 return c.fail(protocol.ExitNotFound, "branch %s not found in %s", srcBranch, srcRepo.Path())
300 }
301 b, err := bodyFrom(c, body, file)
302 if err != nil {
303 return c.fail(protocol.ExitUsage, "%v", err)
304 }
305 n, err := c.Store.CreateMR(repo.ID, c.User.ID, srcRepo.ID, srcBranch, target, title, b, headSHA, fmtName)
306 if err != nil {
307 return c.fail(protocol.ExitFailure, "%v", err)
308 }
309 // Fetch the head into the target so the target owns the objects.
310 dstDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
311 if err := gitutil.FetchInto(dstDir, srcDir, headSHA, mrHeadRef(n)); err != nil {
312 return c.fail(protocol.ExitFailure, "recording MR head: %v", err)
313 }
314 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
315 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
316 notifyUsers(c, targets, mrSubject(repo, n, title),
317 notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n)))
318 }
319 out := map[string]any{"number": n, "head_sha": headSHA}
320 var parent *stackRef
321 if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok {
322 parent = &stackRef{p.Number, p.Title}
323 out["stacked_on"] = parent
324 }
325 return c.emit(out, func(w io.Writer) {
326 fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
327 if parent != nil {
328 fmt.Fprintf(w, "stacked on !%d %s\n", parent.Number, parent.Title)
329 }
330 })
331}
332
333type mrOut struct {
334 Number int64 `json:"number"`
335 Title string `json:"title"`
336 State string `json:"state"`
337 Author string `json:"author"`
338 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
339 TargetRef string `json:"target_ref"`
340 HeadSHA string `json:"head_sha"`
341 Body string `json:"body,omitempty"`
342 BodyFormat string `json:"body_format,omitempty"`
343 Milestone string `json:"milestone,omitempty"`
344 // StackedOn is the open merge request whose source branch this one
345 // targets; Stacked are the open ones targeting this one's source.
346 StackedOn *stackRef `json:"stacked_on,omitempty"`
347 Stacked []stackRef `json:"stacked,omitempty"`
348 CreatedAt string `json:"created_at"`
349 MergedAt string `json:"merged_at,omitempty"`
350 MergedBy string `json:"merged_by,omitempty"`
351 ClosedAt string `json:"closed_at,omitempty"`
352 ClosedBy string `json:"closed_by,omitempty"`
353}
354
355type stackRef struct {
356 Number int64 `json:"number"`
357 Title string `json:"title"`
358}
359
360// stackOf derives the stack around m: the open merge request whose source
361// branch m targets, and the open ones targeting m's source. Both only
362// within m's repository; a fork's branch is not a target anything can
363// stack on.
364func stackOf(c *Ctx, repo store.Repo, m store.MR) (*stackRef, []stackRef) {
365 if m.State != "open" {
366 return nil, nil
367 }
368 var parent *stackRef
369 if p, ok, err := c.Store.OpenMRBySource(repo.ID, m.TargetRef); err == nil && ok && p.ID != m.ID {
370 parent = &stackRef{p.Number, p.Title}
371 }
372 var children []stackRef
373 if m.SourceRepoID == repo.ID {
374 if kids, err := c.Store.OpenMRsByTarget(repo.ID, m.SourceRef); err == nil {
375 for _, k := range kids {
376 if k.ID != m.ID {
377 children = append(children, stackRef{k.Number, k.Title})
378 }
379 }
380 }
381 }
382 return parent, children
383}
384
385func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
386 src := ""
387 if m.SourcePath != "" {
388 if m.SourceRepoID == repo.ID {
389 src = m.SourceRef
390 } else {
391 src = m.SourcePath + ":" + m.SourceRef
392 }
393 }
394 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
395 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
396 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
397 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy}
398 if withBody {
399 o.Body = m.Body
400 o.BodyFormat = m.BodyFormat
401 }
402 return o
403}
404
405func runMRList(c *Ctx, args []string) int {
406 args, p, code := parsePageFlags(c, args, "mr", true)
407 if code >= 0 {
408 return code
409 }
410 state := "open"
411 var path string
412 for i := 0; i < len(args); i++ {
413 switch args[i] {
414 case "--state":
415 if i+1 >= len(args) {
416 return c.fail(protocol.ExitUsage, "--state requires a value")
417 }
418 state = args[i+1]
419 i++
420 default:
421 if path != "" {
422 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
423 }
424 path = args[i]
425 }
426 }
427 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
428 if path == "" || !valid[state] {
429 return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]")
430 }
431 repo, code := resolveRepo(c, path, policy.CanRead)
432 if code >= 0 {
433 return code
434 }
435 mrs, err := c.Store.ListMRs(repo.ID, state, p.queryLimit(), p.keyInt())
436 if err != nil {
437 return c.fail(protocol.ExitFailure, "%v", err)
438 }
439 mrs, next := trimPage(p, mrs, "mr", func(m store.MR) string {
440 return strconv.FormatInt(m.Number, 10)
441 })
442 var ds []mrOut
443 for _, m := range mrs {
444 o := mrToOut(repo, m, false)
445 o.StackedOn, _ = stackOf(c, repo, m)
446 ds = append(ds, o)
447 }
448 return c.emitPage(p, ds, next, func(w io.Writer) {
449 for _, d := range ds {
450 stacked := ""
451 if d.StackedOn != nil {
452 stacked = fmt.Sprintf("\tstacked on !%d", d.StackedOn.Number)
453 }
454 fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s%s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef, stacked)
455 }
456 })
457}
458
459// byWhom renders " by <user>", or nothing when the actor is unknown — an
460// imported merge request carries a time but no local account.
461func byWhom(user string) string {
462 if user == "" {
463 return ""
464 }
465 return " by " + user
466}
467
468func runMRShow(c *Ctx, args []string) int {
469 repo, mr, code := mrRef(c, args, policy.CanRead)
470 if code >= 0 {
471 return code
472 }
473 if len(args) != 2 {
474 return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>")
475 }
476 comments, err := c.Store.ListMRComments(mr.ID)
477 if err != nil {
478 return c.fail(protocol.ExitFailure, "%v", err)
479 }
480 reviews, err := c.Store.ListMRReviews(mr.ID)
481 if err != nil {
482 return c.fail(protocol.ExitFailure, "%v", err)
483 }
484 statuses, combined, err := c.Store.ChecksForCommit(repo.ID, mr.HeadSHA)
485 if err != nil {
486 return c.fail(protocol.ExitFailure, "%v", err)
487 }
488 unresolved, err := c.Store.UnresolvedThreadCount(mr.ID)
489 if err != nil {
490 return c.fail(protocol.ExitFailure, "%v", err)
491 }
492 type commentOut struct {
493 Author string `json:"author"`
494 Body string `json:"body"`
495 BodyFormat string `json:"body_format,omitempty"`
496 CreatedAt string `json:"created_at"`
497 }
498 type reviewOut struct {
499 Reviewer string `json:"reviewer"`
500 Verdict string `json:"verdict"`
501 Stale bool `json:"stale"`
502 CreatedAt string `json:"created_at"`
503 }
504 type checkOut struct {
505 Context string `json:"context"`
506 State string `json:"state"`
507 URL string `json:"url,omitempty"`
508 UpdatedAt string `json:"updated_at"`
509 Duration string `json:"duration,omitempty"` // CI checks only, once finished
510 }
511 var checks []checkOut
512 for _, st := range statuses {
513 out := checkOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt}
514 if st.Duration > 0 {
515 out.Duration = st.Duration.String()
516 }
517 checks = append(checks, out)
518 }
519 var cs []commentOut
520 for _, cm := range comments {
521 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
522 }
523 var rs []reviewOut
524 for _, r := range reviews {
525 rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale, r.CreatedAt})
526 }
527 // The commits this MR carries: base..head, the diff's range.
528 type commitOut struct {
529 SHA string `json:"sha"`
530 Subject string `json:"subject"`
531 }
532 var commits []commitOut
533 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
534 base := mr.MergedBase
535 if base == "" {
536 if b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number)); err == nil {
537 base = b
538 }
539 }
540 if base != "" {
541 if shas, err := gitutil.RevListRange(dir, base, mrHeadRef(mr.Number)); err == nil {
542 for _, sha := range shas {
543 subject := ""
544 if raw, err := gitutil.ReadCommit(dir, sha); err == nil {
545 if parsed, err := sig.ParseCommit(raw); err == nil {
546 subject = parsed.Subject
547 }
548 }
549 commits = append(commits, commitOut{sha, subject})
550 }
551 }
552 }
553 d := struct {
554 mrOut
555 Checks []checkOut `json:"checks,omitempty"`
556 Combined string `json:"checks_combined,omitempty"`
557 UnresolvedThreads int `json:"unresolved_threads,omitempty"`
558 Commits []commitOut `json:"commits,omitempty"`
559 Comments []commentOut `json:"comments,omitempty"`
560 Reviews []reviewOut `json:"reviews,omitempty"`
561 }{mrToOut(repo, mr, true), checks, combined, unresolved, commits, cs, rs}
562 d.StackedOn, d.Stacked = stackOf(c, repo, mr)
563 return c.emit(d, func(w io.Writer) {
564 fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, d.State, d.Author, d.Source, d.TargetRef, d.HeadSHA)
565 if d.StackedOn != nil {
566 fmt.Fprintf(w, "stacked on !%d %s\n", d.StackedOn.Number, d.StackedOn.Title)
567 }
568 for _, k := range d.Stacked {
569 fmt.Fprintf(w, "stacked: !%d %s\n", k.Number, k.Title)
570 }
571 if d.MergedAt != "" {
572 fmt.Fprintf(w, "merged %s%s\n", d.MergedAt, byWhom(d.MergedBy))
573 }
574 if d.ClosedAt != "" {
575 fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy))
576 }
577 if d.Body != "" {
578 fmt.Fprintf(w, "\n%s\n", d.Body)
579 }
580 for _, cm := range commits {
581 fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject)
582 }
583 for _, x := range checks {
584 dur := ""
585 if x.Duration != "" {
586 dur = " in " + x.Duration
587 }
588 fmt.Fprintf(w, "check: %s %s at %s%s\n", x.Context, x.State, x.UpdatedAt, dur)
589 }
590 if d.UnresolvedThreads > 0 {
591 fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads)
592 }
593 for _, r := range rs {
594 stale := ""
595 if r.Stale {
596 stale = " (stale)"
597 }
598 fmt.Fprintf(w, "review: %s %s%s at %s\n", r.Reviewer, r.Verdict, stale, r.CreatedAt)
599 }
600 for _, cm := range cs {
601 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
602 }
603 })
604}
605
606func runMRDiff(c *Ctx, args []string) int {
607 repo, mr, code := mrRef(c, args, policy.CanRead)
608 if code >= 0 {
609 return code
610 }
611 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
612 head := mrHeadRef(mr.Number)
613 // After a merge (especially fast-forward) the live merge-base equals
614 // the head and the diff would vanish; use the recorded base instead.
615 base := mr.MergedBase
616 if base == "" {
617 b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
618 if err != nil {
619 return c.fail(protocol.ExitFailure, "%v", err)
620 }
621 base = b
622 }
623 patch, err := gitutil.Diff(dir, base, head, 4<<20)
624 if err != nil {
625 return c.fail(protocol.ExitFailure, "%v", err)
626 }
627 fmt.Fprint(c.Stdout, patch)
628 return protocol.ExitOK
629}
630
631func runMREdit(c *Ctx, args []string) int {
632 rest, title, body, format, code := editText(c, args, "mr")
633 if code >= 0 {
634 return code
635 }
636 repo, mr, code := mrRef(c, rest, policy.CanRead)
637 if code >= 0 {
638 return code
639 }
640 if code := refuseArchived(c, repo); code >= 0 {
641 return code
642 }
643 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
644 if err != nil {
645 return c.fail(protocol.ExitFailure, "%v", err)
646 }
647 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
648 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request")
649 }
650 if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
651 return c.fail(protocol.ExitFailure, "%v", err)
652 }
653 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
654 fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
655 })
656}
657
658// runMRRetarget moves an open merge request onto another branch of the
659// same repository.
660func runMRRetarget(c *Ctx, args []string) int {
661 if len(args) != 3 {
662 return c.fail(protocol.ExitUsage, "usage: mr retarget <owner/name> <n> <branch>")
663 }
664 repo, mr, code := mrRef(c, args[:2], policy.CanRead)
665 if code >= 0 {
666 return code
667 }
668 if code := refuseArchived(c, repo); code >= 0 {
669 return code
670 }
671 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
672 if err != nil {
673 return c.fail(protocol.ExitFailure, "%v", err)
674 }
675 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
676 return c.fail(protocol.ExitDenied, "only the author or users with write access can retarget this merge request")
677 }
678 if mr.State == "merged" || mr.State == "closed" {
679 return c.fail(protocol.ExitUsage, "!%d is %s; only an open merge request can be retargeted", mr.Number, mr.State)
680 }
681 target := args[2]
682 if target == mr.TargetRef {
683 return c.fail(protocol.ExitUsage, "!%d already targets %s", mr.Number, target)
684 }
685 if mr.SourceRepoID == repo.ID && target == mr.SourceRef {
686 return c.fail(protocol.ExitUsage, "%s is the source branch of !%d", target, mr.Number)
687 }
688 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
689 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+target); err != nil {
690 return c.fail(protocol.ExitNotFound, "branch %s not found in %s", target, repo.Path())
691 }
692 // The diff, the commit list and the merge gates all derive their base
693 // from the target on every read, so the only thing to check here is
694 // that a base exists at all: without one there is nothing to show and
695 // nothing to merge.
696 base, err := gitutil.MergeBase(dir, "refs/heads/"+target, mrHeadRef(mr.Number))
697 if err != nil || base == "" {
698 return c.fail(protocol.ExitUsage, "%s shares no history with the head of !%d", target, mr.Number)
699 }
700 old := mr.TargetRef
701 if err := c.Store.SetMRTarget(mr.ID, target); err != nil {
702 return c.fail(protocol.ExitFailure, "%v", err)
703 }
704 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
705 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
706 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
707 notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), "",
708 fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
709 }
710 return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) {
711 fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base)
712 })
713}
714
715func runMRComment(c *Ctx, args []string) int {
716 var rest []string
717 var message, file, format string
718 for i := 0; i < len(args); i++ {
719 switch args[i] {
720 case "--message", "--file", "--format":
721 if i+1 >= len(args) {
722 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
723 }
724 switch args[i] {
725 case "--message":
726 message = args[i+1]
727 case "--file":
728 file = args[i+1]
729 case "--format":
730 format = args[i+1]
731 }
732 i++
733 default:
734 rest = append(rest, args[i])
735 }
736 }
737 fmtName, err := markupFormat(format)
738 if err != nil {
739 return c.fail(protocol.ExitUsage, "%v", err)
740 }
741 if fmtName == "" {
742 fmtName = "md"
743 }
744 repo, mr, code := mrRef(c, rest, policy.CanRead)
745 if code >= 0 {
746 return code
747 }
748 if code := refuseArchived(c, repo); code >= 0 {
749 return code
750 }
751 body, err := bodyFrom(c, message, file)
752 if err != nil {
753 return c.fail(protocol.ExitUsage, "%v", err)
754 }
755 if strings.TrimSpace(body) == "" {
756 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
757 }
758 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body, fmtName); err != nil {
759 return c.fail(protocol.ExitFailure, "%v", err)
760 }
761 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number))
762 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
763 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
764 notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
765 }
766 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
767 fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
768 })
769}
770
771func runMRReview(c *Ctx, args []string) int {
772 verdict := ""
773 var rest []string
774 for _, a := range args {
775 switch a {
776 case "--approve":
777 verdict = "approve"
778 case "--request-changes":
779 verdict = "request_changes"
780 case "--comment":
781 verdict = "comment"
782 default:
783 rest = append(rest, a)
784 }
785 }
786 if verdict == "" {
787 return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
788 }
789 repo, mr, code := mrRef(c, rest, policy.CanRead)
790 if code >= 0 {
791 return code
792 }
793 if code := refuseArchived(c, repo); code >= 0 {
794 return code
795 }
796 if mr.State != "open" {
797 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
798 }
799 if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
800 return c.fail(protocol.ExitFailure, "%v", err)
801 }
802 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
803 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
804 notifyBody(c, fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
805 }
806 return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
807 fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
808 })
809}
810
811func runMRMerge(c *Ctx, args []string) int {
812 strategy := ""
813 var rest []string
814 for i := 0; i < len(args); i++ {
815 if args[i] == "--strategy" {
816 if i+1 >= len(args) {
817 return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
818 }
819 strategy = args[i+1]
820 i++
821 continue
822 }
823 rest = append(rest, args[i])
824 }
825 valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
826 if !valid[strategy] {
827 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
828 }
829 repo, mr, code := mrRef(c, rest, policy.CanWrite)
830 if code >= 0 {
831 return code
832 }
833 if code := refuseArchived(c, repo); code >= 0 {
834 return code
835 }
836 if mr.State != "open" && mr.State != "source_gone" {
837 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
838 }
839
840 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
841 targetRef := "refs/heads/" + mr.TargetRef
842 targetSHA, err := gitutil.ResolveRef(dir, targetRef)
843 if err != nil {
844 return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
845 }
846 headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
847 if err != nil {
848 return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
849 }
850
851 // Check gate: with require_checks, the MR head must carry statuses
852 // and every one of them must be green.
853 if repo.Settings.RequireChecks {
854 statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA)
855 if err != nil {
856 return c.fail(protocol.ExitFailure, "%v", err)
857 }
858 switch store.CombinedStatus(statuses) {
859 case "success":
860 case "":
861 return c.fail(protocol.ExitDenied,
862 "%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)
863 default:
864 var bad []string
865 for _, st := range statuses {
866 if st.State != "success" {
867 bad = append(bad, st.Context+"="+st.State)
868 }
869 }
870 return c.fail(protocol.ExitDenied,
871 "%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))
872 }
873 }
874
875 // Review gates: approvals, CODEOWNERS, resolved threads.
876 if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
877 return code
878 }
879
880 upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
881 if err != nil {
882 return c.fail(protocol.ExitFailure, "%v", err)
883 }
884 if upToDate {
885 return c.fail(protocol.ExitUsage, "target already contains the MR head")
886 }
887 ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
888 if err != nil {
889 return c.fail(protocol.ExitFailure, "%v", err)
890 }
891
892 // Signature policy matrix: with require_signed_commits, only
893 // fast-forward is allowed — squash, rebase-replay, and merge commits
894 // are all server-created and unsigned, violating the branch's own
895 // policy — and every landed commit must be verified. An explicit
896 // rebase when fast-forward is already possible IS a fast-forward
897 // (nothing is rewritten), so it stays legal.
898 if repo.Settings.RequireSignedCommits {
899 if strategy == "merge" || strategy == "squash" || !ffPossible {
900 return c.fail(protocol.ExitDenied,
901 "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
902 repo.Path(), mr.SourceRef, mr.TargetRef)
903 }
904 strategy = "ff"
905 commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
906 if err != nil {
907 return c.fail(protocol.ExitFailure, "%v", err)
908 }
909 for _, sha := range commits {
910 raw, err := gitutil.ReadCommit(dir, sha)
911 if err != nil {
912 return c.fail(protocol.ExitFailure, "%v", err)
913 }
914 parsed, err := sigParse(raw)
915 if err != nil {
916 return c.fail(protocol.ExitFailure, "%v", err)
917 }
918 res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
919 if err != nil {
920 return c.fail(protocol.ExitFailure, "%v", err)
921 }
922 if res.State != "verified" {
923 return c.fail(protocol.ExitDenied,
924 "%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
925 }
926 }
927 }
928 if strategy == "" {
929 if ffPossible {
930 strategy = "ff"
931 } else {
932 strategy = "merge"
933 }
934 }
935 if strategy == "rebase" && ffPossible {
936 // Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
937 // and taking it keeps the original commits and their signatures.
938 strategy = "ff"
939 }
940
941 // Every server-created commit needs the merger's verified identity.
942 mergerEmail := ""
943 if strategy != "ff" {
944 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
945 if err != nil {
946 return c.fail(protocol.ExitFailure, "%v", err)
947 }
948 if email == "" {
949 return c.fail(protocol.ExitDenied,
950 "%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
951 }
952 mergerEmail = email
953 }
954
955 var newSHA string
956 switch strategy {
957 case "ff":
958 if !ffPossible {
959 return c.fail(protocol.ExitUsage,
960 "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
961 }
962 newSHA = headSHA
963
964 case "merge":
965 tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
966 if err != nil {
967 return c.fail(protocol.ExitFailure, "%v", err)
968 }
969 if conflict {
970 return c.fail(protocol.ExitUsage,
971 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
972 }
973 msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
974 newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
975 if err != nil {
976 return c.fail(protocol.ExitFailure, "%v", err)
977 }
978
979 case "squash":
980 // One new commit with the merged tree. Authorship credit goes to
981 // the MR author (their verified identity when they have one); the
982 // committer is the merger.
983 tree := ""
984 if ffPossible {
985 t, err := gitutil.ResolveTree(dir, headSHA)
986 if err != nil {
987 return c.fail(protocol.ExitFailure, "%v", err)
988 }
989 tree = t
990 } else {
991 t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
992 if err != nil {
993 return c.fail(protocol.ExitFailure, "%v", err)
994 }
995 if conflict {
996 return c.fail(protocol.ExitUsage,
997 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
998 }
999 tree = t
1000 }
1001 authorName, authorEmail := c.User.Username, mergerEmail
1002 if author, err := c.Store.UserByUsername(mr.Author); err == nil {
1003 if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
1004 authorName, authorEmail = author.Username, ae
1005 }
1006 }
1007 msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
1008 if mr.Body != "" {
1009 msg += "\n\n" + mr.Body
1010 }
1011 var err error
1012 newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
1013 authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
1014 if err != nil {
1015 return c.fail(protocol.ExitFailure, "%v", err)
1016 }
1017
1018 case "rebase":
1019 commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
1020 if err != nil {
1021 return c.fail(protocol.ExitFailure, "%v", err)
1022 }
1023 // Oldest first.
1024 for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
1025 commits[i], commits[j] = commits[j], commits[i]
1026 }
1027 onto := targetSHA
1028 for _, sha := range commits {
1029 parents, err := gitutil.CommitParents(dir, sha)
1030 if err != nil {
1031 return c.fail(protocol.ExitFailure, "%v", err)
1032 }
1033 if len(parents) > 1 {
1034 return c.fail(protocol.ExitUsage,
1035 "the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
1036 }
1037 base := onto // root commit: replay against the new tip itself
1038 if len(parents) == 1 {
1039 base = parents[0]
1040 }
1041 tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
1042 if err != nil {
1043 return c.fail(protocol.ExitFailure, "%v", err)
1044 }
1045 if conflict {
1046 return c.fail(protocol.ExitUsage,
1047 "commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
1048 }
1049 aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
1050 if err != nil {
1051 return c.fail(protocol.ExitFailure, "%v", err)
1052 }
1053 msg, err := gitutil.CommitMessage(dir, sha)
1054 if err != nil {
1055 return c.fail(protocol.ExitFailure, "%v", err)
1056 }
1057 onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
1058 aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
1059 if err != nil {
1060 return c.fail(protocol.ExitFailure, "%v", err)
1061 }
1062 }
1063 newSHA = onto
1064 }
1065
1066 // A stacked merge request's diff is against this branch. After a
1067 // fast-forward or merge commit the same commits are on the target and
1068 // its diff is unchanged there; after a squash or rebase they are not,
1069 // and the stack would carry this merge request's changes a second
1070 // time. Refuse rather than leave the stack wrong.
1071 var stack []store.MR
1072 if mr.SourceRepoID == repo.ID {
1073 stack, _ = c.Store.OpenMRsByTarget(repo.ID, mr.SourceRef)
1074 }
1075 if len(stack) > 0 && (strategy == "squash" || strategy == "rebase") {
1076 var nums []string
1077 for _, k := range stack {
1078 nums = append(nums, fmt.Sprintf("!%d", k.Number))
1079 }
1080 return c.fail(protocol.ExitUsage,
1081 "%s is stacked on by %s; a %s merge rewrites the commits they build on. Merge with --strategy ff or merge, or merge the stack into %s first",
1082 fmt.Sprintf("!%d", mr.Number), strings.Join(nums, ", "), strategy, mr.SourceRef)
1083 }
1084
1085 // CAS so a concurrent push between our read and this write fails the
1086 // merge instead of silently discarding the push.
1087 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
1088 return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
1089 }
1090 if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil {
1091 return c.fail(protocol.ExitFailure, "%v", err)
1092 }
1093 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
1094 // The stack moves up: whatever targeted this branch now targets what
1095 // it merged into, reviews intact, since that diff is the one they
1096 // were of.
1097 for _, k := range stack {
1098 if err := c.Store.RetargetKeepingReviews(k.ID, mr.TargetRef); err != nil {
1099 continue
1100 }
1101 c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number))
1102 if parts, err := c.Store.MRParticipants(k.ID); err == nil {
1103 notifyUsers(c, parts, mrSubject(repo, k.Number, k.Title),
1104 notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number), "",
1105 fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)))
1106 }
1107 }
1108 // Merges bypass receive-pack, so the commit-message issue actions
1109 // (closes #N, references) run here for the newly landed commits. The
1110 // description is scanned after them, so a commit wins the attribution
1111 // when both name the same issue.
1112 if mr.TargetRef == repo.DefaultBranch {
1113 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
1114 ProcessMRDescription(c.Store, repo, mr, c.User.ID)
1115 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
1116 }
1117 // A merge moves the ref directly, so it never reaches post-receive and
1118 // none of the ref-update work fires on its own. The event webhooks
1119 // subscribe to, and the branch's CI jobs, happen here instead.
1120 c.Store.RecordEvent(repo.ID, c.User.ID, "push", fmt.Sprintf(
1121 `{"ref":%q,"old":%q,"new":%q,"forced":false,"deleted":false}`,
1122 targetRef, targetSHA, newSHA))
1123 QueueBranchBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL,
1124 repo, c.User.ID, mr.TargetRef, newSHA, time.Now())
1125 c.Store.MarkMirrorsDirty(repo.ID, "push")
1126 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1127 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
1128 notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
1129 }
1130 return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
1131 fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
1132 })
1133}
1134
1135// reviewGates enforces require_approvals (fresh, non-author, latest review
1136// per reviewer; a fresh request-changes blocks), CODEOWNERS coverage, and
1137// require_resolved. Returns -1 to proceed.
1138func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
1139 set := repo.Settings
1140 if set.RequireApprovals == 0 && !set.RequireResolved {
1141 return -1
1142 }
1143
1144 if set.RequireApprovals > 0 {
1145 reviews, err := c.Store.ListMRReviews(mr.ID)
1146 if err != nil {
1147 return c.fail(protocol.ExitFailure, "%v", err)
1148 }
1149 // Latest fresh review per reviewer decides their stance.
1150 latest := map[string]string{}
1151 for _, r := range reviews {
1152 if r.Stale || r.Reviewer == mr.Author {
1153 continue
1154 }
1155 latest[r.Reviewer] = r.Verdict
1156 }
1157 var approvers []string
1158 var blockers []string
1159 for who, verdict := range latest {
1160 switch verdict {
1161 case "approve":
1162 approvers = append(approvers, who)
1163 case "request_changes":
1164 blockers = append(blockers, who)
1165 }
1166 }
1167 if len(blockers) > 0 {
1168 slices.Sort(blockers)
1169 return c.fail(protocol.ExitDenied,
1170 "%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number)
1171 }
1172 if len(approvers) < set.RequireApprovals {
1173 return c.fail(protocol.ExitDenied,
1174 "%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers))
1175 }
1176
1177 // CODEOWNERS: every owned changed file needs an approval from one
1178 // of its owners.
1179 content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20)
1180 if err != nil {
1181 content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
1182 }
1183 if err == nil && len(content) > 0 {
1184 rules := policy.ParseCodeowners(string(content))
1185 base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
1186 if err != nil {
1187 return c.fail(protocol.ExitFailure, "%v", err)
1188 }
1189 files, err := gitutil.DiffFiles(dir, base, headSHA)
1190 if err != nil {
1191 return c.fail(protocol.ExitFailure, "%v", err)
1192 }
1193 approved := map[string]bool{}
1194 for _, a := range approvers {
1195 approved[a] = true
1196 }
1197 missing := map[string][]string{} // owner-set key -> example paths
1198 for _, f := range files {
1199 owners := policy.OwnersFor(rules, f)
1200 if owners == nil {
1201 continue
1202 }
1203 ok := false
1204 for _, o := range owners {
1205 if approved[o] {
1206 ok = true
1207 break
1208 }
1209 }
1210 if !ok {
1211 key := strings.Join(owners, ",")
1212 if len(missing[key]) < 3 {
1213 missing[key] = append(missing[key], f)
1214 }
1215 }
1216 }
1217 if len(missing) > 0 {
1218 var parts []string
1219 for owners, paths := range missing {
1220 parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners))
1221 }
1222 slices.Sort(parts)
1223 return c.fail(protocol.ExitDenied,
1224 "CODEOWNERS approval missing for: %s", strings.Join(parts, "; "))
1225 }
1226 }
1227 }
1228
1229 if set.RequireResolved {
1230 n, err := c.Store.UnresolvedThreadCount(mr.ID)
1231 if err != nil {
1232 return c.fail(protocol.ExitFailure, "%v", err)
1233 }
1234 if n > 0 {
1235 return c.fail(protocol.ExitDenied,
1236 "%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number)
1237 }
1238 }
1239 return -1
1240}
1241
1242func runMRClose(c *Ctx, args []string) int {
1243 repo, mr, code := mrRef(c, args, policy.CanRead)
1244 if code >= 0 {
1245 return code
1246 }
1247 if code := refuseArchived(c, repo); code >= 0 {
1248 return code
1249 }
1250 if len(args) != 2 {
1251 return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
1252 }
1253 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1254 if err != nil {
1255 return c.fail(protocol.ExitFailure, "%v", err)
1256 }
1257 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
1258 return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
1259 }
1260 if mr.State == "merged" || mr.State == "closed" {
1261 return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
1262 }
1263 if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil {
1264 return c.fail(protocol.ExitFailure, "%v", err)
1265 }
1266 return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
1267 fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
1268 })
1269}