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