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.
Make change requests follow branches and allow base retargeting
e050e14a64f6f19c1b1478c4c073d690ef3d3129 · Keenan Nemetz · 13d ago · parent 59464445
Make change requests follow branches and allow base retargeting A request opened from a branch now follows it: post-receive moves the request's head to the new tip after an accepted push, so approvals and checks that named the old tip stop counting. Approvals stay bound to an exact base and commit; the approval writers now require the base and head the approver saw, so a push or retarget cannot silently approve a target nobody reviewed. Merged requests freeze the reviewed head. Add `vole change update --base` and a Change base control for stacked requests. A new base changes the diff and therefore invalidates approvals, which is what retargeting an upper request after its lower branch lands needs. Base names may now contain slashes. Also fix a second-boundary flake in the landing test.
server/auth_test.go +96−13
| @@ -239,7 +239,7 @@ func TestWebApprovalNamesTheSessionAccount(t *testing.T) { | |||
| 239 | 239 | ||
| 240 | 240 | sess, cookie := f.browserSession(t, f.ownerID) | |
| 241 | 241 | resp, body := f.browserPost(t, "/r/"+f.repo+"/requests/"+req.ID+"/approve", cookie, | |
| 242 | url.Values{"csrf": {sess.CSRF}}) | ||
| 242 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {head}}) | ||
| 243 | 243 | if resp.StatusCode != http.StatusSeeOther { | |
| 244 | 244 | t.Fatalf("approve: status=%d body=%s", resp.StatusCode, body) | |
| 245 | 245 | } | |
| @@ -252,6 +252,57 @@ func TestWebApprovalNamesTheSessionAccount(t *testing.T) { | |||
| 252 | 252 | } | |
| 253 | 253 | } | |
| 254 | 254 | ||
| 255 | // A followed branch, or the request's base, can move between rendering the approval | ||
| 256 | // form and submitting it. The form names the target it showed, so the server refuses | ||
| 257 | // a form for a base or head that is no longer current instead of approving the new | ||
| 258 | // target unseen. | ||
| 259 | func TestWebApprovalRefusesAStaleTarget(t *testing.T) { | ||
| 260 | t.Parallel() | ||
| 261 | f := browserFixture(t) | ||
| 262 | f.addWriter(t) | ||
| 263 | m := mustRepo(t, f) | ||
| 264 | head := strings.Repeat("a", 40) | ||
| 265 | stale := strings.Repeat("b", 40) | ||
| 266 | req, err := metadata.NewRequest("A change", "please review", "wilbur", "main", head, time.Now().UTC()) | ||
| 267 | if err != nil { | ||
| 268 | t.Fatal(err) | ||
| 269 | } | ||
| 270 | if err := metadata.CreateRequest(m.Git(), req, "wilbur"); err != nil { | ||
| 271 | t.Fatal(err) | ||
| 272 | } | ||
| 273 | |||
| 274 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 275 | resp, body := f.browserPost(t, "/r/"+f.repo+"/requests/"+req.ID+"/approve", cookie, | ||
| 276 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {stale}}) | ||
| 277 | if resp.StatusCode != http.StatusBadRequest { | ||
| 278 | t.Fatalf("stale approval: status=%d body=%s", resp.StatusCode, body) | ||
| 279 | } | ||
| 280 | if !strings.Contains(body, "moved from") { | ||
| 281 | t.Fatalf("the refusal does not explain the moved head:\n%s", body) | ||
| 282 | } | ||
| 283 | |||
| 284 | // Retargeting the request has the same effect: the base the form names is no | ||
| 285 | // longer the base the request would land on. | ||
| 286 | if _, err := metadata.SetRequestBase(m.Git(), req.ID, "other", f.ownerID); err != nil { | ||
| 287 | t.Fatal(err) | ||
| 288 | } | ||
| 289 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+req.ID+"/approve", cookie, | ||
| 290 | url.Values{"csrf": {sess.CSRF}, "base": {"main"}, "head": {head}}) | ||
| 291 | if resp.StatusCode != http.StatusBadRequest { | ||
| 292 | t.Fatalf("stale base approval: status=%d body=%s", resp.StatusCode, body) | ||
| 293 | } | ||
| 294 | if !strings.Contains(body, "moved base from main to other") { | ||
| 295 | t.Fatalf("the refusal does not explain the moved base:\n%s", body) | ||
| 296 | } | ||
| 297 | list, err := metadata.LoadRequests(m.Git(), metadata.Ref) | ||
| 298 | if err != nil { | ||
| 299 | t.Fatal(err) | ||
| 300 | } | ||
| 301 | if len(list) != 1 || len(list[0].Approvals) != 0 { | ||
| 302 | t.Fatalf("a stale form wrote an approval: %+v", list) | ||
| 303 | } | ||
| 304 | } | ||
| 305 | |||
| 255 | 306 | func TestWebAuthnBeginEndpoints(t *testing.T) { | |
| 256 | 307 | t.Parallel() | |
| 257 | 308 | f := browserFixture(t) | |
| @@ -374,11 +425,10 @@ func TestContributorNamespaceAndRequestUseTheAccountName(t *testing.T) { | |||
| 374 | 425 | } | |
| 375 | 426 | } | |
| 376 | 427 | ||
| 377 | // TestContributorMovesTheirOwnRequestHead: a request names a commit, not a branch, | ||
| 378 | // so a fixup pushed after it was opened is invisible until the head moves. The CLI | ||
| 379 | // can move it, but only by writing the metadata branch a contributor cannot push, | ||
| 380 | // so the browser has to offer the same move — under the same namespace rule the | ||
| 381 | // create form uses, and only while the request is open. | ||
| 428 | // TestContributorMovesTheirOwnRequestHead: a request opened from a branch follows | ||
| 429 | // it, so a fixup push moves the head without the contributor writing metadata. The | ||
| 430 | // browser's head control is how a request is repointed at another branch, under the | ||
| 431 | // same namespace rule the create form uses, and only while the request is open. | ||
| 382 | 432 | func TestContributorMovesTheirOwnRequestHead(t *testing.T) { | |
| 383 | 433 | t.Parallel() | |
| 384 | 434 | f := browserFixture(t) | |
| @@ -424,8 +474,8 @@ func TestContributorMovesTheirOwnRequestHead(t *testing.T) { | |||
| 424 | 474 | id := list[0].ID | |
| 425 | 475 | first := list[0].Head | |
| 426 | 476 | ||
| 427 | // A fixup lands on the contributor's branch; the request still names the old | ||
| 428 | // commit until somebody moves it. | ||
| 477 | // A fixup lands on the contributor's branch; the request follows it, without a | ||
| 478 | // metadata write from the contributor (who could not push one anyway). | ||
| 429 | 479 | if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("contribution v2\n"), 0o644); err != nil { | |
| 430 | 480 | t.Fatal(err) | |
| 431 | 481 | } | |
| @@ -439,6 +489,13 @@ func TestContributorMovesTheirOwnRequestHead(t *testing.T) { | |||
| 439 | 489 | if err != nil || second == first { | |
| 440 | 490 | t.Fatalf("second tip = %q, %v (first %s)", second, err, first) | |
| 441 | 491 | } | |
| 492 | followed, err := metadata.GetRequest(m.Git(), id) | ||
| 493 | if err != nil { | ||
| 494 | t.Fatal(err) | ||
| 495 | } | ||
| 496 | if followed.Head != second { | ||
| 497 | t.Fatalf("request head = %s, want the pushed tip %s", followed.Head, second) | ||
| 498 | } | ||
| 442 | 499 | ||
| 443 | 500 | // The control is offered, with the contributor's own branch as a choice. | |
| 444 | 501 | resp, body = f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie) | |
| @@ -459,8 +516,8 @@ func TestContributorMovesTheirOwnRequestHead(t *testing.T) { | |||
| 459 | 516 | if err != nil { | |
| 460 | 517 | t.Fatal(err) | |
| 461 | 518 | } | |
| 462 | if got.Head != second { | ||
| 463 | t.Fatalf("head = %s, want the branch tip %s", got.Head, second) | ||
| 519 | if got.Head != second || got.HeadRef != headRef { | ||
| 520 | t.Fatalf("head = %s on %q, want the branch tip %s on %s", got.Head, got.HeadRef, second, headRef) | ||
| 464 | 521 | } | |
| 465 | 522 | ||
| 466 | 523 | // The namespace rule is the one the create form applies: a contributor cannot | |
| @@ -484,11 +541,31 @@ func TestContributorMovesTheirOwnRequestHead(t *testing.T) { | |||
| 484 | 541 | if resp.StatusCode != http.StatusSeeOther { | |
| 485 | 542 | t.Fatalf("maintainer move head: status=%d body=%s", resp.StatusCode, body) | |
| 486 | 543 | } | |
| 487 | if got, _ := metadata.GetRequest(m.Git(), id); got.Head != mainTip { | ||
| 488 | t.Fatalf("head = %s, want the maintainer's choice %s", got.Head, mainTip) | ||
| 544 | if got, _ := metadata.GetRequest(m.Git(), id); got.Head != mainTip || got.HeadRef != "main" { | ||
| 545 | t.Fatalf("head = %s on %q, want the maintainer's choice %s on main", got.Head, got.HeadRef, mainTip) | ||
| 489 | 546 | } | |
| 490 | 547 | ||
| 491 | // Closed requests are settled: the head is history, not a setting. | ||
| 548 | // A stacked request can be retargeted at the branch it should land on now. The | ||
| 549 | // page offers the base control, and the change moves the diff and starts | ||
| 550 | // approvals over. | ||
| 551 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/parent"); err != nil { | ||
| 552 | t.Fatalf("parent push: %v\n%s", err, errs) | ||
| 553 | } | ||
| 554 | resp, body = f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie) | ||
| 555 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/r/`+f.repo+`/requests/`+id+`/base"`) { | ||
| 556 | t.Fatalf("the request page does not offer to change the base:\n%s", body) | ||
| 557 | } | ||
| 558 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/base", cookie, url.Values{ | ||
| 559 | "csrf": {sess.CSRF}, "base": {"parent"}, | ||
| 560 | }) | ||
| 561 | if resp.StatusCode != http.StatusSeeOther { | ||
| 562 | t.Fatalf("change base: status=%d body=%s", resp.StatusCode, body) | ||
| 563 | } | ||
| 564 | if got, _ := metadata.GetRequest(m.Git(), id); got.Base != "parent" { | ||
| 565 | t.Fatalf("base = %s, want parent", got.Base) | ||
| 566 | } | ||
| 567 | |||
| 568 | // Closed requests are settled: the head and base are history, not settings. | ||
| 492 | 569 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/state", cookie, url.Values{ | |
| 493 | 570 | "csrf": {sess.CSRF}, "state": {"closed"}, | |
| 494 | 571 | }) | |
| @@ -501,6 +578,12 @@ func TestContributorMovesTheirOwnRequestHead(t *testing.T) { | |||
| 501 | 578 | if resp.StatusCode != http.StatusForbidden { | |
| 502 | 579 | t.Fatalf("a closed request moved its head: status=%d", resp.StatusCode) | |
| 503 | 580 | } | |
| 581 | resp, _ = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/base", cookie, url.Values{ | ||
| 582 | "csrf": {sess.CSRF}, "base": {"main"}, | ||
| 583 | }) | ||
| 584 | if resp.StatusCode != http.StatusForbidden { | ||
| 585 | t.Fatalf("a closed request changed its base: status=%d", resp.StatusCode) | ||
| 586 | } | ||
| 504 | 587 | } | |
| 505 | 588 | ||
| 506 | 589 | func TestContributorCIIsHeldUntilWriterAllows(t *testing.T) { | |