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