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.
metadata/requests.go +45−8
| @@ -51,8 +51,15 @@ type Request struct { | |||
| 51 | 51 | State string // open | merged | closed | |
| 52 | 52 | Created time.Time | |
| 53 | 53 | Description string | |
| 54 | Approvals []Approval | ||
| 55 | Comments []Comment | ||
| 54 | // MergedHead is the commit that landed on Base, which is the head itself for a | ||
| 55 | // fast-forward and a merge commit otherwise. It is empty unless State is | ||
| 56 | // "merged": a request that claims a landing has to name one, so the file can | ||
| 57 | // never say "merged" without a commit behind it. | ||
| 58 | MergedHead string | ||
| 59 | MergedBy string // account that ran the merge | ||
| 60 | MergedAt time.Time | ||
| 61 | Approvals []Approval | ||
| 62 | Comments []Comment | ||
| 56 | 63 | } | |
| 57 | 64 | ||
| 58 | 65 | // Comment is a remark on a request. It is the same file shape as a comment on an | |
| @@ -84,12 +91,15 @@ func (r *Request) ApprovedBy(principal string) bool { | |||
| 84 | 91 | // meta is the frontmatter head of request.md. Field names are part of the | |
| 85 | 92 | // public format. There is no updated field: the commit carries the time. | |
| 86 | 93 | type requestMeta struct { | |
| 87 | Title string `yaml:"title"` | ||
| 88 | Author string `yaml:"author"` | ||
| 89 | Base string `yaml:"base"` | ||
| 90 | Head string `yaml:"head"` | ||
| 91 | State string `yaml:"state"` | ||
| 92 | Created string `yaml:"created"` | ||
| 94 | Title string `yaml:"title"` | ||
| 95 | Author string `yaml:"author"` | ||
| 96 | Base string `yaml:"base"` | ||
| 97 | Head string `yaml:"head"` | ||
| 98 | State string `yaml:"state"` | ||
| 99 | Created string `yaml:"created"` | ||
| 100 | MergedHead string `yaml:"merged_head"` | ||
| 101 | MergedBy string `yaml:"merged_by"` | ||
| 102 | MergedAt string `yaml:"merged_at"` | ||
| 93 | 103 | } | |
| 94 | 104 | ||
| 95 | 105 | // Load reads and validates every request on the metadata branch. | |
| @@ -246,6 +256,33 @@ func applyRequest(req *Request, raw, path string, problems *[]string) { | |||
| 246 | 256 | } else { | |
| 247 | 257 | req.Created = t.UTC() | |
| 248 | 258 | } | |
| 259 | // The merged fields are the record of a landing, so they are all-or-nothing and | ||
| 260 | // only exist on a merged request. A file that says "merged" without naming a | ||
| 261 | // commit is refused rather than shown as landed, and a stale merged_head left on | ||
| 262 | // a reopened request is refused too: the next landing writes a new one. | ||
| 263 | req.MergedHead = strings.TrimSpace(m.MergedHead) | ||
| 264 | req.MergedBy = strings.TrimSpace(m.MergedBy) | ||
| 265 | if req.State == "merged" { | ||
| 266 | if !shaRE.MatchString(req.MergedHead) { | ||
| 267 | *problems = append(*problems, fmt.Sprintf( | ||
| 268 | "%s: state is merged, so merged_head must be the full commit id that landed", path)) | ||
| 269 | } | ||
| 270 | if req.MergedBy == "" { | ||
| 271 | *problems = append(*problems, fmt.Sprintf("%s: state is merged, so merged_by is required", path)) | ||
| 272 | } else if !assigneeRE.MatchString(req.MergedBy) { | ||
| 273 | *problems = append(*problems, fmt.Sprintf("%s: merged_by %q is not an account name", path, req.MergedBy)) | ||
| 274 | } | ||
| 275 | if m.MergedAt == "" { | ||
| 276 | *problems = append(*problems, fmt.Sprintf("%s: state is merged, so merged_at is required", path)) | ||
| 277 | } else if t, err := time.Parse(time.RFC3339, m.MergedAt); err != nil { | ||
| 278 | *problems = append(*problems, fmt.Sprintf("%s: merged_at must be RFC3339: %v", path, err)) | ||
| 279 | } else { | ||
| 280 | req.MergedAt = t.UTC() | ||
| 281 | } | ||
| 282 | } else if req.MergedHead != "" || req.MergedBy != "" || strings.TrimSpace(m.MergedAt) != "" { | ||
| 283 | *problems = append(*problems, fmt.Sprintf( | ||
| 284 | "%s: merged_head, merged_by and merged_at only belong on a merged request (state is %q)", path, req.State)) | ||
| 285 | } | ||
| 249 | 286 | req.Description = body | |
| 250 | 287 | } | |
| 251 | 288 | ||