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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 2h ago 32290114
history blame raw 11.2 KiB
1package landing
2
3import (
4 "errors"
5 "fmt"
6 "strings"
7 "time"
8
9 "rodent.software/vole/internal/format"
10 "rodent.software/vole/metadata"
11)
12
13// Plan is what a landing would do, worked out without moving anything.
14//
15// It exists so that the code which draws a merge control and the code which performs
16// a merge cannot disagree: a page that offers "Merge" for a change that conflicts is
17// the same bug as a button that hides a landing that would have worked, and both come
18// from asking a different question in each place. Plan is the answer to the one
19// question, and Land is the act of carrying it out.
20//
21// It is not entirely free of side effects: a non-fast-forward landing needs a merge
22// commit before the gate can be asked (the approval matches a commit whose parents
23// are the base tip and the approved head), so Plan writes one unreferenced commit
24// object. Nothing visible changes, and an object nobody points at is collected.
25type Plan struct {
26 Request *metadata.Request
27 // Base is the branch that would move, OldTip what it is now, and Head the commit
28 // under review.
29 Base string
30 OldTip string
31 Head string
32 // NewTip is the commit that would land: Head for a fast-forward, the published
33 // combined result otherwise. Empty when the plan cannot land.
34 NewTip string
35 FastForward bool
36 MergeCommit bool
37 // CandidateRef is the server-only ref holding NewTip when a merge is needed.
38 // Empty for a fast-forward, which lands the head itself.
39 CandidateRef string
40 // Blocked is why the approval half of the gate would refuse this landing. It is
41 // reported rather than returned as an error so the page can show the combined
42 // result even before approvals arrive.
43 Blocked string
44 // Gaps are the required jobs that have not passed for NewTip. A plan with gaps
45 // is still useful: it says what CI is doing, not that the request is invalid.
46 Gaps []string
47 // Pending is true when every gap is a wait (queued or running) rather than a
48 // failure. It is what the page uses to offer "Merge when ready" instead of
49 // reporting a red result.
50 Pending bool
51 // Checks is true when a workflow applies to the landing tree. It separates
52 // "every required job passed" from "no workflow applies", which need different
53 // words on a page and in `change show`.
54 Checks bool
55 // Ready is true when Land would move the ref now: approvals satisfied (or not
56 // required) and every required job green for the commit that lands.
57 Ready bool
58 // AlreadyLanded is true when Head is already in OldTip: there is no ref to move,
59 // only the record to write. It is what a retry finds after a landing whose
60 // metadata write failed, and what a request landed by a push looks like.
61 AlreadyLanded bool
62 // Conflicts names the paths that conflict. A non-empty slice means the plan cannot
63 // land, and NewTip is empty. It is a reported outcome rather than an error so a
64 // page can style the paths instead of printing a sentence.
65 Conflicts []string
66 // Approval is the request and approvers that authorise the landing, nil when the
67 // branch needs no approval (unprotected, or the actor is an admin).
68 Approval *metadata.Merge
69 // ClosedIssues is filled in by Land: the issues the request linked were closed in
70 // the same commit that marked it merged.
71 ClosedIssues []string
72}
73
74// ErrLandedUnmarked is returned when the branch moved but the request could not be
75// marked merged. The landing stands — refusing it now would be a lie — so the error
76// carries the commit that landed and the caller is expected to report it and let a
77// retry finish the marking.
78type ErrLandedUnmarked struct {
79 NewTip string
80 Err error
81}
82
83func (e *ErrLandedUnmarked) Error() string {
84 return fmt.Sprintf("landed %s, but could not mark the change request merged: %v",
85 format.SHA(e.NewTip), e.Err)
86}
87func (e *ErrLandedUnmarked) Unwrap() error { return e.Err }
88
89// attempts bounds the retry loop around a ref that keeps moving. A landing is
90// cheap to recompute, so a couple of tries turn a race into a success instead of
91// an error the caller has to explain.
92const attempts = 3
93
94// Land merges an open change request into its base branch and records that it
95// happened: Plan, then the ref move, then the mark.
96//
97// It moves the ref itself, so it does not run the pre-receive hook; Plan is the same
98// gate the hook would have applied, asked in the same terms and answered in the same
99// words. A landing the hook would refuse is refused here.
100//
101// The ref moves before the request is marked merged. If the process dies between the
102// two, the branch is correct and the request still looks open; running the same
103// command again finds the head already in the base and only writes the mark.
104func Land(g Gate, id, actor string, now time.Time) (*Plan, error) {
105 var lastRace error
106 for attempt := 0; attempt < attempts; attempt++ {
107 plan, err := Prepare(g, id, actor, now)
108 if err != nil {
109 return plan, err
110 }
111 if len(plan.Conflicts) > 0 {
112 return plan, fmt.Errorf(
113 "%s does not merge into %s: %s conflict. Resolve it on the branch and push again",
114 format.SHA(plan.Head), plan.Base, strings.Join(plan.Conflicts, ", "))
115 }
116 if plan.Blocked != "" {
117 return plan, errors.New(plan.Blocked)
118 }
119 if len(plan.Gaps) > 0 {
120 return plan, fmt.Errorf("checks have not passed for %s:\n %s",
121 format.SHA(plan.NewTip), strings.Join(plan.Gaps, "\n "))
122 }
123 if plan.AlreadyLanded {
124 if plan.Request.State == "merged" && plan.Request.MergedHead != "" {
125 return plan, nil
126 }
127 // The base already contains the head, so there is no "before" left to
128 // name: plan.OldTip is the moved tip, not the one the landing started
129 // from. Marking without a base keeps the record honest.
130 closed, err := marked(g, id, "", plan.Head, plan.Head, actor, now)
131 if err != nil {
132 return plan, &ErrLandedUnmarked{NewTip: plan.OldTip, Err: err}
133 }
134 plan.ClosedIssues = closed
135 return plan, nil
136 }
137 ok, err := g.Repo.UpdateRefCAS(g.Ref(plan.Base), plan.NewTip, plan.OldTip)
138 if err != nil {
139 return plan, err
140 }
141 if !ok {
142 lastRace = fmt.Errorf("%s moved while this landing was being prepared; try again", plan.Base)
143 continue // another writer won; recompute against the new tip
144 }
145 if g.Audit != nil {
146 if plan.Approval != nil {
147 g.Audit("merge", actor, g.Name, fmt.Sprintf("%s via request %s (head %s) approved by %s",
148 g.Ref(plan.Base), plan.Approval.Request.ID,
149 format.SHA(plan.Approval.Request.Head), strings.Join(plan.Approval.Approvers, ",")))
150 } else {
151 g.Audit("merge", actor, g.Name,
152 fmt.Sprintf("%s (head %s)", g.Ref(plan.Base), format.SHA(plan.NewTip)))
153 }
154 }
155 closed, err := marked(g, id, plan.OldTip, plan.NewTip, plan.Head, actor, now)
156 if err != nil {
157 return plan, &ErrLandedUnmarked{NewTip: plan.NewTip, Err: err}
158 }
159 plan.ClosedIssues = closed
160 return plan, nil
161 }
162 return nil, lastRace
163}
164
165// Prepare works out what landing this request would do, and refuses the same things
166// Land refuses. It is what a page asks before it offers a merge control, and what Land
167// asks before it moves anything, so the two cannot disagree.
168func Prepare(g Gate, id, actor string, now time.Time) (*Plan, error) {
169 if g.Repo == nil {
170 return nil, errors.New("no repository to land in")
171 }
172 req, err := metadata.GetRequest(g.Repo, id)
173 if err != nil {
174 return nil, err
175 }
176 branch := req.Base
177 oldTip, err := g.Repo.RevParse(g.Ref(branch))
178 if err != nil {
179 return nil, err
180 }
181 if oldTip == "" {
182 return nil, fmt.Errorf("%s does not exist, so there is nothing to land into", g.Ref(branch))
183 }
184 if req.Head == "" {
185 return nil, fmt.Errorf("change request %s names no head commit", id)
186 }
187 head, err := g.Repo.RevParse(req.Head)
188 if err != nil {
189 return nil, err
190 }
191 if head == "" {
192 return nil, fmt.Errorf(
193 "change request %s names head %s, which is not in this repository; push the branch again",
194 id, format.SHA(req.Head))
195 }
196 plan := &Plan{Request: req, Base: branch, OldTip: oldTip, Head: head, NewTip: head}
197 if req.State == "closed" {
198 return plan, fmt.Errorf("change request %s is closed; reopen it before merging", id)
199 }
200
201 // Already in the base branch: nothing to merge, only the record to write.
202 if landed, err := g.Repo.IsAncestor(head, oldTip); err != nil {
203 return plan, err
204 } else if landed {
205 plan.AlreadyLanded = true
206 plan.NewTip = oldTip
207 // There is nothing left to test: the commit is in the branch. Land only has
208 // the record to write, and a caller that armed a merge must not wait forever.
209 plan.Ready = true
210 return plan, nil
211 }
212
213 ff, err := g.Repo.IsAncestor(oldTip, head)
214 if err != nil {
215 return plan, err
216 }
217 if ff {
218 // The commit that passed CI is the commit that lands.
219 plan.FastForward = true
220 } else {
221 // Publish the combined result before asking about it: the gate reads the
222 // candidate ref, and the runner needs the ref to fetch the commit. A
223 // conflict is a refusal, not a resolution; the branch has to change.
224 cand, conflicts, err := EnsureCandidate(g.Repo, req, oldTip, head)
225 if err != nil {
226 return plan, err
227 }
228 if len(conflicts) > 0 {
229 plan.Conflicts = conflicts
230 plan.NewTip = ""
231 return plan, nil
232 }
233 plan.MergeCommit = true
234 plan.NewTip = cand.SHA
235 plan.CandidateRef = cand.Ref
236 }
237
238 // The approval half of the gate is asked separately from the checks so the page
239 // can show "testing the combined result" instead of treating a queued run as a
240 // refusal.
241 needsApproval := g.RequiresApprovals(branch) && !g.Admin()
242 if needsApproval {
243 merge, refusal, err := g.Approve(branch, plan.NewTip, oldTip)
244 if err != nil {
245 return plan, err
246 }
247 plan.Approval = merge
248 plan.Blocked = refusal
249 }
250 // The commit whose checks count is the one that lands: the candidate for a
251 // merge, the head for a fast-forward. Even an unapproved request gets this, so
252 // the page can show the combined result before anybody approves.
253 if needsApproval && g.ACL.RequiresChecks() {
254 plan.Checks, err = HasRequiredChecks(g.Repo, plan.NewTip, branch)
255 if err != nil {
256 return plan, err
257 }
258 if plan.Checks {
259 gaps, pending, err := g.CheckSummary(plan.NewTip, branch)
260 if err != nil {
261 return plan, err
262 }
263 plan.Gaps = gaps
264 plan.Pending = pending
265 }
266 }
267 plan.Ready = len(plan.Conflicts) == 0 && plan.Blocked == "" && len(plan.Gaps) == 0
268 return plan, nil
269}
270
271// marked writes the merged state and turns a validation problem into an error, so a
272// landing never leaves a request file the metadata reader would refuse. The base tip
273// is what the branch pointed at before it moved: it is the only record of what the
274// merge changed once the head is in the base. reviewedHead is the commit the
275// approvals named, which is frozen into head because the followed branch may have
276// moved after the landing was prepared.
277func marked(g Gate, id, baseTip, head, reviewedHead, actor string, now time.Time) ([]string, error) {
278 _, closed, err := metadata.MarkMerged(g.Repo, id, baseTip, head, reviewedHead, actor, now)
279 if err != nil {
280 return nil, err
281 }
282 if problems := metadata.ValidateRequests(g.Repo, metadata.Ref); len(problems) > 0 {
283 return nil, fmt.Errorf("the request tree is invalid after marking it merged: %s", strings.Join(problems, "; "))
284 }
285 if problems := metadata.ValidateIssues(g.Repo, metadata.Ref); len(problems) > 0 {
286 return nil, fmt.Errorf("the issue tree is invalid after closing the linked issues: %s", strings.Join(problems, "; "))
287 }
288 return closed, nil
289}
290