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