vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 1 | package landing |
| 2 | |
| 3 | // The landing operation moves a protected branch without the pre-receive hook, so |
| 4 | // these tests are the ones that hold it to the same rule a push meets: no approval, |
| 5 | // no landing; a moved base gets a merge commit rather than a lost approval; a |
| 6 | // conflict is a refusal and not a resolved mess; and the request says "merged" only |
| 7 | // when a commit backs it. |
| 8 | |
| 9 | import ( |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "rodent.software/vole/core/git" |
| 16 | "rodent.software/vole/forge/access" |
| 17 | "rodent.software/vole/forge/repo" |
| 18 | "rodent.software/vole/metadata" |
| 19 | ) |
| 20 | |
| 21 | type fixture struct { |
| 22 | t *testing.T |
| 23 | g *git.Repo |
| 24 | m *repo.Meta |
| 25 | meta string // the metadata branch's first commit |
| 26 | // acls is the store, so a test can decide which accounts the roster still |
| 27 | // considers active. |
| 28 | acls *access.Store |
| 29 | } |
| 30 | |
| 31 | func newFixture(t *testing.T) *fixture { |
| 32 | t.Helper() |
| 33 | root := t.TempDir() |
| 34 | acls, err := access.Open(filepath.Join(root, "users.db")) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | acls.AuditPath = filepath.Join(root, "audit.log") |
| 39 | t.Cleanup(func() { acls.Close() }) |
| 40 | st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls) |
| 41 | m, err := st.Create("demo", "owner", "") |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | // Every landing test approves as "reviewer", and an approval only counts from an |
| 46 | // account that currently maintains the repository, so the fixture grants it the |
| 47 | // way an operator would. |
| 48 | if _, err := m.UpdateACL("test", func(a *access.ACL) error { |
| 49 | return a.SetEntry("reviewer", access.Maintain.String()) |
| 50 | }); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | g := m.Git() |
| 54 | // The metadata branch has to exist before a request can be written to it. |
| 55 | tree, err := g.MkTree(nil) |
| 56 | if err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | seed, err := g.CommitTree(tree, "", "start the metadata branch") |
| 60 | if err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if err := g.UpdateRef(metadata.Ref, seed); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | return &fixture{t: t, g: g, m: m, meta: seed, acls: acls} |
| 67 | } |
| 68 | |
| 69 | // rules rewrites the repository's ACL, which is where protection and the approval |
| 70 | // count live. |
| 71 | func (f *fixture) rules(mut func(*access.ACL)) { |
| 72 | f.t.Helper() |
| 73 | a, err := f.m.ACL() |
| 74 | if err != nil { |
| 75 | f.t.Fatal(err) |
| 76 | } |
| 77 | mut(a) |
| 78 | if err := f.m.SetACL(a, "test"); err != nil { |
| 79 | f.t.Fatal(err) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // commit writes one file on top of parent and returns the commit. |
| 84 | func (f *fixture) commit(parent, name, content, msg string) string { |
| 85 | f.t.Helper() |
| 86 | blob, err := f.g.HashObject([]byte(content)) |
| 87 | if err != nil { |
| 88 | f.t.Fatal(err) |
| 89 | } |
| 90 | tree, err := f.g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: blob, Name: name}}) |
| 91 | if err != nil { |
| 92 | f.t.Fatal(err) |
| 93 | } |
| 94 | c, err := f.g.CommitTree(tree, parent, msg) |
| 95 | if err != nil { |
| 96 | f.t.Fatal(err) |
| 97 | } |
| 98 | return c |
| 99 | } |
| 100 | |
| 101 | // review opens a request for head against main and has reviewer approve it. |
| 102 | func (f *fixture) review(author, head, reviewer string) string { |
| 103 | f.t.Helper() |
| 104 | return f.reviewWith(author, head, reviewer, nil) |
| 105 | } |
| 106 | |
| 107 | func (f *fixture) reviewWith(author, head, reviewer string, closes []string) string { |
| 108 | f.t.Helper() |
| 109 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) |
| 110 | req, err := metadata.NewRequest("Land it", "why", author, "main", head, now) |
| 111 | if err != nil { |
| 112 | f.t.Fatal(err) |
| 113 | } |
| 114 | req.Closes = closes |
| 115 | if err := metadata.CreateRequest(f.g, req, author); err != nil { |
| 116 | f.t.Fatal(err) |
| 117 | } |
| 118 | if reviewer != "" { |
| 119 | if _, err := metadata.ApproveRequest(f.g, req.ID, reviewer, req.Base, req.Head, now.Add(time.Minute), reviewer); err != nil { |
| 120 | f.t.Fatal(err) |
| 121 | } |
| 122 | } |
| 123 | return req.ID |
| 124 | } |
| 125 | |
| 126 | func (f *fixture) gate(actor string, role access.Role) Gate { |
| 127 | a, err := f.m.ACL() |
| 128 | if err != nil { |
| 129 | f.t.Fatal(err) |
| 130 | } |
| 131 | return Gate{Repo: f.g, Name: "demo", ACL: a, Actor: actor, Role: role} |
| 132 | } |
| 133 | |
| 134 | // protectMain makes main review-gated with a given number of required approvals and |
| 135 | // no check requirement, because a fixture has no run queue. |
| 136 | func (f *fixture) protectMain(approvals int) { |
| 137 | f.rules(func(a *access.ACL) { |
| 138 | a.Protected = []string{"refs/heads/main"} |
| 139 | n := approvals |
| 140 | a.ApprovalsRequired = &n |
| 141 | no := false |
| 142 | a.ChecksRequired = &no |
| 143 | }) |
| 144 | } |
| 145 | |
| 146 | func TestLandFastForwardsAnApprovedRequest(t *testing.T) { |
| 147 | t.Parallel() |
| 148 | f := newFixture(t) |
| 149 | base := f.commit("", "readme.md", "v1\n", "base") |
| 150 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | head := f.commit(base, "readme.md", "v2\n", "change") |
| 154 | if err := f.g.UpdateRef("refs/heads/topic", head); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | f.protectMain(1) |
| 158 | id := f.review("keenan", head, "reviewer") |
| 159 | |
| 160 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) |
| 161 | if err != nil { |
| 162 | t.Fatalf("land: %v", err) |
| 163 | } |
| 164 | if !res.FastForward || res.NewTip != head { |
| 165 | t.Fatalf("landed %s fast-forward=%t, want the reviewed head %s", res.NewTip, res.FastForward, head) |
| 166 | } |
| 167 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != head { |
| 168 | t.Fatalf("main = %s, want %s", tip, head) |
| 169 | } |
| 170 | got, err := metadata.GetRequest(f.g, id) |
| 171 | if err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if got.State != "merged" { |
| 175 | t.Fatalf("state = %q, want merged", got.State) |
| 176 | } |
| 177 | if got.MergedHead != head || got.MergedBy != "reviewer" || got.MergedAt.IsZero() { |
| 178 | t.Fatalf("merged record = %q by %q at %v, want head %s by reviewer", |
| 179 | got.MergedHead, got.MergedBy, got.MergedAt, head) |
| 180 | } |
| 181 | // The base tip before the landing is the "before" side of what the merge |
| 182 | // changed: once the head is in main, it is the only record of the starting point. |
| 183 | if got.MergedBase != base { |
| 184 | t.Fatalf("merged_base = %s, want the base tip %s", got.MergedBase, base) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func TestLandRefusesWithoutAnApproval(t *testing.T) { |
| 189 | t.Parallel() |
| 190 | f := newFixture(t) |
| 191 | base := f.commit("", "readme.md", "v1\n", "base") |
| 192 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | head := f.commit(base, "readme.md", "v2\n", "change") |
| 196 | f.protectMain(1) |
| 197 | id := f.review("keenan", head, "") // no approval |
| 198 | |
| 199 | _, err := Land(f.gate("writer", access.Maintain), id, "writer", time.Now().UTC()) |
| 200 | if err == nil { |
| 201 | t.Fatal("landed without an approval") |
| 202 | } |
| 203 | if !strings.Contains(err.Error(), "needs 1 approval") { |
| 204 | t.Fatalf("refusal = %q, want it to name the missing approval", err) |
| 205 | } |
| 206 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != base { |
| 207 | t.Fatalf("main moved to %s despite the refusal", tip) |
| 208 | } |
| 209 | if got, _ := metadata.GetRequest(f.g, id); got.State != "open" { |
| 210 | t.Fatalf("state = %q, want open after a refused landing", got.State) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestLandWritesAMergeCommitWhenTheBaseMoved(t *testing.T) { |
| 215 | t.Parallel() |
| 216 | f := newFixture(t) |
| 217 | base := f.commit("", "readme.md", "v1\n", "base") |
| 218 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | head := f.commit(base, "topic.md", "topic\n", "change on the branch") |
| 222 | f.protectMain(1) |
| 223 | id := f.review("keenan", head, "reviewer") |
| 224 | // Somebody else lands a different file first, so the approved head is no longer a |
| 225 | // fast-forward. The approval still names that head, and the merge commit keeps it |
| 226 | // as a parent. |
| 227 | moved := f.commit(base, "other.md", "other\n", "landed first") |
| 228 | if err := f.g.UpdateRef("refs/heads/main", moved); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | |
| 232 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) |
| 233 | if err != nil { |
| 234 | t.Fatalf("land: %v", err) |
| 235 | } |
| 236 | if !res.MergeCommit || res.NewTip == head { |
| 237 | t.Fatalf("landed %s merge-commit=%t, want a merge commit", res.NewTip, res.MergeCommit) |
| 238 | } |
| 239 | parents, err := f.g.CommitParents(res.NewTip) |
| 240 | if err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | if len(parents) != 2 || parents[0] != moved || parents[1] != head { |
| 244 | t.Fatalf("parents = %v, want [%s %s]", parents, moved, head) |
| 245 | } |
| 246 | got, _ := metadata.GetRequest(f.g, id) |
| 247 | if got.MergedHead != res.NewTip { |
| 248 | t.Fatalf("merged_head = %s, want the merge commit %s", got.MergedHead, res.NewTip) |
| 249 | } |
| 250 | if got.MergedBase != moved { |
| 251 | t.Fatalf("merged_base = %s, want the base tip %s the merge started from", got.MergedBase, moved) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func TestLandRefusesAConflict(t *testing.T) { |
| 256 | t.Parallel() |
| 257 | f := newFixture(t) |
| 258 | base := f.commit("", "readme.md", "v1\n", "base") |
| 259 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | head := f.commit(base, "readme.md", "branch\n", "branch edit") |
| 263 | f.protectMain(1) |
| 264 | id := f.review("keenan", head, "reviewer") |
| 265 | moved := f.commit(base, "readme.md", "base edit\n", "base edit") |
| 266 | if err := f.g.UpdateRef("refs/heads/main", moved); err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | |
| 270 | _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) |
| 271 | if err == nil { |
| 272 | t.Fatal("landed a conflicting change") |
| 273 | } |
| 274 | if !strings.Contains(err.Error(), "readme.md") { |
| 275 | t.Fatalf("refusal = %q, want it to name the conflicting path", err) |
| 276 | } |
| 277 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved { |
| 278 | t.Fatalf("main = %s, want the untouched %s", tip, moved) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | func TestLandTwiceMarksOnceAndKeepsTheHead(t *testing.T) { |
| 283 | t.Parallel() |
| 284 | f := newFixture(t) |
| 285 | base := f.commit("", "readme.md", "v1\n", "base") |
| 286 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | head := f.commit(base, "readme.md", "v2\n", "change") |
| 290 | f.protectMain(1) |
| 291 | id := f.review("keenan", head, "reviewer") |
| 292 | |
| 293 | if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err != nil { |
| 294 | t.Fatalf("first land: %v", err) |
| 295 | } |
| 296 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) |
| 297 | if err != nil { |
| 298 | t.Fatalf("second land: %v", err) |
| 299 | } |
| 300 | if !res.AlreadyLanded { |
| 301 | t.Fatal("second landing did not report the head as already landed") |
| 302 | } |
| 303 | got, _ := metadata.GetRequest(f.g, id) |
| 304 | if got.State != "merged" || got.MergedHead != head { |
| 305 | t.Fatalf("record = %q/%q, want merged at %s", got.State, got.MergedHead, head) |
| 306 | } |
| 307 | // A retry has no base tip of its own to name, so it keeps the one the first |
| 308 | // landing recorded instead of overwriting it with the moved tip. |
| 309 | if got.MergedBase != base { |
| 310 | t.Fatalf("merged_base = %s, want the retained %s", got.MergedBase, base) |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | func TestLandRefusesAClosedRequest(t *testing.T) { |
| 315 | t.Parallel() |
| 316 | f := newFixture(t) |
| 317 | base := f.commit("", "readme.md", "v1\n", "base") |
| 318 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 319 | t.Fatal(err) |
| 320 | } |
| 321 | head := f.commit(base, "readme.md", "v2\n", "change") |
| 322 | f.protectMain(1) |
| 323 | id := f.review("keenan", head, "reviewer") |
| 324 | if _, err := metadata.SetRequestState(f.g, id, "closed", "keenan"); err != nil { |
| 325 | t.Fatal(err) |
| 326 | } |
| 327 | if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err == nil { |
| 328 | t.Fatal("landed a closed request") |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | // Prepare is what the page asks before drawing a merge control, so it has to report a |
| 333 | // conflict rather than only refusing one: the control is drawn from the plan, and the |
| 334 | // paths are what the Review section shows. |
| 335 | func TestPrepareReportsConflictsWithoutMovingAnything(t *testing.T) { |
| 336 | t.Parallel() |
| 337 | f := newFixture(t) |
| 338 | base := f.commit("", "readme.md", "v1\n", "base") |
| 339 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 340 | t.Fatal(err) |
| 341 | } |
| 342 | head := f.commit(base, "readme.md", "branch\n", "branch edit") |
| 343 | f.protectMain(1) |
| 344 | id := f.review("keenan", head, "reviewer") |
| 345 | moved := f.commit(base, "readme.md", "base edit\n", "base edit") |
| 346 | if err := f.g.UpdateRef("refs/heads/main", moved); err != nil { |
| 347 | t.Fatal(err) |
| 348 | } |
| 349 | |
| 350 | plan, err := Prepare(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) |
| 351 | if err != nil { |
| 352 | t.Fatalf("prepare: %v", err) |
| 353 | } |
| 354 | if len(plan.Conflicts) == 0 || plan.Conflicts[0] != "readme.md" { |
| 355 | t.Fatalf("conflicts = %v, want [readme.md]", plan.Conflicts) |
| 356 | } |
| 357 | if plan.NewTip != "" { |
| 358 | t.Fatalf("NewTip = %s, want empty when the plan cannot land", plan.NewTip) |
| 359 | } |
| 360 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved { |
| 361 | t.Fatalf("main = %s, want the untouched %s", tip, moved) |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | // A clean non-fast-forward lands with a merge commit, and the plan says so before |
| 366 | // anything moves — the same answer the write then carries out. |
| 367 | func TestPrepareDescribesAMergeCommit(t *testing.T) { |
| 368 | t.Parallel() |
| 369 | f := newFixture(t) |
| 370 | base := f.commit("", "readme.md", "v1\n", "base") |
| 371 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 372 | t.Fatal(err) |
| 373 | } |
| 374 | head := f.commit(base, "topic.md", "topic\n", "branch") |
| 375 | f.protectMain(1) |
| 376 | id := f.review("keenan", head, "reviewer") |
| 377 | moved := f.commit(base, "other.md", "other\n", "landed first") |
| 378 | if err := f.g.UpdateRef("refs/heads/main", moved); err != nil { |
| 379 | t.Fatal(err) |
| 380 | } |
| 381 | |
| 382 | plan, err := Prepare(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) |
| 383 | if err != nil { |
| 384 | t.Fatalf("prepare: %v", err) |
| 385 | } |
| 386 | if !plan.MergeCommit || len(plan.Conflicts) > 0 || plan.NewTip == "" { |
| 387 | t.Fatalf("plan = merge %t conflicts %v tip %q, want a merge commit", plan.MergeCommit, plan.Conflicts, plan.NewTip) |
| 388 | } |
| 389 | parents, err := f.g.CommitParents(plan.NewTip) |
| 390 | if err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | if len(parents) != 2 || parents[0] != moved || parents[1] != head { |
| 394 | t.Fatalf("parents = %v, want [%s %s]", parents, moved, head) |
| 395 | } |
| 396 | // Preparing wrote an unreferenced commit and moved nothing. |
| 397 | if tip, _ := f.g.RevParse("refs/heads/main"); tip != moved { |
| 398 | t.Fatalf("main = %s, want the untouched %s", tip, moved) |
| 399 | } |
| 400 | // The plan the write carries out is the same one: same parents and shape. The |
| 401 | // commit id may differ because a merge commit's timestamp has second resolution, |
| 402 | // so a Prepare and a Land that straddle a second boundary write different objects |
| 403 | // for the same merge. |
| 404 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) |
| 405 | if err != nil { |
| 406 | t.Fatalf("land: %v", err) |
| 407 | } |
| 408 | if !res.MergeCommit { |
| 409 | t.Fatalf("landed %s, want a merge commit", res.NewTip) |
| 410 | } |
| 411 | landedParents, err := f.g.CommitParents(res.NewTip) |
| 412 | if err != nil { |
| 413 | t.Fatal(err) |
| 414 | } |
| 415 | if len(landedParents) != 2 || landedParents[0] != moved || landedParents[1] != head { |
| 416 | t.Fatalf("landed parents = %v, want [%s %s]", landedParents, moved, head) |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | // Landing a request closes the issues it says it closes, in the same commit that |
| 421 | // marks it merged. The point of the link is that nobody has to remember. |
| 422 | func TestLandClosesTheIssuesTheRequestNames(t *testing.T) { |
| 423 | t.Parallel() |
| 424 | f := newFixture(t) |
| 425 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) |
| 426 | closing, err := metadata.NewIssue("Blocked by the workflow", "why", "keenan", nil, nil, now) |
| 427 | if err != nil { |
| 428 | t.Fatal(err) |
| 429 | } |
| 430 | if err := metadata.CreateIssue(f.g, closing, "keenan"); err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | other, err := metadata.NewIssue("Unrelated", "why", "keenan", nil, nil, now) |
| 434 | if err != nil { |
| 435 | t.Fatal(err) |
| 436 | } |
| 437 | if err := metadata.CreateIssue(f.g, other, "keenan"); err != nil { |
| 438 | t.Fatal(err) |
| 439 | } |
| 440 | |
| 441 | base := f.commit("", "readme.md", "v1\n", "base") |
| 442 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 443 | t.Fatal(err) |
| 444 | } |
| 445 | head := f.commit(base, "readme.md", "v2\n", "change") |
| 446 | f.protectMain(1) |
| 447 | id := f.reviewWith("keenan", head, "reviewer", []string{closing.ID}) |
| 448 | |
| 449 | res, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()) |
| 450 | if err != nil { |
| 451 | t.Fatalf("land: %v", err) |
| 452 | } |
| 453 | if len(res.ClosedIssues) != 1 || res.ClosedIssues[0] != closing.ID { |
| 454 | t.Fatalf("closed = %v, want [%s]", res.ClosedIssues, closing.ID) |
| 455 | } |
| 456 | issues, err := metadata.LoadIssues(f.g, metadata.Ref) |
| 457 | if err != nil { |
| 458 | t.Fatal(err) |
| 459 | } |
| 460 | state := map[string]string{} |
| 461 | for _, iss := range issues { |
| 462 | state[iss.ID] = iss.State |
| 463 | } |
| 464 | if state[closing.ID] != "closed" { |
| 465 | t.Fatalf("linked issue = %q, want closed", state[closing.ID]) |
| 466 | } |
| 467 | if state[other.ID] != "open" { |
| 468 | t.Fatalf("unrelated issue = %q, want open", state[other.ID]) |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | // An approval is a statement by somebody who is answerable for it now. A |
| 473 | // maintainer who has been demoted cannot land yesterday's review, and the page |
| 474 | // stops counting it at the same moment. |
| 475 | func TestLandRefusesAnApprovalFromADemotedMaintainer(t *testing.T) { |
| 476 | t.Parallel() |
| 477 | f := newFixture(t) |
| 478 | base := f.commit("", "readme.md", "v1\n", "base") |
| 479 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 480 | t.Fatal(err) |
| 481 | } |
| 482 | head := f.commit(base, "readme.md", "v2\n", "change") |
| 483 | if err := f.g.UpdateRef("refs/heads/topic", head); err != nil { |
| 484 | t.Fatal(err) |
| 485 | } |
| 486 | f.protectMain(1) |
| 487 | id := f.review("keenan", head, "reviewer") |
| 488 | |
| 489 | // The demotion happens after the approval was written, which is the case the |
| 490 | // file alone cannot see. |
| 491 | if _, err := f.m.UpdateACL("root", func(a *access.ACL) error { |
| 492 | return a.SetEntry("reviewer", access.Contribute.String()) |
| 493 | }); err != nil { |
| 494 | t.Fatal(err) |
| 495 | } |
| 496 | if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err == nil { |
| 497 | t.Fatal("a demoted maintainer's approval still landed a change") |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | // Retirement is the other way standing lapses: the account is gone, the approval |
| 502 | // file is still on the branch. |
| 503 | func TestLandRefusesAnApprovalFromARetiredAccount(t *testing.T) { |
| 504 | t.Parallel() |
| 505 | f := newFixture(t) |
| 506 | base := f.commit("", "readme.md", "v1\n", "base") |
| 507 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 508 | t.Fatal(err) |
| 509 | } |
| 510 | head := f.commit(base, "readme.md", "v2\n", "change") |
| 511 | if err := f.g.UpdateRef("refs/heads/topic", head); err != nil { |
| 512 | t.Fatal(err) |
| 513 | } |
| 514 | f.protectMain(1) |
| 515 | id := f.review("keenan", head, "reviewer") |
| 516 | |
| 517 | f.acls.Active = func(name string) bool { return name != "reviewer" } |
| 518 | if _, err := Land(f.gate("reviewer", access.Maintain), id, "reviewer", time.Now().UTC()); err == nil { |
| 519 | t.Fatal("a retired account's approval still landed a change") |
| 520 | } |
| 521 | } |
| 522 | |
| 523 | // TestLandMovesTheRequestsThatFollowTheBase is the stacked-workflow rule for a |
| 524 | // landing: moving a base branch without post-receive still has to move the open |
| 525 | // requests that follow it, or the next change up the stack keeps reviewing the |
| 526 | // pre-merge tip. |
| 527 | func TestLandMovesTheRequestsThatFollowTheBase(t *testing.T) { |
| 528 | t.Parallel() |
| 529 | f := newFixture(t) |
| 530 | base := f.commit("", "readme.md", "v1\n", "base") |
| 531 | if err := f.g.UpdateRef("refs/heads/main", base); err != nil { |
| 532 | t.Fatal(err) |
| 533 | } |
| 534 | lower := f.commit(base, "readme.md", "v2\n", "stack") |
| 535 | if err := f.g.UpdateRef("refs/heads/stack", lower); err != nil { |
| 536 | t.Fatal(err) |
| 537 | } |
| 538 | upper := f.commit(lower, "readme.md", "v3\n", "upper") |
| 539 | if err := f.g.UpdateRef("refs/heads/topic", upper); err != nil { |
| 540 | t.Fatal(err) |
| 541 | } |
| 542 | now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) |
| 543 | |
| 544 | // The lower request targets main and follows the stack branch; the upper one |
| 545 | // targets the stack branch. Landing the upper moves the branch the lower |
| 546 | // follows, without any push to tell it. |
| 547 | lowerReq, err := metadata.NewRequest("Lower", "why", "keenan", "main", lower, now) |
| 548 | if err != nil { |
| 549 | t.Fatal(err) |
| 550 | } |
| 551 | lowerReq.HeadRef = "stack" |
| 552 | if err := metadata.CreateRequest(f.g, lowerReq, "keenan"); err != nil { |
| 553 | t.Fatal(err) |
| 554 | } |
| 555 | upperReq, err := metadata.NewRequest("Upper", "why", "keenan", "stack", upper, now) |
| 556 | if err != nil { |
| 557 | t.Fatal(err) |
| 558 | } |
| 559 | if err := metadata.CreateRequest(f.g, upperReq, "keenan"); err != nil { |
| 560 | t.Fatal(err) |
| 561 | } |
| 562 | |
| 563 | // An admin on an unprotected base needs no approval; the follower is the point. |
| 564 | if _, err := Land(f.gate("owner", access.Admin), upperReq.ID, "owner", now); err != nil { |
| 565 | t.Fatalf("land: %v", err) |
| 566 | } |
| 567 | if tip, _ := f.g.RevParse("refs/heads/stack"); tip != upper { |
| 568 | t.Fatalf("stack = %s, want %s", tip, upper) |
| 569 | } |
| 570 | got, err := metadata.GetRequest(f.g, lowerReq.ID) |
| 571 | if err != nil { |
| 572 | t.Fatal(err) |
| 573 | } |
| 574 | if got.State != "open" { |
| 575 | t.Fatalf("lower state = %q, want open", got.State) |
| 576 | } |
| 577 | if got.Head != upper { |
| 578 | t.Fatalf("lower head = %s, want the merged stack tip %s", got.Head, upper) |
| 579 | } |
| 580 | } |
| 581 |