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 | } | ||