control, web, wiki: merge gates on mr show and the page, every unmet gate at once !343

merged merged by cmc on 2026-09-08 02:35 UTC · krz/gitbay:merge-gates into main

7 files changed, +331 −97

Layout: unified · split

.gitbay/wiki/Users.org +8
@@ -408,6 +408,14 @@ to ask without it gating merges; with it on and no file on the target
408branch, the merge is refused and says so. It does not wait on 408branch, the merge is refused and says so. It does not wait on
409=require-approvals=. 409=require-approvals=.
410 410
411=mr show= carries a =gates= block with where the merge request stands
412against all of them — approvals counted and required, owners still
413outstanding per file, open threads, checks, and whether a fast-forward
414is possible — and the merge request page shows the same block. =mr
415merge= names every unmet gate at once rather than the first. =mr
416review= says when a verdict is advisory, which it is from anyone
417without write access: the gates do not count it.
418
411Those gates apply to =mr merge=. A direct push to a protected branch 419Those gates apply to =mr merge=. A direct push to a protected branch
412passes none of them until =require-mr on=: then an existing protected 420passes none of them until =require-mr on=: then an existing protected
413branch refuses every push, including =repo commit-file= and the web 421branch refuses every push, including =repo commit-file= and the web
e2e/gates_test.go added +99
@@ -0,0 +1,99 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// mr show reports the gates before the merge fails, mr merge names every
11// unmet gate at once, and mr review says when a verdict is advisory
12// (#199).
13func TestMergeGatesVisible(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 bobKey := inst.newKey(t, "bob")
17 carolKey := inst.newKey(t, "carol")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
19 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
20 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
21 for _, args := range [][]string{
22 {"repo", "create", "alice/svc"},
23 {"repo", "access", "grant", "alice/svc", "bob", "write"},
24 {"repo", "settings", "require-approvals", "alice/svc", "1"},
25 {"repo", "settings", "require-codeowners", "alice/svc", "on"},
26 {"repo", "settings", "require-resolved", "alice/svc", "on"},
27 } {
28 if _, errOut, code := inst.ssh(t, aliceKey, "", args...); code != 0 {
29 t.Fatalf("%v: %s", args, errOut)
30 }
31 }
32 env := inst.gitEnv(aliceKey)
33 work := t.TempDir()
34 mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/svc"), "w")
35 dir := filepath.Join(work, "w")
36 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
37 os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @bob\n"), 0o644)
38 os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n"), 0o644)
39 mustGit(t, dir, env, "add", ".")
40 mustGit(t, dir, env, "commit", "-q", "-m", "base")
41 mustGit(t, dir, env, "push", "-q", "origin", "main")
42 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
43 os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n\nvar V = 1\n"), 0o644)
44 mustGit(t, dir, env, "commit", "-q", "-am", "change")
45 mustGit(t, dir, env, "push", "-q", "origin", "feat")
46 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc",
47 "--source", "feat", "--target", "main", "--title", "'change'"); code != 0 {
48 t.Fatalf("mr create: %s", errOut)
49 }
50
51 // Before anyone reviews: two unmet gates, visible on mr show.
52 out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1", "--json")
53 for _, want := range []string{
54 `"approvals_required":1`, `"codeowners_required":true`, `"resolved_required":true`,
55 `"owners_outstanding":[{"files":["svc.go"],"owners":["bob"]}]`, `"fast_forward":true`,
56 `requires 1 fresh approval(s)`, `CODEOWNERS approval missing for: svc.go (owned by bob)`,
57 } {
58 if !strings.Contains(out, want) {
59 t.Errorf("mr show gates missing %s:\n%s", want, out)
60 }
61 }
62 // The merge names both at once.
63 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 4 ||
64 !strings.Contains(errOut, "fresh approval") || !strings.Contains(errOut, "CODEOWNERS approval missing") {
65 t.Fatalf("merge refusal does not name every gate: %d %s", code, errOut)
66 }
67
68 // A reader's approval is advisory, and says so when made.
69 out, _, code := inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--approve", "--json")
70 if code != 0 || !strings.Contains(out, `"counts":false`) {
71 t.Fatalf("reader review: %d %s", code, out)
72 }
73 if out, _, _ := inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--comment"); !strings.Contains(out, "advisory") {
74 t.Fatalf("reader review does not say it is advisory: %s", out)
75 }
76 out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1", "--json")
77 if !strings.Contains(out, `requires 1 fresh approval(s)`) {
78 t.Fatalf("advisory approval counted in the gates:\n%s", out)
79 }
80
81 // The owner's approval meets both; the block says so and the merge lands.
82 out, _, _ = inst.ssh(t, bobKey, "", "mr", "review", "alice/svc", "1", "--approve", "--json")
83 if !strings.Contains(out, `"counts":true`) {
84 t.Fatalf("writer review: %s", out)
85 }
86 out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1", "--json")
87 if strings.Contains(out, `"unmet"`) || !strings.Contains(out, `"approvals":["bob"]`) {
88 t.Fatalf("gates after approval:\n%s", out)
89 }
90 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !strings.Contains(out, "gates: met; fast-forward possible") {
91 t.Fatalf("text gates line: %s", out)
92 }
93 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 {
94 t.Fatalf("merge: %s", errOut)
95 }
96 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1", "--json"); strings.Contains(out, `"gates"`) {
97 t.Fatalf("gates reported on a merged request:\n%s", out)
98 }
99}
internal/control/mr.go +145 −96
@@ -567,6 +567,13 @@ func runMRShow(c *Ctx, args []string) int {
567 d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined, 567 d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined,
568 UnresolvedThreads: unresolved, Commits: commits, Comments: cs, Reviews: rs} 568 UnresolvedThreads: unresolved, Commits: commits, Comments: cs, Reviews: rs}
569 d.StackedOn, d.Stacked = stackOf(c, repo, mr) 569 d.StackedOn, d.Stacked = stackOf(c, repo, mr)
570 if mr.State == "open" || mr.State == "source_gone" {
571 if targetSHA, err := gitutil.ResolveRef(dir, "refs/heads/"+mr.TargetRef); err == nil {
572 if g, err := MergeGates(c.Store, repo, mr, dir, targetSHA, mr.HeadSHA); err == nil {
573 d.Gates = &g
574 }
575 }
576 }
570 return c.emit(d, func(w io.Writer) { 577 return c.emit(d, func(w io.Writer) {
571 state := d.State 578 state := d.State
572 if d.Draft { 579 if d.Draft {
@@ -604,6 +611,20 @@ func runMRShow(c *Ctx, args []string) int {
604 if d.UnresolvedThreads > 0 { 611 if d.UnresolvedThreads > 0 {
605 fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads) 612 fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads)
606 } 613 }
614 if g := d.Gates; g != nil {
615 ff := "fast-forward possible"
616 if !g.FastForward {
617 ff = "fast-forward not possible"
618 }
619 if len(g.Unmet) == 0 {
620 fmt.Fprintf(w, "gates: met; %s\n", ff)
621 } else {
622 fmt.Fprintf(w, "gates: %d unmet; %s\n", len(g.Unmet), ff)
623 for _, u := range g.Unmet {
624 fmt.Fprintf(w, "gate: %s\n", u)
625 }
626 }
627 }
607 for _, r := range rs { 628 for _, r := range rs {
608 stale := "" 629 stale := ""
609 if r.Stale { 630 if r.Stale {
@@ -800,11 +821,17 @@ func runMRReview(c *Ctx, args []string) int {
800 action: reviewAction(mr.Number, verdict, published), 821 action: reviewAction(mr.Number, verdict, published),
801 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)}) 822 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
802 } 823 }
803 return c.emit(map[string]any{"number": mr.Number, "verdict": verdict, "published": published}, func(w io.Writer) { 824 // Whether the merge gates will count this verdict, said now rather
825 // than at the refusal (#199).
826 counts := ReviewersWhoCount(c.Store, repo, []store.MRReview{{Reviewer: c.User.Username}})[c.User.Username]
827 return c.emit(map[string]any{"number": mr.Number, "verdict": verdict, "published": published, "counts": counts}, func(w io.Writer) {
804 fmt.Fprintf(w, "reviewed %s!%d: %s", repo.Path(), mr.Number, verdict) 828 fmt.Fprintf(w, "reviewed %s!%d: %s", repo.Path(), mr.Number, verdict)
805 if published > 0 { 829 if published > 0 {
806 fmt.Fprintf(w, " (%d comment(s))", published) 830 fmt.Fprintf(w, " (%d comment(s))", published)
807 } 831 }
832 if !counts {
833 fmt.Fprintf(w, " (advisory: no write access on %s, so the merge gates do not count it)", repo.Path())
834 }
808 fmt.Fprintln(w) 835 fmt.Fprintln(w)
809 }) 836 })
810} 837}
@@ -928,31 +955,8 @@ func runMRMerge(c *Ctx, args []string) int {
928 return c.fail(protocol.ExitFailure, "MR head ref: %v", err) 955 return c.fail(protocol.ExitFailure, "MR head ref: %v", err)
929 } 956 }
930 957
931 // Check gate: with require_checks, the MR head must carry statuses 958 // Merge gates: draft, checks, approvals, CODEOWNERS, resolved threads,
932 // and every one of them must be green. 959 // all reported at once.
933 if repo.Settings.RequireChecks {
934 statuses, err := c.Store.ListCommitStatuses(repo.ID, headSHA)
935 if err != nil {
936 return c.fail(protocol.ExitFailure, "%v", err)
937 }
938 switch store.CombinedStatus(statuses) {
939 case "success":
940 case "":
941 return c.fail(protocol.ExitDenied,
942 "%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)
943 default:
944 var bad []string
945 for _, st := range statuses {
946 if st.State != "success" {
947 bad = append(bad, st.Context+"="+st.State)
948 }
949 }
950 return c.fail(protocol.ExitDenied,
951 "%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))
952 }
953 }
954
955 // Review gates: approvals, CODEOWNERS, resolved threads.
956 if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 { 960 if code := c.reviewGates(repo, mr, dir, targetSHA, headSHA); code >= 0 {
957 return code 961 return code
958 } 962 }
@@ -1225,20 +1229,62 @@ func runMRMerge(c *Ctx, args []string) int {
1225 }) 1229 })
1226} 1230}
1227 1231
1228// reviewGates enforces require_approvals (fresh, non-author, latest review 1232// reviewGates refuses a merge whose gates are not all met, naming every
1229// per reviewer; a fresh request-changes blocks), require_codeowners, and 1233// unmet one. Returns -1 to proceed.
1230// require_resolved. Returns -1 to proceed.
1231func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int { 1234func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) int {
1235 g, err := MergeGates(c.Store, repo, mr, dir, targetSHA, headSHA)
1236 if err != nil {
1237 return c.fail(protocol.ExitFailure, "%v", err)
1238 }
1239 if len(g.Unmet) > 0 {
1240 return c.fail(protocol.ExitDenied, "%s", strings.Join(g.Unmet, "; "))
1241 }
1242 return -1
1243}
1244
1245// MergeGates computes where a merge request stands against its
1246// repository's gates: draft, require_checks, require_approvals (fresh,
1247// non-author, latest review per reviewer from someone who can write; a
1248// fresh request-changes blocks), require_codeowners and require_resolved.
1249// Unmet carries one sentence per gate not passed. Fast-forward is
1250// reported, not gated: whether it matters depends on the strategy.
1251func MergeGates(st *store.Store, repo store.Repo, mr store.MR, dir, targetSHA, headSHA string) (GatesOut, error) {
1252 set := repo.Settings
1253 g := GatesOut{Draft: mr.Draft, ApprovalsRequired: set.RequireApprovals,
1254 CodeownersRequired: set.RequireCodeowners, ResolvedRequired: set.RequireResolved,
1255 ChecksRequired: set.RequireChecks}
1232 // A draft is open but not asking. This gate is unconditional — no 1256 // A draft is open but not asking. This gate is unconditional — no
1233 // setting turns it off — because the author said so themselves. 1257 // setting turns it off — because the author said so themselves.
1234 if mr.Draft { 1258 if mr.Draft {
1235 return c.fail(protocol.ExitDenied, 1259 g.Unmet = append(g.Unmet, fmt.Sprintf("!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number))
1236 "!%d is a draft; `gitbay mr ready %s %d` first", mr.Number, repo.Path(), mr.Number)
1237 } 1260 }
1238 set := repo.Settings 1261
1239 reviews, err := c.Store.ListMRReviews(mr.ID) 1262 // Checks: with require_checks, the head must carry statuses and every
1263 // one of them must be green.
1264 statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
1240 if err != nil { 1265 if err != nil {
1241 return c.fail(protocol.ExitFailure, "%v", err) 1266 return g, err
1267 }
1268 g.Checks = store.CombinedStatus(statuses)
1269 if set.RequireChecks {
1270 switch g.Checks {
1271 case "success":
1272 case "":
1273 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA))
1274 default:
1275 var bad []string
1276 for _, st := range statuses {
1277 if st.State != "success" {
1278 bad = append(bad, st.Context+"="+st.State)
1279 }
1280 }
1281 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", ")))
1282 }
1283 }
1284
1285 reviews, err := st.ListMRReviews(mr.ID)
1286 if err != nil {
1287 return g, err
1242 } 1288 }
1243 // Latest fresh review per reviewer decides their stance — but only 1289 // Latest fresh review per reviewer decides their stance — but only
1244 // from someone the repository trusts to write to it. Reviewing is 1290 // from someone the repository trusts to write to it. Reviewing is
@@ -1246,7 +1292,7 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA
1246 // public change possible; deciding a merge gate is not the same 1292 // public change possible; deciding a merge gate is not the same
1247 // thing, and counting every verdict let anyone with an account 1293 // thing, and counting every verdict let anyone with an account
1248 // satisfy require_approvals or block a merge indefinitely (#147). 1294 // satisfy require_approvals or block a merge indefinitely (#147).
1249 counts := ReviewersWhoCount(c.Store, repo, reviews) 1295 counts := ReviewersWhoCount(st, repo, reviews)
1250 latest := map[string]string{} 1296 latest := map[string]string{}
1251 for _, r := range reviews { 1297 for _, r := range reviews {
1252 if r.Stale || r.Reviewer == mr.Author || !counts[r.Reviewer] { 1298 if r.Stale || r.Reviewer == mr.Author || !counts[r.Reviewer] {
@@ -1254,26 +1300,22 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA
1254 } 1300 }
1255 latest[r.Reviewer] = r.Verdict 1301 latest[r.Reviewer] = r.Verdict
1256 } 1302 }
1257 var approvers []string
1258 var blockers []string
1259 for who, verdict := range latest { 1303 for who, verdict := range latest {
1260 switch verdict { 1304 switch verdict {
1261 case "approve": 1305 case "approve":
1262 approvers = append(approvers, who) 1306 g.Approvals = append(g.Approvals, who)
1263 case "request_changes": 1307 case "request_changes":
1264 blockers = append(blockers, who) 1308 g.ChangesRequested = append(g.ChangesRequested, who)
1265 } 1309 }
1266 } 1310 }
1267 1311 slices.Sort(g.Approvals)
1312 slices.Sort(g.ChangesRequested)
1268 if set.RequireApprovals > 0 { 1313 if set.RequireApprovals > 0 {
1269 if len(blockers) > 0 { 1314 if len(g.ChangesRequested) > 0 {
1270 slices.Sort(blockers) 1315 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requested changes on !%d; resolve their review before merging", strings.Join(g.ChangesRequested, ", "), mr.Number))
1271 return c.fail(protocol.ExitDenied,
1272 "%s requested changes on !%d; resolve their review before merging", strings.Join(blockers, ", "), mr.Number)
1273 } 1316 }
1274 if len(approvers) < set.RequireApprovals { 1317 if len(g.Approvals) < set.RequireApprovals {
1275 return c.fail(protocol.ExitDenied, 1318 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)))
1276 "%s requires %d fresh approval(s); !%d has %d", repo.Path(), set.RequireApprovals, mr.Number, len(approvers))
1277 } 1319 }
1278 } 1320 }
1279 1321
@@ -1287,65 +1329,72 @@ func (c *Ctx) reviewGates(repo store.Repo, mr store.MR, dir, targetSHA, headSHA
1287 content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20) 1329 content, err = gitutil.ReadBlob(dir, "refs/heads/"+mr.TargetRef, ".gitbay/CODEOWNERS", 1<<20)
1288 } 1330 }
1289 if err != nil || len(content) == 0 { 1331 if err != nil || len(content) == 0 {
1290 return c.fail(protocol.ExitDenied, 1332 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires CODEOWNERS approval but %s carries no CODEOWNERS file", repo.Path(), mr.TargetRef))
1291 "%s requires CODEOWNERS approval but %s carries no CODEOWNERS file", 1333 } else {
1292 repo.Path(), mr.TargetRef) 1334 rules := policy.ParseCodeowners(string(content))
1293 } 1335 base, err := gitutil.MergeBase(dir, targetSHA, headSHA)
1294 rules := policy.ParseCodeowners(string(content)) 1336 if err != nil {
1295 base, err := gitutil.MergeBase(dir, targetSHA, headSHA) 1337 return g, err
1296 if err != nil {
1297 return c.fail(protocol.ExitFailure, "%v", err)
1298 }
1299 files, err := gitutil.DiffFiles(dir, base, headSHA)
1300 if err != nil {
1301 return c.fail(protocol.ExitFailure, "%v", err)
1302 }
1303 approved := map[string]bool{}
1304 for _, a := range approvers {
1305 approved[a] = true
1306 }
1307 missing := map[string][]string{} // owner-set key -> example paths
1308 for _, f := range files {
1309 owners := policy.OwnersFor(rules, f)
1310 if owners == nil {
1311 continue
1312 } 1338 }
1313 ok := false 1339 files, err := gitutil.DiffFiles(dir, base, headSHA)
1314 for _, o := range owners { 1340 if err != nil {
1315 if approved[o] { 1341 return g, err
1316 ok = true 1342 }
1317 break 1343 approved := map[string]bool{}
1318 } 1344 for _, a := range g.Approvals {
1345 approved[a] = true
1319 } 1346 }
1320 if !ok { 1347 missing := map[string][]string{} // owner-set key -> paths
1321 key := strings.Join(owners, ",") 1348 var keys []string
1322 if len(missing[key]) < 3 { 1349 for _, f := range files {
1350 owners := policy.OwnersFor(rules, f)
1351 if owners == nil {
1352 continue
1353 }
1354 ok := false
1355 for _, o := range owners {
1356 if approved[o] {
1357 ok = true
1358 break
1359 }
1360 }
1361 if !ok {
1362 key := strings.Join(owners, ",")
1363 if _, seen := missing[key]; !seen {
1364 keys = append(keys, key)
1365 }
1323 missing[key] = append(missing[key], f) 1366 missing[key] = append(missing[key], f)
1324 } 1367 }
1325 } 1368 }
1326 } 1369 if len(missing) > 0 {
1327 if len(missing) > 0 { 1370 slices.Sort(keys)
1328 var parts []string 1371 var parts []string
1329 for owners, paths := range missing { 1372 for _, key := range keys {
1330 parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), owners)) 1373 paths := missing[key]
1374 g.OwnersOutstanding = append(g.OwnersOutstanding, OwnersOut{Files: paths, Owners: strings.Split(key, ",")})
1375 if len(paths) > 3 {
1376 paths = paths[:3]
1377 }
1378 parts = append(parts, fmt.Sprintf("%s (owned by %s)", strings.Join(paths, ", "), key))
1379 }
1380 g.Unmet = append(g.Unmet, "CODEOWNERS approval missing for: "+strings.Join(parts, "; "))
1331 } 1381 }
1332 slices.Sort(parts)
1333 return c.fail(protocol.ExitDenied,
1334 "CODEOWNERS approval missing for: %s", strings.Join(parts, "; "))
1335 } 1382 }
1336 } 1383 }
1337 1384
1338 if set.RequireResolved { 1385 n, err := st.UnresolvedThreadCount(mr.ID)
1339 n, err := c.Store.UnresolvedThreadCount(mr.ID) 1386 if err != nil {
1340 if err != nil { 1387 return g, err
1341 return c.fail(protocol.ExitFailure, "%v", err)
1342 }
1343 if n > 0 {
1344 return c.fail(protocol.ExitDenied,
1345 "%s requires review threads resolved; !%d has %d open (mr threads %s %d)", repo.Path(), mr.Number, n, repo.Path(), mr.Number)
1346 }
1347 } 1388 }
1348 return -1 1389 g.OpenThreads = n
1390 if set.RequireResolved && n > 0 {
1391 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))
1392 }
1393
1394 if ff, err := gitutil.IsAncestor(dir, targetSHA, headSHA); err == nil {
1395 g.FastForward = ff
1396 }
1397 return g, nil
1349} 1398}
1350 1399
1351func runMRDraft(c *Ctx, args []string) int { return setMRDraft(c, args, true) } 1400func runMRDraft(c *Ctx, args []string) int { return setMRDraft(c, args, true) }
internal/control/output.go +27
@@ -33,6 +33,33 @@ type MRShow struct {
33 Commits []CommitOut `json:"commits,omitempty"` 33 Commits []CommitOut `json:"commits,omitempty"`
34 Comments []commentOut `json:"comments,omitempty"` 34 Comments []commentOut `json:"comments,omitempty"`
35 Reviews []ReviewOut `json:"reviews,omitempty"` 35 Reviews []ReviewOut `json:"reviews,omitempty"`
36 // Gates is set while the merge request is open.
37 Gates *GatesOut `json:"gates,omitempty"`
38}
39
40// OwnersOut is a set of changed files still waiting on an approval from
41// one of their CODEOWNERS.
42type OwnersOut struct {
43 Files []string `json:"files"`
44 Owners []string `json:"owners"`
45}
46
47// GatesOut is what a merge request has to pass to merge, and where it
48// stands: the same computation mr merge refuses on, so nothing is
49// learned at the refusal that mr show did not say (#199).
50type GatesOut struct {
51 Draft bool `json:"draft,omitempty"`
52 ApprovalsRequired int `json:"approvals_required"`
53 Approvals []string `json:"approvals,omitempty"` // fresh, from reviewers who count
54 ChangesRequested []string `json:"changes_requested,omitempty"`
55 CodeownersRequired bool `json:"codeowners_required"`
56 OwnersOutstanding []OwnersOut `json:"owners_outstanding,omitempty"`
57 ResolvedRequired bool `json:"resolved_required"`
58 OpenThreads int `json:"open_threads"`
59 ChecksRequired bool `json:"checks_required"`
60 Checks string `json:"checks,omitempty"` // combined status; "" when none reported
61 FastForward bool `json:"fast_forward"`
62 Unmet []string `json:"unmet,omitempty"`
36} 63}
37 64
38// ReviewOut is one review on a merge request. 65// ReviewOut is one review on a merge request.
internal/httpd/mrpage_test.go +32
@@ -6,6 +6,7 @@ import (
6 "testing" 6 "testing"
7 "time" 7 "time"
8 8
9 "gitbay.org/gitbay/internal/control"
9 "gitbay.org/gitbay/internal/gitutil" 10 "gitbay.org/gitbay/internal/gitutil"
10 "gitbay.org/gitbay/internal/store" 11 "gitbay.org/gitbay/internal/store"
11 "gitbay.org/gitbay/internal/web" 12 "gitbay.org/gitbay/internal/web"
@@ -31,6 +32,7 @@ type mrPageData struct {
31 Revisions []store.MRHead 32 Revisions []store.MRHead
32 Notice string 33 Notice string
33 DetachedThreads []diffThread 34 DetachedThreads []diffThread
35 Gates *control.GatesOut
34} 36}
35 37
36func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { 38func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
@@ -131,3 +133,33 @@ func TestMRChecksRenderSkippedWithoutBuildLink(t *testing.T) {
131 t.Errorf("skipped check with no build linked anyway: %s", row) 133 t.Errorf("skipped check with no build linked anyway: %s", row)
132 } 134 }
133} 135}
136
137// The gates block says what the merge is waiting on before a merge is
138// refused (#199): every unmet gate, the approval count, the outstanding
139// owners, and whether a fast-forward is possible.
140func TestMRGatesRender(t *testing.T) {
141 render := func(g *control.GatesOut) string {
142 t.Helper()
143 var sb strings.Builder
144 if err := web.Render(&sb, "mr.html", mrPageData{
145 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Gates: g,
146 }); err != nil {
147 t.Fatalf("render: %v", err)
148 }
149 return sb.String()
150 }
151 out := render(&control.GatesOut{ApprovalsRequired: 2, Approvals: []string{"bob"},
152 OwnersOutstanding: []control.OwnersOut{{Files: []string{"svc.go"}, Owners: []string{"carol"}}},
153 Unmet: []string{"krz/hutch requires 2 fresh approval(s); !42 has 1", "CODEOWNERS approval missing for: svc.go (owned by carol)"}})
154 for _, want := range []string{"Merge gates", "requires 2 fresh approval(s)", "approvals: 1 of 2 (bob)", `waiting on <a href="/carol">carol</a>`, "not a fast-forward"} {
155 if !strings.Contains(out, want) {
156 t.Errorf("gates block missing %q:\n%s", want, out)
157 }
158 }
159 if out := render(&control.GatesOut{FastForward: true}); !strings.Contains(out, "All gates met") || !strings.Contains(out, "fast-forward possible") {
160 t.Errorf("met gates not rendered:\n%s", out)
161 }
162 if out := render(nil); strings.Contains(out, "Merge gates") {
163 t.Errorf("gates block on a merge request without gates:\n%s", out)
164 }
165}
internal/httpd/web.go +12 −1
@@ -1828,6 +1828,16 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1828 if view != "commits" && view != "diff" { 1828 if view != "commits" && view != "diff" {
1829 view = "conversation" 1829 view = "conversation"
1830 } 1830 }
1831 // Where the merge request stands against the gates, the same
1832 // computation mr merge refuses on (#199).
1833 var gates *control.GatesOut
1834 if m.State == "open" || m.State == "source_gone" {
1835 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
1836 if g, err := control.MergeGates(s.st, p.Repo, m, p.Dir, targetSHA, m.HeadSHA); err == nil {
1837 gates = &g
1838 }
1839 }
1840 }
1831 // The stack around an open merge request, for the header. 1841 // The stack around an open merge request, for the header.
1832 var stackedOn *store.MR 1842 var stackedOn *store.MR
1833 var stacked []store.MR 1843 var stacked []store.MR
@@ -1862,9 +1872,10 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1862 DetachedThreads []diffThread 1872 DetachedThreads []diffThread
1863 StackedOn *store.MR 1873 StackedOn *store.MR
1864 Stacked []store.MR 1874 Stacked []store.MR
1875 Gates *control.GatesOut
1865 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 1876 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1866 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 1877 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1867 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked}) 1878 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates})
1868} 1879}
1869 1880
1870func (s *Server) refs(w http.ResponseWriter, r *http.Request) { 1881func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
internal/web/templates/mr.html +8
@@ -145,6 +145,14 @@
145 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two: 145 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
146 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}} 146 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
147 </div> 147 </div>
148 {{with .Gates}}<div class="grp">
149 <h2>Merge gates</h2>
150 {{if .Unmet}}{{range .Unmet}}<p class="row"><span class="dot bad"></span>{{.}}</p>{{end}}
151 {{else}}<p class="row"><span class="dot ok"></span>All gates met</p>{{end}}
152 {{if .ApprovalsRequired}}<p class="row none">approvals: {{len .Approvals}} of {{.ApprovalsRequired}}{{if .Approvals}} ({{range $i, $a := .Approvals}}{{if $i}}, {{end}}{{$a}}{{end}}){{end}}</p>{{end}}
153 {{range .OwnersOutstanding}}<p class="row none">waiting on {{range $i, $o := .Owners}}{{if $i}} or {{end}}<a href="/{{$o}}">{{$o}}</a>{{end}} for {{range $i, $f := .Files}}{{if $i}}, {{end}}<code>{{$f}}</code>{{end}}</p>{{end}}
154 <p class="row none">{{if .FastForward}}fast-forward possible{{else}}not a fast-forward: rebase, or merge with a merge commit{{end}}</p>
155 </div>{{end}}
148 <div class="grp"> 156 <div class="grp">
149 <h2>Checks</h2> 157 <h2>Checks</h2>
150 {{if .Checks}}<p class="row"><span class="badge check-{{.Combined}}">{{.Combined}}</span></p> 158 {{if .Checks}}<p class="row"><span class="badge check-{{.Combined}}">{{.Combined}}</span></p>