vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Test the merge result before landing it
e5478085e5b53a2892d0649d6f07c6f0a5653378 · Keenan Nemetz · 11d ago · parent db5e4382
Test the merge result before landing it A protected branch could receive a non-fast-forward landing whose tree was never built: the gate asked for checks on the request head, and the merge commit was made at landing time. A pushed merge with the right parents but extra content was accepted too. Publish the merge commit under refs/vole/candidates/, test it as the base branch, and make the gate require its results. Approvals name the head and the base branch, so a moved base retests without a new approval. A merger who clicks while that run is still going waits for it instead of clicking again. A pushed merge commit now has to carry exactly the tree a clean merge would produce. Closes: 01M61QQ56TESQ1HTMB1Z5C3733
server/web/writes.go +108−6
| @@ -12,6 +12,7 @@ package web | |||
| 12 | 12 | ||
| 13 | 13 | import ( | |
| 14 | 14 | "errors" | |
| 15 | "fmt" | ||
| 15 | 16 | "net/http" | |
| 16 | 17 | "strings" | |
| 17 | 18 | "time" | |
| @@ -21,6 +22,7 @@ import ( | |||
| 21 | 22 | "github.com/rodent-software/vole/forge/index" | |
| 22 | 23 | "github.com/rodent-software/vole/forge/landing" | |
| 23 | 24 | "github.com/rodent-software/vole/forge/repo" | |
| 25 | "github.com/rodent-software/vole/internal/format" | ||
| 24 | 26 | "github.com/rodent-software/vole/metadata" | |
| 25 | 27 | ) | |
| 26 | 28 | ||
| @@ -476,6 +478,10 @@ func (w *Web) landGate(m *repo.Meta, acl *access.ACL, actor string) landing.Gate | |||
| 476 | 478 | // `vole change merge` runs, through the same gate the pre-receive hook applies to a | |
| 477 | 479 | // push. The branch moves here, so without that call this handler would be the | |
| 478 | 480 | // easiest way to land an unapproved change. | |
| 481 | // | ||
| 482 | // When the combined result is still building, the click is not thrown away: it arms | ||
| 483 | // the request to land as soon as the result is green. Every landing still runs the | ||
| 484 | // full gate, so an arm is a wait, not an authorisation. | ||
| 479 | 485 | func (w *Web) MergeRequest(rw http.ResponseWriter, r *http.Request) { | |
| 480 | 486 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) | |
| 481 | 487 | if !ok { | |
| @@ -496,17 +502,57 @@ func (w *Web) MergeRequest(rw http.ResponseWriter, r *http.Request) { | |||
| 496 | 502 | http.Error(rw, "this change is not open", http.StatusBadRequest) | |
| 497 | 503 | return | |
| 498 | 504 | } | |
| 499 | _, err = landing.Land(w.landGate(m, ctx.ACL, ctx.Name), id, ctx.Name, time.Now().UTC()) | ||
| 505 | now := time.Now().UTC() | ||
| 506 | gate := w.landGate(m, ctx.ACL, ctx.Name) | ||
| 507 | |||
| 508 | // Publishing and queueing here makes the click useful even when the reconciler | ||
| 509 | // has not seen this head yet. A failure is not fatal: Prepare below reports the | ||
| 510 | // real state, and the background pass will try again. | ||
| 511 | if w.EnsureCandidate != nil { | ||
| 512 | if err := w.EnsureCandidate(m.Name, id); err != nil && w.Logger != nil { | ||
| 513 | w.Logger("web: cannot prepare the combined result for %s: %v", id, err) | ||
| 514 | } | ||
| 515 | } | ||
| 516 | |||
| 517 | plan, err := landing.Prepare(gate, id, ctx.Name, now) | ||
| 500 | 518 | if err != nil { | |
| 519 | w.mergeRefused(rw, r, m, req, err.Error()) | ||
| 520 | return | ||
| 521 | } | ||
| 522 | if len(plan.Conflicts) > 0 { | ||
| 523 | w.mergeRefused(rw, r, m, req, fmt.Sprintf( | ||
| 524 | "%s does not merge into %s: %s conflict. Resolve it on the branch and push again", | ||
| 525 | format.SHA(plan.Head), plan.Base, strings.Join(plan.Conflicts, ", "))) | ||
| 526 | return | ||
| 527 | } | ||
| 528 | if plan.Blocked != "" { | ||
| 529 | w.mergeRefused(rw, r, m, req, plan.Blocked) | ||
| 530 | return | ||
| 531 | } | ||
| 532 | if !plan.Ready { | ||
| 533 | if plan.Pending || len(plan.Gaps) == 0 { | ||
| 534 | w.armMerge(rw, r, m, req, ctx.Name, now, "Merging when the combined result passes.") | ||
| 535 | return | ||
| 536 | } | ||
| 537 | w.mergeRefused(rw, r, m, req, "The combined result failed:\n "+strings.Join(plan.Gaps, "\n ")) | ||
| 538 | return | ||
| 539 | } | ||
| 540 | |||
| 541 | _, err = landing.Land(gate, id, ctx.Name, now) | ||
| 542 | if err != nil { | ||
| 543 | // A base move between the plan and the landing is the expected race. If the | ||
| 544 | // new plan is merely pending, arm instead of making the reader click again. | ||
| 545 | if next, perr := landing.Prepare(gate, id, ctx.Name, time.Now().UTC()); perr == nil && | ||
| 546 | len(next.Conflicts) == 0 && next.Blocked == "" && !next.Ready && | ||
| 547 | (next.Pending || len(next.Gaps) == 0) { | ||
| 548 | w.armMerge(rw, r, m, req, ctx.Name, now, "The latest base moved, so Vole is testing the new combination. It will land when that passes.") | ||
| 549 | return | ||
| 550 | } | ||
| 501 | 551 | // The refusal is the answer, so it is shown as written: it names the missing | |
| 502 | 552 | // approval, the failing check, or the conflicting path. It is shown *on the | |
| 503 | 553 | // page* rather than as bare text, so the reader keeps the conflict block and | |
| 504 | 554 | // the diff that explain it. | |
| 505 | if htmxRequest(r) { | ||
| 506 | w.render(rw, "request_sidebar", w.requestSidebarWithLanding(r, m, req, "", err.Error()), 0) | ||
| 507 | return | ||
| 508 | } | ||
| 509 | w.renderRequestPage(rw, r, err.Error(), http.StatusConflict) | ||
| 555 | w.mergeRefused(rw, r, m, req, err.Error()) | ||
| 510 | 556 | return | |
| 511 | 557 | } | |
| 512 | 558 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { | |
| @@ -518,6 +564,62 @@ func (w *Web) MergeRequest(rw http.ResponseWriter, r *http.Request) { | |||
| 518 | 564 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id+"?merged=1", http.StatusSeeOther) | |
| 519 | 565 | } | |
| 520 | 566 | ||
| 567 | // armMerge records "merge when ready" and answers the click. The intent is a wait, | ||
| 568 | // not an authorisation: the worker re-runs the whole gate before it moves a ref. | ||
| 569 | func (w *Web) armMerge(rw http.ResponseWriter, r *http.Request, m *repo.Meta, req *metadata.Request, actor string, now time.Time, notice string) { | ||
| 570 | if w.Runs == nil { | ||
| 571 | w.mergeRefused(rw, r, m, req, "this instance has no CI queue, so the combined result cannot be tested here") | ||
| 572 | return | ||
| 573 | } | ||
| 574 | if err := w.Runs.ArmAutoMerge(m.Name, req.ID, actor, now); err != nil { | ||
| 575 | w.serverError(rw, r, "cannot arm merge: "+err.Error()) | ||
| 576 | return | ||
| 577 | } | ||
| 578 | w.audit("web.request.merge_when_ready", actor, m.Name, req.ID) | ||
| 579 | if htmxRequest(r) { | ||
| 580 | view := w.requestSidebarWithLanding(r, m, req, "", "") | ||
| 581 | view.Notice = notice | ||
| 582 | w.render(rw, "request_sidebar", view, 0) | ||
| 583 | return | ||
| 584 | } | ||
| 585 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+req.ID+"?merging=1", http.StatusSeeOther) | ||
| 586 | } | ||
| 587 | |||
| 588 | // CancelMerge turns off merge-when-ready. It cannot cancel a landing that already | ||
| 589 | // happened: the row is gone by then, and the redirect shows the merged page. | ||
| 590 | func (w *Web) CancelMerge(rw http.ResponseWriter, r *http.Request) { | ||
| 591 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) | ||
| 592 | if !ok { | ||
| 593 | return | ||
| 594 | } | ||
| 595 | if !ctx.canMergeChange() { | ||
| 596 | w.refuse(rw, r, "you do not have permission to merge here") | ||
| 597 | return | ||
| 598 | } | ||
| 599 | m := ctx.Repo | ||
| 600 | id := r.PathValue("id") | ||
| 601 | if w.Runs == nil { | ||
| 602 | http.Error(rw, "this instance has no CI queue", http.StatusBadRequest) | ||
| 603 | return | ||
| 604 | } | ||
| 605 | if err := w.Runs.DisarmAutoMerge(m.Name, id); err != nil { | ||
| 606 | w.serverError(rw, r, "cannot cancel merge-when-ready: "+err.Error()) | ||
| 607 | return | ||
| 608 | } | ||
| 609 | w.audit("web.request.merge_cancel", ctx.Name, m.Name, id) | ||
| 610 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther) | ||
| 611 | } | ||
| 612 | |||
| 613 | // mergeRefused renders a refusal where the reader can see the review, the conflict | ||
| 614 | // block and the controls that explain it. | ||
| 615 | func (w *Web) mergeRefused(rw http.ResponseWriter, r *http.Request, m *repo.Meta, req *metadata.Request, msg string) { | ||
| 616 | if htmxRequest(r) { | ||
| 617 | w.render(rw, "request_sidebar", w.requestSidebarWithLanding(r, m, req, "", msg), 0) | ||
| 618 | return | ||
| 619 | } | ||
| 620 | w.renderRequestPage(rw, r, msg, http.StatusConflict) | ||
| 621 | } | ||
| 622 | |||
| 521 | 623 | // closesField splits the closes input: spaces, commas and newlines all work, because | |
| 522 | 624 | // the field is one line somebody pasted issue ids into. | |
| 523 | 625 | func closesField(raw string) []string { | |