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.

Land a change request on the server instead of by pushing

38b3025145d8cee8479733fd4d3b0df443c72607 · Keenan Nemetz · 13d ago · parent 536f08b9

Land a change request on the server instead of by pushing

A change request could only be landed by merging locally and pushing the
base branch: the web page was a review surface with no way to finish, and
nothing recorded that the request had landed. `state: merged` was already
accepted by the request reader and rendered by the activity feed, but
nothing ever wrote it, so a merged request and an abandoned one looked
identical.

The gate that decides a landing lives in the pre-receive hook, and the
hook only sees pushes. A merge performed inside the server moves the ref
without the hook, so a server-side merge either re-runs that rule or
becomes the easiest way to land an unapproved change. It now re-runs it:
the approval and check rule moved to forge/landing, and both the hook and
the new landing operation call the same Check.

Landing is fast-forward when the base is an ancestor of the reviewed head,
so the commit that passed CI is the commit that lands; otherwise a
two-parent merge commit, which is the only other shape the gate accepts.
Conflicts are refused with the conflicting paths, never resolved. The ref
moves by compare-and-swap, so a concurrent push loses cleanly and the
landing retries; the request is marked merged after the ref moves, which
makes a retry after a crash finish the marking instead of failing.

The request now records merged_head, merged_by and merged_at, and the
reader refuses a merged request that names no commit, and refuses those
fields on a request that is not merged. "merged" is no longer settable
through the generic state writer: it has to travel with the commit.

The browser gets a Merge button that preflights the same gate, so it only
appears when the landing would succeed, and the page shows what landed,
who landed it, and when. `vole change merge` follows in its own change:
the CLI has no access to the ACL or the run queue, so it needs a verb on
the SSH channel, and that transport is worth reviewing on its own.

Closes the landing half of #01M5XF4XWHVMMCQEYENA8X32GK.

server/web/writes.go +54

@@ -16,8 +16,11 @@ import (
16 16 "strings"
17 17 "time"
18 18
19 "github.com/rodent-software/vole/ci/runs"
19 20 "github.com/rodent-software/vole/forge/access"
20 21 "github.com/rodent-software/vole/forge/index"
22 "github.com/rodent-software/vole/forge/landing"
23 "github.com/rodent-software/vole/forge/repo"
21 24 "github.com/rodent-software/vole/metadata"
22 25 )
23 26
@@ -344,3 +347,54 @@ func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) {
344 347 }
345 348 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther)
346 349 }
350
351 // landGate is the landing gate wired to the stores this process already holds. The
352 // run queue is the web's own open handle, not a path: the server is long-lived and
353 // the gate must not close it.
354 func (w *Web) landGate(m *repo.Meta, acl *access.ACL, actor string) landing.Gate {
355 return landing.Gate{
356 Repo: m.Git(), Name: m.Name, ACL: acl, Actor: actor, Role: acl.RoleFor(actor),
357 Runs: func() (*runs.Store, error) { return w.Runs, nil },
358 Audit: func(line string) {
359 w.audit("web.request.merge", actor, m.Name+" "+line)
360 },
361 }
362 }
363
364 // MergeRequest lands a change request in the browser: the same operation
365 // `vole change merge` runs, through the same gate the pre-receive hook applies to a
366 // push. The branch moves here, so without that call this handler would be the
367 // easiest way to land an unapproved change.
368 func (w *Web) MergeRequest(rw http.ResponseWriter, r *http.Request) {
369 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
370 if !ok {
371 return
372 }
373 m := ctx.Repo
374 if !ctx.canMergeChange() {
375 w.refuse(rw, r, "you do not have permission to merge here")
376 return
377 }
378 id := r.PathValue("id")
379 req, err := metadata.GetRequest(m.Git(), id)
380 if err != nil {
381 w.notFound(rw, r)
382 return
383 }
384 if !req.Open() {
385 http.Error(rw, "this change request is not open", http.StatusBadRequest)
386 return
387 }
388 res, err := landing.Land(w.landGate(m, ctx.ACL, ctx.Name), id, ctx.Name, time.Now().UTC())
389 if err != nil {
390 // The gate's refusal is the answer, so it is shown as written: it names the
391 // missing approval, the failing check, or the conflicting path.
392 http.Error(rw, err.Error(), http.StatusConflict)
393 return
394 }
395 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
396 w.Logger("web: merge left the request tree invalid: %v", problems)
397 }
398 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther)
399 _ = res
400 }