internal/control/mr.go
1180 lines · 40296 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 return c.emit(map[string]any{"number": n, "head_sha": headSHA}, func(w io.Writer) {
320 fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target)
321 })
322}
323
324type mrOut struct {
325 Number int64 `json:"number"`
326 Title string `json:"title"`
327 State string `json:"state"`
328 Author string `json:"author"`
329 Source string `json:"source"` // owner/name:branch, or branch, "" if gone
330 TargetRef string `json:"target_ref"`
331 HeadSHA string `json:"head_sha"`
332 Body string `json:"body,omitempty"`
333 BodyFormat string `json:"body_format,omitempty"`
334 Milestone string `json:"milestone,omitempty"`
335 CreatedAt string `json:"created_at"`
336 MergedAt string `json:"merged_at,omitempty"`
337 MergedBy string `json:"merged_by,omitempty"`
338 ClosedAt string `json:"closed_at,omitempty"`
339 ClosedBy string `json:"closed_by,omitempty"`
340}
341
342func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
343 src := ""
344 if m.SourcePath != "" {
345 if m.SourceRepoID == repo.ID {
346 src = m.SourceRef
347 } else {
348 src = m.SourcePath + ":" + m.SourceRef
349 }
350 }
351 o := mrOut{Number: m.Number, Title: m.Title, State: m.State, Author: m.Author,
352 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
353 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
354 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy}
355 if withBody {
356 o.Body = m.Body
357 o.BodyFormat = m.BodyFormat
358 }
359 return o
360}
361
362func runMRList(c *Ctx, args []string) int {
363 args, p, code := parsePageFlags(c, args, "mr", true)
364 if code >= 0 {
365 return code
366 }
367 state := "open"
368 var path string
369 for i := 0; i < len(args); i++ {
370 switch args[i] {
371 case "--state":
372 if i+1 >= len(args) {
373 return c.fail(protocol.ExitUsage, "--state requires a value")
374 }
375 state = args[i+1]
376 i++
377 default:
378 if path != "" {
379 return c.fail(protocol.ExitUsage, "unexpected argument %q", args[i])
380 }
381 path = args[i]
382 }
383 }
384 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
385 if path == "" || !valid[state] {
386 return c.fail(protocol.ExitUsage, "usage: mr list <owner/name> [--state open|merged|closed|source_gone|all] [--limit <n>] [--cursor <c>]")
387 }
388 repo, code := resolveRepo(c, path, policy.CanRead)
389 if code >= 0 {
390 return code
391 }
392 mrs, err := c.Store.ListMRs(repo.ID, state, p.queryLimit(), p.keyInt())
393 if err != nil {
394 return c.fail(protocol.ExitFailure, "%v", err)
395 }
396 mrs, next := trimPage(p, mrs, "mr", func(m store.MR) string {
397 return strconv.FormatInt(m.Number, 10)
398 })
399 var ds []mrOut
400 for _, m := range mrs {
401 ds = append(ds, mrToOut(repo, m, false))
402 }
403 return c.emitPage(p, ds, next, func(w io.Writer) {
404 for _, d := range ds {
405 fmt.Fprintf(w, "!%d\t%s\t%s\t%s -> %s\n", d.Number, d.State, d.Title, d.Source, d.TargetRef)
406 }
407 })
408}
409
410// byWhom renders " by <user>", or nothing when the actor is unknown — an
411// imported merge request carries a time but no local account.
412func byWhom(user string) string {
413 if user == "" {
414 return ""
415 }
416 return " by " + user
417}
418
419func runMRShow(c *Ctx, args []string) int {
420 repo, mr, code := mrRef(c, args, policy.CanRead)
421 if code >= 0 {
422 return code
423 }
424 if len(args) != 2 {
425 return c.fail(protocol.ExitUsage, "usage: mr show <owner/name> <n>")
426 }
427 comments, err := c.Store.ListMRComments(mr.ID)
428 if err != nil {
429 return c.fail(protocol.ExitFailure, "%v", err)
430 }
431 reviews, err := c.Store.ListMRReviews(mr.ID)
432 if err != nil {
433 return c.fail(protocol.ExitFailure, "%v", err)
434 }
435 statuses, combined, err := c.Store.ChecksForCommit(repo.ID, mr.HeadSHA)
436 if err != nil {
437 return c.fail(protocol.ExitFailure, "%v", err)
438 }
439 unresolved, err := c.Store.UnresolvedThreadCount(mr.ID)
440 if err != nil {
441 return c.fail(protocol.ExitFailure, "%v", err)
442 }
443 type commentOut struct {
444 Author string `json:"author"`
445 Body string `json:"body"`
446 BodyFormat string `json:"body_format,omitempty"`
447 CreatedAt string `json:"created_at"`
448 }
449 type reviewOut struct {
450 Reviewer string `json:"reviewer"`
451 Verdict string `json:"verdict"`
452 Stale bool `json:"stale"`
453 CreatedAt string `json:"created_at"`
454 }
455 type checkOut struct {
456 Context string `json:"context"`
457 State string `json:"state"`
458 URL string `json:"url,omitempty"`
459 UpdatedAt string `json:"updated_at"`
460 Duration string `json:"duration,omitempty"` // CI checks only, once finished
461 }
462 var checks []checkOut
463 for _, st := range statuses {
464 out := checkOut{Context: st.Context, State: st.State, URL: st.TargetURL, UpdatedAt: st.UpdatedAt}
465 if st.Duration > 0 {
466 out.Duration = st.Duration.String()
467 }
468 checks = append(checks, out)
469 }
470 var cs []commentOut
471 for _, cm := range comments {
472 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt})
473 }
474 var rs []reviewOut
475 for _, r := range reviews {
476 rs = append(rs, reviewOut{r.Reviewer, r.Verdict, r.Stale, r.CreatedAt})
477 }
478 // The commits this MR carries: base..head, the diff's range.
479 type commitOut struct {
480 SHA string `json:"sha"`
481 Subject string `json:"subject"`
482 }
483 var commits []commitOut
484 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
485 base := mr.MergedBase
486 if base == "" {
487 if b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mrHeadRef(mr.Number)); err == nil {
488 base = b
489 }
490 }
491 if base != "" {
492 if shas, err := gitutil.RevListRange(dir, base, mrHeadRef(mr.Number)); err == nil {
493 for _, sha := range shas {
494 subject := ""
495 if raw, err := gitutil.ReadCommit(dir, sha); err == nil {
496 if parsed, err := sig.ParseCommit(raw); err == nil {
497 subject = parsed.Subject
498 }
499 }
500 commits = append(commits, commitOut{sha, subject})
501 }
502 }
503 }
504 d := struct {
505 mrOut
506 Checks []checkOut `json:"checks,omitempty"`
507 Combined string `json:"checks_combined,omitempty"`
508 UnresolvedThreads int `json:"unresolved_threads,omitempty"`
509 Commits []commitOut `json:"commits,omitempty"`
510 Comments []commentOut `json:"comments,omitempty"`
511 Reviews []reviewOut `json:"reviews,omitempty"`
512 }{mrToOut(repo, mr, true), checks, combined, unresolved, commits, cs, rs}
513 return c.emit(d, func(w io.Writer) {
514 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)
515 if d.MergedAt != "" {
516 fmt.Fprintf(w, "merged %s%s\n", d.MergedAt, byWhom(d.MergedBy))
517 }
518 if d.ClosedAt != "" {
519 fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy))
520 }
521 if d.Body != "" {
522 fmt.Fprintf(w, "\n%s\n", d.Body)
523 }
524 for _, cm := range commits {
525 fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject)
526 }
527 for _, x := range checks {
528 dur := ""
529 if x.Duration != "" {
530 dur = " in " + x.Duration
531 }
532 fmt.Fprintf(w, "check: %s %s at %s%s\n", x.Context, x.State, x.UpdatedAt, dur)
533 }
534 if d.UnresolvedThreads > 0 {
535 fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads)
536 }
537 for _, r := range rs {
538 stale := ""
539 if r.Stale {
540 stale = " (stale)"
541 }
542 fmt.Fprintf(w, "review: %s %s%s at %s\n", r.Reviewer, r.Verdict, stale, r.CreatedAt)
543 }
544 for _, cm := range cs {
545 fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body)
546 }
547 })
548}
549
550func runMRDiff(c *Ctx, args []string) int {
551 repo, mr, code := mrRef(c, args, policy.CanRead)
552 if code >= 0 {
553 return code
554 }
555 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
556 head := mrHeadRef(mr.Number)
557 // After a merge (especially fast-forward) the live merge-base equals
558 // the head and the diff would vanish; use the recorded base instead.
559 base := mr.MergedBase
560 if base == "" {
561 b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
562 if err != nil {
563 return c.fail(protocol.ExitFailure, "%v", err)
564 }
565 base = b
566 }
567 patch, err := gitutil.Diff(dir, base, head, 4<<20)
568 if err != nil {
569 return c.fail(protocol.ExitFailure, "%v", err)
570 }
571 fmt.Fprint(c.Stdout, patch)
572 return protocol.ExitOK
573}
574
575func runMREdit(c *Ctx, args []string) int {
576 rest, title, body, format, code := editText(c, args, "mr")
577 if code >= 0 {
578 return code
579 }
580 repo, mr, code := mrRef(c, rest, policy.CanRead)
581 if code >= 0 {
582 return code
583 }
584 if code := refuseArchived(c, repo); code >= 0 {
585 return code
586 }
587 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
588 if err != nil {
589 return c.fail(protocol.ExitFailure, "%v", err)
590 }
591 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
592 return c.fail(protocol.ExitDenied, "only the author or users with write access can edit this merge request")
593 }
594 if err := c.Store.UpdateMRText(mr.ID, title, body, format); err != nil {
595 return c.fail(protocol.ExitFailure, "%v", err)
596 }
597 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
598 fmt.Fprintf(w, "edited %s!%d\n", repo.Path(), mr.Number)
599 })
600}
601
602// runMRRetarget moves an open merge request onto another branch of the
603// same repository.
604func runMRRetarget(c *Ctx, args []string) int {
605 if len(args) != 3 {
606 return c.fail(protocol.ExitUsage, "usage: mr retarget <owner/name> <n> <branch>")
607 }
608 repo, mr, code := mrRef(c, args[:2], policy.CanRead)
609 if code >= 0 {
610 return code
611 }
612 if code := refuseArchived(c, repo); code >= 0 {
613 return code
614 }
615 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
616 if err != nil {
617 return c.fail(protocol.ExitFailure, "%v", err)
618 }
619 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
620 return c.fail(protocol.ExitDenied, "only the author or users with write access can retarget this merge request")
621 }
622 if mr.State == "merged" || mr.State == "closed" {
623 return c.fail(protocol.ExitUsage, "!%d is %s; only an open merge request can be retargeted", mr.Number, mr.State)
624 }
625 target := args[2]
626 if target == mr.TargetRef {
627 return c.fail(protocol.ExitUsage, "!%d already targets %s", mr.Number, target)
628 }
629 if mr.SourceRepoID == repo.ID && target == mr.SourceRef {
630 return c.fail(protocol.ExitUsage, "%s is the source branch of !%d", target, mr.Number)
631 }
632 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
633 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+target); err != nil {
634 return c.fail(protocol.ExitNotFound, "branch %s not found in %s", target, repo.Path())
635 }
636 // The diff, the commit list and the merge gates all derive their base
637 // from the target on every read, so the only thing to check here is
638 // that a base exists at all: without one there is nothing to show and
639 // nothing to merge.
640 base, err := gitutil.MergeBase(dir, "refs/heads/"+target, mrHeadRef(mr.Number))
641 if err != nil || base == "" {
642 return c.fail(protocol.ExitUsage, "%s shares no history with the head of !%d", target, mr.Number)
643 }
644 old := mr.TargetRef
645 if err := c.Store.SetMRTarget(mr.ID, target); err != nil {
646 return c.fail(protocol.ExitFailure, "%v", err)
647 }
648 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
649 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
650 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
651 notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), "",
652 fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
653 }
654 return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) {
655 fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base)
656 })
657}
658
659func runMRComment(c *Ctx, args []string) int {
660 var rest []string
661 var message, file, format string
662 for i := 0; i < len(args); i++ {
663 switch args[i] {
664 case "--message", "--file", "--format":
665 if i+1 >= len(args) {
666 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
667 }
668 switch args[i] {
669 case "--message":
670 message = args[i+1]
671 case "--file":
672 file = args[i+1]
673 case "--format":
674 format = args[i+1]
675 }
676 i++
677 default:
678 rest = append(rest, args[i])
679 }
680 }
681 fmtName, err := markupFormat(format)
682 if err != nil {
683 return c.fail(protocol.ExitUsage, "%v", err)
684 }
685 if fmtName == "" {
686 fmtName = "md"
687 }
688 repo, mr, code := mrRef(c, rest, policy.CanRead)
689 if code >= 0 {
690 return code
691 }
692 if code := refuseArchived(c, repo); code >= 0 {
693 return code
694 }
695 body, err := bodyFrom(c, message, file)
696 if err != nil {
697 return c.fail(protocol.ExitUsage, "%v", err)
698 }
699 if strings.TrimSpace(body) == "" {
700 return c.fail(protocol.ExitUsage, "empty comment; use --message or --file -")
701 }
702 if err := c.Store.AddMRComment(mr.ID, c.User.ID, body, fmtName); err != nil {
703 return c.fail(protocol.ExitFailure, "%v", err)
704 }
705 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, mr.Number))
706 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
707 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
708 notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
709 }
710 return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) {
711 fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number)
712 })
713}
714
715func runMRReview(c *Ctx, args []string) int {
716 verdict := ""
717 var rest []string
718 for _, a := range args {
719 switch a {
720 case "--approve":
721 verdict = "approve"
722 case "--request-changes":
723 verdict = "request_changes"
724 case "--comment":
725 verdict = "comment"
726 default:
727 rest = append(rest, a)
728 }
729 }
730 if verdict == "" {
731 return c.fail(protocol.ExitUsage, "usage: mr review <owner/name> <n> --approve|--request-changes|--comment")
732 }
733 repo, mr, code := mrRef(c, rest, policy.CanRead)
734 if code >= 0 {
735 return code
736 }
737 if code := refuseArchived(c, repo); code >= 0 {
738 return code
739 }
740 if mr.State != "open" {
741 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
742 }
743 if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil {
744 return c.fail(protocol.ExitFailure, "%v", err)
745 }
746 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
747 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
748 notifyBody(c, fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
749 }
750 return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
751 fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
752 })
753}
754
755func runMRMerge(c *Ctx, args []string) int {
756 strategy := ""
757 var rest []string
758 for i := 0; i < len(args); i++ {
759 if args[i] == "--strategy" {
760 if i+1 >= len(args) {
761 return c.fail(protocol.ExitUsage, "--strategy requires ff|merge|squash|rebase")
762 }
763 strategy = args[i+1]
764 i++
765 continue
766 }
767 rest = append(rest, args[i])
768 }
769 valid := map[string]bool{"": true, "ff": true, "merge": true, "squash": true, "rebase": true}
770 if !valid[strategy] {
771 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
772 }
773 repo, mr, code := mrRef(c, rest, policy.CanWrite)
774 if code >= 0 {
775 return code
776 }
777 if code := refuseArchived(c, repo); code >= 0 {
778 return code
779 }
780 if mr.State != "open" && mr.State != "source_gone" {
781 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
782 }
783
784 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
785 targetRef := "refs/heads/" + mr.TargetRef
786 targetSHA, err := gitutil.ResolveRef(dir, targetRef)
787 if err != nil {
788 return c.fail(protocol.ExitFailure, "target branch %s: %v", mr.TargetRef, err)
789 }
790 headSHA, err := gitutil.ResolveRef(dir, mrHeadRef(mr.Number))
791 if err != nil {
792 return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
793 }
794
795 // Check gate: with require_checks, the MR head must carry statuses
796 // and every one of them must be green.
797 if repo.Settings.RequireChecks {
798 statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA)
799 if err != nil {
800 return c.fail(protocol.ExitFailure, "%v", err)
801 }
802 switch store.CombinedStatus(statuses) {
803 case "success":
804 case "":
805 return c.fail(protocol.ExitDenied,
806 "%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)
807 default:
808 var bad []string
809 for _, st := range statuses {
810 if st.State != "success" {
811 bad = append(bad, st.Context+"="+st.State)
812 }
813 }
814 return c.fail(protocol.ExitDenied,
815 "%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))
816 }
817 }
818
819 // Review gates: approvals, CODEOWNERS, resolved threads.
820 if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
821 return code
822 }
823
824 upToDate, err := gitutil.IsAncestor(dir, headSHA, targetSHA)
825 if err != nil {
826 return c.fail(protocol.ExitFailure, "%v", err)
827 }
828 if upToDate {
829 return c.fail(protocol.ExitUsage, "target already contains the MR head")
830 }
831 ffPossible, err := gitutil.IsAncestor(dir, targetSHA, headSHA)
832 if err != nil {
833 return c.fail(protocol.ExitFailure, "%v", err)
834 }
835
836 // Signature policy matrix: with require_signed_commits, only
837 // fast-forward is allowed — squash, rebase-replay, and merge commits
838 // are all server-created and unsigned, violating the branch's own
839 // policy — and every landed commit must be verified. An explicit
840 // rebase when fast-forward is already possible IS a fast-forward
841 // (nothing is rewritten), so it stays legal.
842 if repo.Settings.RequireSignedCommits {
843 if strategy == "merge" || strategy == "squash" || !ffPossible {
844 return c.fail(protocol.ExitDenied,
845 "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
846 repo.Path(), mr.SourceRef, mr.TargetRef)
847 }
848 strategy = "ff"
849 commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
850 if err != nil {
851 return c.fail(protocol.ExitFailure, "%v", err)
852 }
853 for _, sha := range commits {
854 raw, err := gitutil.ReadCommit(dir, sha)
855 if err != nil {
856 return c.fail(protocol.ExitFailure, "%v", err)
857 }
858 parsed, err := sigParse(raw)
859 if err != nil {
860 return c.fail(protocol.ExitFailure, "%v", err)
861 }
862 res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
863 if err != nil {
864 return c.fail(protocol.ExitFailure, "%v", err)
865 }
866 if res.State != "verified" {
867 return c.fail(protocol.ExitDenied,
868 "%s requires signed commits: %.10s is %s", repo.Path(), sha, res.State)
869 }
870 }
871 }
872 if strategy == "" {
873 if ffPossible {
874 strategy = "ff"
875 } else {
876 strategy = "merge"
877 }
878 }
879 if strategy == "rebase" && ffPossible {
880 // Nothing to rewrite: a rebase onto an ancestor is a fast-forward,
881 // and taking it keeps the original commits and their signatures.
882 strategy = "ff"
883 }
884
885 // Every server-created commit needs the merger's verified identity.
886 mergerEmail := ""
887 if strategy != "ff" {
888 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
889 if err != nil {
890 return c.fail(protocol.ExitFailure, "%v", err)
891 }
892 if email == "" {
893 return c.fail(protocol.ExitDenied,
894 "%s merges create commits carrying your identity: verify a primary email first (or use a fast-forward merge)", strategy)
895 }
896 mergerEmail = email
897 }
898
899 var newSHA string
900 switch strategy {
901 case "ff":
902 if !ffPossible {
903 return c.fail(protocol.ExitUsage,
904 "fast-forward not possible: %s has diverged from the MR head; use --strategy merge or rebase and re-push", mr.TargetRef)
905 }
906 newSHA = headSHA
907
908 case "merge":
909 tree, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
910 if err != nil {
911 return c.fail(protocol.ExitFailure, "%v", err)
912 }
913 if conflict {
914 return c.fail(protocol.ExitUsage,
915 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
916 }
917 msg := fmt.Sprintf("Merge request !%d: %s\n\nMerged %s into %s", mr.Number, mr.Title, mr.SourceRef, mr.TargetRef)
918 newSHA, err = gitutil.CommitTree(dir, tree, []string{targetSHA, headSHA}, c.User.Username, mergerEmail, msg)
919 if err != nil {
920 return c.fail(protocol.ExitFailure, "%v", err)
921 }
922
923 case "squash":
924 // One new commit with the merged tree. Authorship credit goes to
925 // the MR author (their verified identity when they have one); the
926 // committer is the merger.
927 tree := ""
928 if ffPossible {
929 t, err := gitutil.ResolveTree(dir, headSHA)
930 if err != nil {
931 return c.fail(protocol.ExitFailure, "%v", err)
932 }
933 tree = t
934 } else {
935 t, conflict, err := gitutil.MergeTree(dir, targetSHA, headSHA)
936 if err != nil {
937 return c.fail(protocol.ExitFailure, "%v", err)
938 }
939 if conflict {
940 return c.fail(protocol.ExitUsage,
941 "merge conflicts between %s and the MR head; resolve locally and re-push", mr.TargetRef)
942 }
943 tree = t
944 }
945 authorName, authorEmail := c.User.Username, mergerEmail
946 if author, err := c.Store.UserByUsername(mr.Author); err == nil {
947 if ae, err := c.Store.PrimaryVerifiedEmail(author.ID); err == nil && ae != "" {
948 authorName, authorEmail = author.Username, ae
949 }
950 }
951 msg := fmt.Sprintf("%s (!%d)", mr.Title, mr.Number)
952 if mr.Body != "" {
953 msg += "\n\n" + mr.Body
954 }
955 var err error
956 newSHA, err = gitutil.CommitTreeIdent(dir, tree, []string{targetSHA},
957 authorName, authorEmail, "", c.User.Username, mergerEmail, msg)
958 if err != nil {
959 return c.fail(protocol.ExitFailure, "%v", err)
960 }
961
962 case "rebase":
963 commits, err := gitutil.RevListRange(dir, targetSHA, headSHA)
964 if err != nil {
965 return c.fail(protocol.ExitFailure, "%v", err)
966 }
967 // Oldest first.
968 for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
969 commits[i], commits[j] = commits[j], commits[i]
970 }
971 onto := targetSHA
972 for _, sha := range commits {
973 parents, err := gitutil.CommitParents(dir, sha)
974 if err != nil {
975 return c.fail(protocol.ExitFailure, "%v", err)
976 }
977 if len(parents) > 1 {
978 return c.fail(protocol.ExitUsage,
979 "the MR contains merge commit %.10s; a rebase merge needs linear history — use --strategy merge or squash", sha)
980 }
981 base := onto // root commit: replay against the new tip itself
982 if len(parents) == 1 {
983 base = parents[0]
984 }
985 tree, conflict, err := gitutil.MergeTreeOnto(dir, base, onto, sha)
986 if err != nil {
987 return c.fail(protocol.ExitFailure, "%v", err)
988 }
989 if conflict {
990 return c.fail(protocol.ExitUsage,
991 "commit %.10s does not apply cleanly onto %s; rebase locally and re-push", sha, mr.TargetRef)
992 }
993 aName, aEmail, aDate, err := gitutil.AuthorIdent(dir, sha)
994 if err != nil {
995 return c.fail(protocol.ExitFailure, "%v", err)
996 }
997 msg, err := gitutil.CommitMessage(dir, sha)
998 if err != nil {
999 return c.fail(protocol.ExitFailure, "%v", err)
1000 }
1001 onto, err = gitutil.CommitTreeIdent(dir, tree, []string{onto},
1002 aName, aEmail, aDate, c.User.Username, mergerEmail, msg)
1003 if err != nil {
1004 return c.fail(protocol.ExitFailure, "%v", err)
1005 }
1006 }
1007 newSHA = onto
1008 }
1009
1010 // CAS so a concurrent push between our read and this write fails the
1011 // merge instead of silently discarding the push.
1012 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
1013 return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
1014 }
1015 if err := c.Store.MarkMerged(mr.ID, targetSHA, c.User.ID, ""); err != nil {
1016 return c.fail(protocol.ExitFailure, "%v", err)
1017 }
1018 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
1019 // Merges bypass receive-pack, so the commit-message issue actions
1020 // (closes #N, references) run here for the newly landed commits. The
1021 // description is scanned after them, so a commit wins the attribution
1022 // when both name the same issue.
1023 if mr.TargetRef == repo.DefaultBranch {
1024 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, targetSHA, newSHA)
1025 ProcessMRDescription(c.Store, repo, mr, c.User.ID)
1026 RecordLandedCommits(c.Store, dir, repo, targetSHA, newSHA)
1027 }
1028 // A merge moves the ref directly, so it never reaches post-receive and
1029 // none of the ref-update work fires on its own. The event webhooks
1030 // subscribe to, and the branch's CI jobs, happen here instead.
1031 c.Store.RecordEvent(repo.ID, c.User.ID, "push", fmt.Sprintf(
1032 `{"ref":%q,"old":%q,"new":%q,"forced":false,"deleted":false}`,
1033 targetRef, targetSHA, newSHA))
1034 QueueBranchBuilds(c.Store, c.Cfg.Server.Root, c.Cfg.Server.SiteURL,
1035 repo, c.User.ID, mr.TargetRef, newSHA, time.Now())
1036 c.Store.MarkMirrorsDirty(repo.ID, "push")
1037 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1038 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title),
1039 notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)))
1040 }
1041 return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
1042 fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
1043 })
1044}
1045
1046// reviewGates enforces require_approvals (fresh, non-author, latest review
1047// per reviewer; a fresh request-changes blocks), CODEOWNERS coverage, and
1048// require_resolved. Returns -1 to proceed.
1049func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
1050 set := repo.Settings
1051 if set.RequireApprovals == 0 && !set.RequireResolved {
1052 return -1
1053 }
1054
1055 if set.RequireApprovals > 0 {
1056 reviews, err := c.Store.ListMRReviews(mr.ID)
1057 if err != nil {
1058 return c.fail(protocol.ExitFailure, "%v", err)
1059 }
1060 // Latest fresh review per reviewer decides their stance.
1061 latest := map[string]string{}
1062 for _, r := range reviews {
1063 if r.Stale || r.Reviewer == mr.Author {
1064 continue
1065 }
1066 latest[r.Reviewer] = r.Verdict
1067 }
1068 var approvers []string
1069 var blockers []string
1070 for who, verdict := range latest {
1071 switch verdict {
1072 case "approve":
1073 approvers = append(approvers, who)
1074 case "request_changes":
1075 blockers = append(blockers, who)
1076 }
1077 }
1078 if len(blockers) > 0 {
1079 slices.Sort(blockers)
1080 return c.fail(protocol.ExitDenied,
1081 "%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number)
1082 }
1083 if len(approvers) < set.RequireApprovals {
1084 return c.fail(protocol.ExitDenied,
1085 "%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers))
1086 }
1087
1088 // CODEOWNERS: every owned changed file needs an approval from one
1089 // of its owners.
1090 content, err := gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, "CODEOWNERS", 1<<20)
1091 if err != nil {
1092 content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
1093 }
1094 if err == nil && len(content) > 0 {
1095 rules := policy.ParseCodeowners(string(content))
1096 base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
1097 if err != nil {
1098 return c.fail(protocol.ExitFailure, "%v", err)
1099 }
1100 files, err := gitutil.DiffFiles(dir, base, headSHA)
1101 if err != nil {
1102 return c.fail(protocol.ExitFailure, "%v", err)
1103 }
1104 approved := map[string]bool{}
1105 for _, a := range approvers {
1106 approved[a] = true
1107 }
1108 missing := map[string][]string{} // owner-set key -> example paths
1109 for _, f := range files {
1110 owners := policy.OwnersFor(rules, f)
1111 if owners == nil {
1112 continue
1113 }
1114 ok := false
1115 for _, o := range owners {
1116 if approved[o] {
1117 ok = true
1118 break
1119 }
1120 }
1121 if !ok {
1122 key := strings.Join(owners, ",")
1123 if len(missing[key]) < 3 {
1124 missing[key] = append(missing[key], f)
1125 }
1126 }
1127 }
1128 if len(missing) > 0 {
1129 var parts []string
1130 for owners, paths := range missing {
1131 parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners))
1132 }
1133 slices.Sort(parts)
1134 return c.fail(protocol.ExitDenied,
1135 "CODEOWNERS approval missing for: %s", strings.Join(parts, "; "))
1136 }
1137 }
1138 }
1139
1140 if set.RequireResolved {
1141 n, err := c.Store.UnresolvedThreadCount(mr.ID)
1142 if err != nil {
1143 return c.fail(protocol.ExitFailure, "%v", err)
1144 }
1145 if n > 0 {
1146 return c.fail(protocol.ExitDenied,
1147 "%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number)
1148 }
1149 }
1150 return -1
1151}
1152
1153func runMRClose(c *Ctx, args []string) int {
1154 repo, mr, code := mrRef(c, args, policy.CanRead)
1155 if code >= 0 {
1156 return code
1157 }
1158 if code := refuseArchived(c, repo); code >= 0 {
1159 return code
1160 }
1161 if len(args) != 2 {
1162 return c.fail(protocol.ExitUsage, "usage: mr close <owner/name> <n>")
1163 }
1164 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1165 if err != nil {
1166 return c.fail(protocol.ExitFailure, "%v", err)
1167 }
1168 if mr.Author != c.User.Username && !policy.CanWrite(c.User, repo, grant) {
1169 return c.fail(protocol.ExitDenied, "only the author or users with write access can close this MR")
1170 }
1171 if mr.State == "merged" || mr.State == "closed" {
1172 return c.fail(protocol.ExitUsage, "MR !%d is already %s", mr.Number, mr.State)
1173 }
1174 if err := c.Store.MarkClosed(mr.ID, c.User.ID, ""); err != nil {
1175 return c.fail(protocol.ExitFailure, "%v", err)
1176 }
1177 return c.emit(map[string]any{"number": mr.Number, "state": "closed"}, func(w io.Writer) {
1178 fmt.Fprintf(w, "closed %s!%d\n", repo.Path(), mr.Number)
1179 })
1180}