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.

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