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.

Land a change request on the server instead of by pushing

38b3025145d8cee8479733fd4d3b0df443c72607 · Keenan Nemetz · 14d 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.

forge/landing/land.go +205

@@ -0,0 +1,205 @@
1 package landing
2
3 import (
4 "errors"
5 "fmt"
6 "strings"
7 "time"
8
9 "github.com/rodent-software/vole/internal/format"
10 "github.com/rodent-software/vole/metadata"
11 )
12
13 // Result describes one landing.
14 type Result struct {
15 Request *metadata.Request
16 // Base is the branch written, NewTip is the commit now on it and OldTip is what
17 // it replaced. They differ from the request's head when the landing needed a
18 // merge commit.
19 Base string
20 OldTip string
21 NewTip string
22 // Approval is the request and approvers that authorised the landing. It is nil
23 // when the landing needed no approval (an unprotected branch, or an admin).
24 Approval *metadata.Merge
25 // FastForward is true when NewTip is the reviewed head itself, which is the
26 // usual case: nothing new was written, so the commit that passed CI is the
27 // commit that landed.
28 FastForward bool
29 // MergeCommit is true when a two-parent commit of the base tip and the reviewed
30 // head was written, which is the only other shape the gate accepts.
31 MergeCommit bool
32 // AlreadyLanded is true when the head was already in the base branch: a retry of
33 // a landing whose metadata write failed, or a request that was landed by pushing
34 // the branch before this command existed.
35 AlreadyLanded bool
36 }
37
38 // ErrLandedUnmarked is returned when the branch moved but the request could not be
39 // marked merged. The landing stands — refusing it now would be a lie — so the error
40 // carries the commit that landed and the caller is expected to report it and let a
41 // retry finish the marking.
42 type ErrLandedUnmarked struct {
43 NewTip string
44 Err error
45 }
46
47 func (e *ErrLandedUnmarked) Error() string {
48 return fmt.Sprintf("landed %s, but could not mark the change request merged: %v",
49 format.SHA(e.NewTip), e.Err)
50 }
51 func (e *ErrLandedUnmarked) Unwrap() error { return e.Err }
52
53 // attempts bounds the retry loop around a ref that keeps moving. A landing is
54 // cheap to recompute, so a couple of tries turn a race into a success instead of
55 // an error the caller has to explain.
56 const attempts = 3
57
58 // Land merges an open change request into its base branch and records that it
59 // happened.
60 //
61 // It moves the ref itself, so it does not run the pre-receive hook; g.Check is the
62 // same gate the hook would have applied, asked in the same terms and answered in the
63 // same words. A landing that the hook would refuse is refused here.
64 //
65 // The ref moves before the request is marked merged. If the process dies between
66 // the two, the branch is correct and the request still looks open; running the same
67 // command again finds the head already in the base and only writes the mark.
68 func Land(g Gate, id, actor string, now time.Time) (*Result, error) {
69 var lastRace error
70 for attempt := 0; attempt < attempts; attempt++ {
71 res, done, err := landOnce(g, id, actor, now)
72 if err != nil {
73 return res, err
74 }
75 if done {
76 return res, nil
77 }
78 lastRace = fmt.Errorf("%s moved while this landing was being prepared; try again", res.Base)
79 }
80 return nil, lastRace
81 }
82
83 // landOnce is one attempt. done is false only when the compare-and-swap lost a race
84 // with another writer.
85 func landOnce(g Gate, id, actor string, now time.Time) (*Result, bool, error) {
86 if g.Repo == nil {
87 return nil, false, errors.New("no repository to land in")
88 }
89 req, err := metadata.GetRequest(g.Repo, id)
90 if err != nil {
91 return nil, false, err
92 }
93 branch := req.Base
94 ref := g.Ref(branch)
95 oldTip, err := g.Repo.RevParse(ref)
96 if err != nil {
97 return nil, false, err
98 }
99 if oldTip == "" {
100 return nil, false, fmt.Errorf("%s does not exist, so there is nothing to land into", ref)
101 }
102 if req.Head == "" {
103 return nil, false, fmt.Errorf("change request %s names no head commit", id)
104 }
105 head, err := g.Repo.RevParse(req.Head)
106 if err != nil {
107 return nil, false, err
108 }
109 if head == "" {
110 return nil, false, fmt.Errorf(
111 "change request %s names head %s, which is not in this repository; push the branch again",
112 id, format.SHA(req.Head))
113 }
114 res := &Result{Request: req, Base: branch, OldTip: oldTip, NewTip: head}
115 if req.State == "closed" {
116 return res, false, fmt.Errorf("change request %s is closed; reopen it before merging", id)
117 }
118
119 // Already in the base branch: nothing to merge, only the record to write. This is
120 // the retry path after the branch moved and the mark did not, and the path for a
121 // request whose branch was landed by a push.
122 if landed, err := g.Repo.IsAncestor(head, oldTip); err != nil {
123 return res, false, err
124 } else if landed {
125 res.AlreadyLanded = true
126 res.NewTip = oldTip
127 if req.State == "merged" && req.MergedHead != "" {
128 return res, true, nil
129 }
130 if err := marked(g, id, head, actor, now); err != nil {
131 return res, false, &ErrLandedUnmarked{NewTip: oldTip, Err: err}
132 }
133 return res, true, nil
134 }
135
136 // Fast-forward when the base is an ancestor of the reviewed head: the commit that
137 // passed CI is then exactly the commit that lands, which is the property worth
138 // keeping. Otherwise a two-parent merge commit, the only other shape the gate
139 // accepts, so the reviewed head is still one of its parents.
140 ff, err := g.Repo.IsAncestor(oldTip, head)
141 if err != nil {
142 return res, false, err
143 }
144 newTip := head
145 if ff {
146 res.FastForward = true
147 } else {
148 tree, conflicts, err := g.Repo.MergeTree(oldTip, head)
149 if err != nil {
150 return res, false, err
151 }
152 if len(conflicts) > 0 {
153 return res, false, fmt.Errorf(
154 "%s does not merge into %s: %s conflict. Resolve it on the branch and push again",
155 format.SHA(head), branch, strings.Join(conflicts, ", "))
156 }
157 commit, err := g.Repo.CommitMergeTree(tree, []string{oldTip, head},
158 fmt.Sprintf("Merge change request %s: %s", req.ID, req.Title), actor)
159 if err != nil {
160 return res, false, err
161 }
162 newTip = commit
163 res.MergeCommit = true
164 }
165 res.NewTip = newTip
166
167 // The same question the hook would have asked of a push of this commit.
168 merge, err := g.Check(branch, newTip, oldTip)
169 if err != nil {
170 return res, false, err
171 }
172 res.Approval = merge
173
174 ok, err := g.Repo.UpdateRefCAS(ref, newTip, oldTip)
175 if err != nil {
176 return res, false, err
177 }
178 if !ok {
179 return res, false, nil // another writer won; the caller retries
180 }
181 if g.Audit != nil {
182 if merge != nil {
183 g.Audit(fmt.Sprintf("merge %s via request %s (head %s) approved by %s",
184 ref, merge.Request.ID, format.SHA(merge.Request.Head), strings.Join(merge.Approvers, ",")))
185 } else {
186 g.Audit(fmt.Sprintf("merge %s by %s (head %s)", ref, actor, format.SHA(newTip)))
187 }
188 }
189 if err := marked(g, id, newTip, actor, now); err != nil {
190 return res, false, &ErrLandedUnmarked{NewTip: newTip, Err: err}
191 }
192 return res, true, nil
193 }
194
195 // marked writes the merged state and turns a validation problem into an error, so a
196 // landing never leaves a request file the metadata reader would refuse.
197 func marked(g Gate, id, head, actor string, now time.Time) error {
198 if _, _, err := metadata.MarkMerged(g.Repo, id, head, actor, now); err != nil {
199 return err
200 }
201 if problems := metadata.ValidateRequests(g.Repo, metadata.Ref); len(problems) > 0 {
202 return fmt.Errorf("the request tree is invalid after marking it merged: %s", strings.Join(problems, "; "))
203 }
204 return nil
205 }