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