Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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) {