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.

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.

metadata/requests.go +18−18

@@ -12,7 +12,8 @@
12 12 // An approval names the exact commit it reviewed. That is the whole point: a
13 13 // general "I approve this request" is worthless, because the head can move
14 14 // afterwards. If the head changes, the approvals no longer match and the gate
15 // closes again.
15 // closes again. A request opened from a branch follows that branch, so a push moves
16 // its head; a request opened from a bare commit stays pinned to that commit.
16 17 //
17 18 // Landing is either a push the pre-receive hook accepts — it permits a protected
18 19 // branch when the push carries an approved request — or the server's own merge
@@ -42,7 +43,6 @@ const (
42 43 var (
43 44 shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
44 45 requestPathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})(?:/(request\.md)|/approvals/([a-z0-9][a-z0-9_-]{1,31})\.md|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`)
45 branchNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
46 46 )
47 47
48 48 type Request struct {
@@ -51,11 +51,11 @@ type Request struct {
51 51 Author string // account name: what the approval gate compares against
52 52 Base string // branch name, e.g. "main"
53 53 Head string // commit sha under review
54 // HeadRef is the branch the head was taken from when the request was opened,
55 // e.g. "contrib/alice/fix". It is a display hint, not part of the review: the
56 // commit in Head is what approvals, checks, the diff and the landing gate name.
57 // It is empty for requests opened from a bare commit, for older records, and it
58 // may no longer point at Head if the branch has moved or been deleted.
54 // HeadRef is the branch the request follows, e.g. "contrib/alice/fix". A push
55 // that moves that branch moves Head to the new tip, which makes approvals of the
56 // old tip stale and asks for checks on the new one. It is empty for requests
57 // opened from a bare commit, for older records, and for a request whose branch
58 // has been deleted (Head keeps the last tip it saw).
59 59 HeadRef string
60 60 State string // open | merged | closed
61 61 Created time.Time
@@ -256,19 +256,19 @@ func applyRequest(req *Request, raw, path string, problems *[]string) {
256 256 "%s: author %q is not an account name", path, req.Author))
257 257 }
258 258 req.Base = strings.TrimSpace(m.Base)
259 if !branchNameRE.MatchString(req.Base) {
259 if !validBranchName(req.Base) {
260 260 *problems = append(*problems, fmt.Sprintf("%s: base must be a branch name, got %q", path, req.Base))
261 261 }
262 262 req.Head = strings.TrimSpace(m.Head)
263 263 if req.Head != "" && !shaRE.MatchString(req.Head) {
264 264 *problems = append(*problems, fmt.Sprintf("%s: head must be a full 40-character commit id, got %q", path, req.Head))
265 265 }
266 // The branch is a display hint, and a branch with no commit behind it says
267 // nothing: the commit is what the request is about.
266 // HeadRef names the branch this request follows. It needs a commit behind it so
267 // a reader that cannot see the branch still knows what was last reviewed.
268 268 req.HeadRef = strings.TrimSpace(m.HeadRef)
269 269 if req.HeadRef != "" {
270 270 switch {
271 case !validHeadRef(req.HeadRef):
271 case !validBranchName(req.HeadRef):
272 272 *problems = append(*problems, fmt.Sprintf(
273 273 "%s: head_ref must be a branch name, got %q", path, req.HeadRef))
274 274 case req.Head == "":
@@ -338,12 +338,12 @@ func applyRequest(req *Request, raw, path string, problems *[]string) {
338 338 req.Description = body
339 339 }
340 340
341 // validHeadRef is the file-format check for head_ref: one line, no spaces, and not a
342 // full ref. It is deliberately looser than git's ref rules because the branch the
343 // request names may not exist yet on the reader's side, and because git is the
344 // authority where the branch does exist (Repo.ValidBranch at the writer). A reader
345 // that refused a name git accepts would reject a request that was written correctly.
346 func validHeadRef(name string) bool {
341 // validBranchName is the file-format check for base and head_ref: one line, no
342 // spaces, and not a full ref. It is deliberately looser than git's ref rules because
343 // the branch a request names may not exist yet on the reader's side, and because git
344 // is the authority where the branch does exist (Repo.ValidBranch at the writer). A
345 // reader that refused a name git accepts would reject a request written correctly.
346 func validBranchName(name string) bool {
347 347 if name == "" || strings.HasPrefix(name, "refs/") || strings.ContainsAny(name, " \t") {
348 348 return false
349 349 }
@@ -397,7 +397,7 @@ func parseApproval(principal, raw, path string) (Approval, string) {
397 397 return a, fmt.Sprintf("%s: head must be the full commit id that was reviewed", path)
398 398 }
399 399 a.Head = m.Head
400 if !branchNameRE.MatchString(m.Base) {
400 if !validBranchName(m.Base) {
401 401 return a, fmt.Sprintf("%s: base must name the branch you approved (e.g. \"main\")", path)
402 402 }
403 403 a.Base = m.Base