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/web/writes.go +68−12
| @@ -195,7 +195,17 @@ func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) { | |||
| 195 | 195 | sidebar := func(msg string) (string, any) { | |
| 196 | 196 | return "request_sidebar", w.requestSidebar(r, m, req, "", msg) | |
| 197 | 197 | } | |
| 198 | if _, err := metadata.ApproveRequest(m.Git(), id, ctx.Name, time.Now().UTC(), ctx.Name); err != nil { | ||
| 198 | // The form carries the base and head the page displayed. If a push moved the | ||
| 199 | // followed branch, or the request was retargeted, the writer refuses rather than | ||
| 200 | // approving a target the approver never saw. | ||
| 201 | expectedBase := strings.TrimSpace(r.PostFormValue("base")) | ||
| 202 | expectedHead := strings.TrimSpace(r.PostFormValue("head")) | ||
| 203 | if expectedBase == "" || expectedHead == "" { | ||
| 204 | w.fail(rw, r, http.StatusBadRequest, | ||
| 205 | "reload the request before approving: the form did not name the base and head you reviewed", sidebar) | ||
| 206 | return | ||
| 207 | } | ||
| 208 | if _, err := metadata.ApproveRequest(m.Git(), id, ctx.Name, expectedBase, expectedHead, time.Now().UTC(), ctx.Name); err != nil { | ||
| 199 | 209 | w.fail(rw, r, http.StatusBadRequest, "cannot approve: "+err.Error(), sidebar) | |
| 200 | 210 | return | |
| 201 | 211 | } | |
| @@ -242,9 +252,9 @@ func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) { | |||
| 242 | 252 | http.Error(rw, "choose a base and a head branch", http.StatusBadRequest) | |
| 243 | 253 | return | |
| 244 | 254 | } | |
| 245 | // The head is a commit; the branch it came from is recorded as a display hint. | ||
| 246 | // It has to be a real branch name, or a hand-edited form could store something | ||
| 247 | // the metadata reader would refuse. | ||
| 255 | // The head starts at the branch tip and the request follows that branch afterwards. | ||
| 256 | // It has to be a real branch name, or a hand-edited form could store something the | ||
| 257 | // metadata reader would refuse. | ||
| 248 | 258 | if !m.Git().ValidBranch(head) { | |
| 249 | 259 | http.Error(rw, "that head branch does not exist yet; push it first", http.StatusBadRequest) | |
| 250 | 260 | return | |
| @@ -359,11 +369,10 @@ func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) { | |||
| 359 | 369 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther) | |
| 360 | 370 | } | |
| 361 | 371 | ||
| 362 | // UpdateRequestHead points an open change request at the tip of a branch. A request | ||
| 363 | // names a commit, not a branch, so a fixup pushed after it was opened is invisible | ||
| 364 | // until the head moves. The CLI can do that, but only by writing the metadata | ||
| 365 | // branch, which a contributor cannot push; this is the same move on the surface a | ||
| 366 | // contributor can already write to. | ||
| 372 | // UpdateRequestHead repoints an open change request at another branch. The request | ||
| 373 | // already follows the branch it was opened from, so this is only for changing which | ||
| 374 | // branch it follows, or for attaching a request that was opened from a bare commit. | ||
| 375 | // Pushes move the head from then on. | ||
| 367 | 376 | // | |
| 368 | 377 | // The head must be a branch tip, and a contributor may only name a branch in their | |
| 369 | 378 | // own namespace — the same rule the browser applies when opening a request, so the | |
| @@ -404,12 +413,12 @@ func (w *Web) UpdateRequestHead(rw http.ResponseWriter, r *http.Request) { | |||
| 404 | 413 | http.Error(rw, "that branch does not exist; push it first", http.StatusBadRequest) | |
| 405 | 414 | return | |
| 406 | 415 | } | |
| 407 | if tip != req.Head { | ||
| 408 | if _, err := metadata.SetRequestHead(m.Git(), id, tip, ctx.Name); err != nil { | ||
| 416 | if tip != req.Head || branch != req.HeadRef { | ||
| 417 | if _, err := metadata.SetRequestHead(m.Git(), id, tip, branch, ctx.Name); err != nil { | ||
| 409 | 418 | w.serverError(rw, r, "cannot move the change request's head: "+err.Error()) | |
| 410 | 419 | return | |
| 411 | 420 | } | |
| 412 | w.audit("web.request.head", ctx.Name, m.Name+" "+id+" "+req.Head+"->"+tip) | ||
| 421 | w.audit("web.request.head", ctx.Name, m.Name+" "+id+" "+req.Head+"->"+tip+" following "+branch) | ||
| 413 | 422 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { | |
| 414 | 423 | w.Logger("web: request head %s left the request tree invalid: %v", id, problems) | |
| 415 | 424 | } | |
| @@ -417,6 +426,53 @@ func (w *Web) UpdateRequestHead(rw http.ResponseWriter, r *http.Request) { | |||
| 417 | 426 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther) | |
| 418 | 427 | } | |
| 419 | 428 | ||
| 429 | // UpdateRequestBase retargets an open change request at another base branch. This is | ||
| 430 | // how a stacked request is repointed when the branch below it lands, or when it | ||
| 431 | // should land somewhere else. A new base changes the diff, so the approvals that | ||
| 432 | // named the old base stop counting. | ||
| 433 | func (w *Web) UpdateRequestBase(rw http.ResponseWriter, r *http.Request) { | ||
| 434 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) | ||
| 435 | if !ok { | ||
| 436 | return | ||
| 437 | } | ||
| 438 | m := ctx.Repo | ||
| 439 | id := r.PathValue("id") | ||
| 440 | req, err := metadata.GetRequest(m.Git(), id) | ||
| 441 | if err != nil { | ||
| 442 | w.notFound(rw, r) | ||
| 443 | return | ||
| 444 | } | ||
| 445 | if !ctx.canTriage(req.Author) { | ||
| 446 | w.refuse(rw, r, "you do not have permission to change this change request's base") | ||
| 447 | return | ||
| 448 | } | ||
| 449 | if !req.Open() { | ||
| 450 | w.refuse(rw, r, "only an open change request can change its base") | ||
| 451 | return | ||
| 452 | } | ||
| 453 | base := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("base")), "refs/heads/") | ||
| 454 | metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/") | ||
| 455 | if base == "" || base == metadataBranch || !m.Git().ValidBranch(base) { | ||
| 456 | http.Error(rw, "choose the branch this change should land on", http.StatusBadRequest) | ||
| 457 | return | ||
| 458 | } | ||
| 459 | if tip, err := m.Git().RevParse("refs/heads/" + base); err != nil || tip == "" { | ||
| 460 | http.Error(rw, "that base branch does not exist", http.StatusBadRequest) | ||
| 461 | return | ||
| 462 | } | ||
| 463 | if base != req.Base { | ||
| 464 | if _, err := metadata.SetRequestBase(m.Git(), id, base, ctx.Name); err != nil { | ||
| 465 | w.serverError(rw, r, "cannot change the change request's base: "+err.Error()) | ||
| 466 | return | ||
| 467 | } | ||
| 468 | w.audit("web.request.base", ctx.Name, m.Name+" "+id+" "+req.Base+"->"+base) | ||
| 469 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { | ||
| 470 | w.Logger("web: request base %s left the request tree invalid: %v", id, problems) | ||
| 471 | } | ||
| 472 | } | ||
| 473 | http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther) | ||
| 474 | } | ||
| 475 | |||
| 420 | 476 | // landGate is the landing gate wired to the stores this process already holds. The | |
| 421 | 477 | // run queue is the web's own open handle, not a path: the server is long-lived and | |
| 422 | 478 | // the gate must not close it. | |