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/request_write.go +57−1

@@ -1,6 +1,7 @@
1 1 package metadata
2 2
3 3 import (
4 "errors"
4 5 "fmt"
5 6 "strings"
6 7 "time"
@@ -65,6 +66,11 @@ func renderRequest(r *Request) ([]byte, error) {
65 66 d.Word("base", r.Base)
66 67 d.Word("head", r.Head)
67 68 d.Word("created", r.Created.UTC().Format(time.RFC3339))
69 if r.State == "merged" {
70 d.Word("merged_head", r.MergedHead)
71 d.Word("merged_by", r.MergedBy)
72 d.Word("merged_at", r.MergedAt.UTC().Format(time.RFC3339))
73 }
68 74 return d.Bytes(strings.TrimRight(r.Description, "\n"))
69 75 }
70 76
@@ -172,8 +178,12 @@ func ApproveRequest(g *git.Repo, id, principal string, now time.Time, actor stri
172 178
173 179 // SetState moves a request between open and closed.
174 180 func SetRequestState(g *git.Repo, id, state, actor string) (*Request, error) {
181 // "merged" is not a state a caller may set: it is the outcome of a landing, and
182 // it has to travel with the commit that landed. Use MarkMerged, which writes the
183 // state and the commit together, so a request cannot claim a merge no commit
184 // backs.
175 185 switch state {
176 case "open", "merged", "closed":
186 case "open", "closed":
177 187 default:
178 188 return nil, fmt.Errorf("requests: state must be open or closed")
179 189 }
@@ -196,6 +206,52 @@ func SetRequestState(g *git.Repo, id, state, actor string) (*Request, error) {
196 206 return out, err
197 207 }
198 208
209 // MarkMerged records a landing: the state becomes merged and the request names the
210 // commit that landed, who landed it, and when. The commit is written in the same
211 // commit as the state, so the file cannot say "merged" and mean nothing.
212 //
213 // A request that is already merged with the same commit is left alone and returned
214 // unchanged, which makes a retry after a half-finished landing harmless.
215 func MarkMerged(g *git.Repo, id, landedHead, actor string, at time.Time) (*Request, bool, error) {
216 if !shaRE.MatchString(landedHead) {
217 return nil, false, fmt.Errorf("requests: merged head must be a full commit id, got %q", landedHead)
218 }
219 var (
220 out *Request
221 changed bool
222 )
223 base := fmt.Sprintf("%s/%s/", RequestsDir, id)
224 _, err := git.CommitFiles(g, Ref, func(files map[string][]byte) error {
225 r, err := loadFrom(files, id)
226 if err != nil {
227 return err
228 }
229 if r.State == "merged" && r.MergedHead == landedHead {
230 out = r
231 return ErrNoChange
232 }
233 r.State = "merged"
234 r.MergedHead = landedHead
235 r.MergedBy = actor
236 r.MergedAt = at.UTC()
237 raw, err := renderRequest(r)
238 if err != nil {
239 return err
240 }
241 files[base+requestName] = raw
242 out = r
243 changed = true
244 return nil
245 }, fmt.Sprintf("request: %s merged %s", id, landedHead), actor)
246 if errors.Is(err, ErrNoChange) {
247 return out, false, nil
248 }
249 if err != nil {
250 return nil, false, err
251 }
252 return out, changed, nil
253 }
254
199 255 // NewComment builds a comment on a request with a fresh id.
200 256 func NewRequestComment(body, author string, now time.Time) (*Comment, error) {
201 257 commentID, err := voleid.New(now)