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