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.

Blame · forge/landing/land.go

Line attribution on main · 289 line(s)

38b30251
Keenan Nemetz
14d ago
1 package landing
2
3 import (
4 "errors"
5 "fmt"
6 "strings"
7 "time"
8
d37d301f
Keenan Nemetz
21h ago
9 "rodent.software/vole/internal/format"
10 "rodent.software/vole/metadata"
38b30251
Keenan Nemetz
14d ago
11 )
12
097b2110
Keenan Nemetz
14d ago
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.
25 type Plan struct {
38b30251
Keenan Nemetz
14d ago
26 Request *metadata.Request
097b2110
Keenan Nemetz
14d ago
27 // Base is the branch that would move, OldTip what it is now, and Head the commit
28 // under review.
38b30251
Keenan Nemetz
14d ago
29 Base string
30 OldTip string
097b2110
Keenan Nemetz
14d ago
31 Head string
e5478085
Keenan Nemetz
11d ago
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.
097b2110
Keenan Nemetz
14d ago
34 NewTip string
38b30251
Keenan Nemetz
14d ago
35 FastForward bool
36 MergeCommit bool
e5478085
Keenan Nemetz
11d ago
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
097b2110
Keenan Nemetz
14d ago
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.
38b30251
Keenan Nemetz
14d ago
61 AlreadyLanded bool
097b2110
Keenan Nemetz
14d ago
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
183d48e5
Keenan Nemetz
14d ago
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
38b30251
Keenan Nemetz
14d ago
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.
78 type ErrLandedUnmarked struct {
79 NewTip string
80 Err error
81 }
82
83 func (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 }
87 func (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.
92 const attempts = 3
93
94 // Land merges an open change request into its base branch and records that it
097b2110
Keenan Nemetz
14d ago
95 // happened: Plan, then the ref move, then the mark.
38b30251
Keenan Nemetz
14d ago
96 //
097b2110
Keenan Nemetz
14d ago
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.
38b30251
Keenan Nemetz
14d ago
100 //
097b2110
Keenan Nemetz
14d ago
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
38b30251
Keenan Nemetz
14d ago
103 // command again finds the head already in the base and only writes the mark.
097b2110
Keenan Nemetz
14d ago
104 func Land(g Gate, id, actor string, now time.Time) (*Plan, error) {
38b30251
Keenan Nemetz
14d ago
105 var lastRace error
106 for attempt := 0; attempt < attempts; attempt++ {
097b2110
Keenan Nemetz
14d ago
107 plan, err := Prepare(g, id, actor, now)
38b30251
Keenan Nemetz
14d ago
108 if err != nil {
097b2110
Keenan Nemetz
14d ago
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 }
e5478085
Keenan Nemetz
11d ago
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 }
097b2110
Keenan Nemetz
14d ago
123 if plan.AlreadyLanded {
124 if plan.Request.State == "merged" && plan.Request.MergedHead != "" {
125 return plan, nil
126 }
2841d922
Keenan Nemetz
13d ago
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.
e050e14a
Keenan Nemetz
13d ago
130 closed, err := marked(g, id, "", plan.Head, plan.Head, actor, now)
183d48e5
Keenan Nemetz
14d ago
131 if err != nil {
097b2110
Keenan Nemetz
14d ago
132 return plan, &ErrLandedUnmarked{NewTip: plan.OldTip, Err: err}
133 }
183d48e5
Keenan Nemetz
14d ago
134 plan.ClosedIssues = closed
097b2110
Keenan Nemetz
14d ago
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
38b30251
Keenan Nemetz
14d ago
144 }
097b2110
Keenan Nemetz
14d ago
145 if g.Audit != nil {
146 if plan.Approval != nil {
b1499c72
Keenan Nemetz
13d ago
147 g.Audit("merge", actor, g.Name, fmt.Sprintf("%s via request %s (head %s) approved by %s",
097b2110
Keenan Nemetz
14d ago
148 g.Ref(plan.Base), plan.Approval.Request.ID,
149 format.SHA(plan.Approval.Request.Head), strings.Join(plan.Approval.Approvers, ",")))
150 } else {
b1499c72
Keenan Nemetz
13d ago
151 g.Audit("merge", actor, g.Name,
152 fmt.Sprintf("%s (head %s)", g.Ref(plan.Base), format.SHA(plan.NewTip)))
097b2110
Keenan Nemetz
14d ago
153 }
38b30251
Keenan Nemetz
14d ago
154 }
e050e14a
Keenan Nemetz
13d ago
155 closed, err := marked(g, id, plan.OldTip, plan.NewTip, plan.Head, actor, now)
183d48e5
Keenan Nemetz
14d ago
156 if err != nil {
097b2110
Keenan Nemetz
14d ago
157 return plan, &ErrLandedUnmarked{NewTip: plan.NewTip, Err: err}
158 }
183d48e5
Keenan Nemetz
14d ago
159 plan.ClosedIssues = closed
097b2110
Keenan Nemetz
14d ago
160 return plan, nil
38b30251
Keenan Nemetz
14d ago
161 }
162 return nil, lastRace
163 }
164
097b2110
Keenan Nemetz
14d ago
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.
168 func Prepare(g Gate, id, actor string, now time.Time) (*Plan, error) {
38b30251
Keenan Nemetz
14d ago
169 if g.Repo == nil {
097b2110
Keenan Nemetz
14d ago
170 return nil, errors.New("no repository to land in")
38b30251
Keenan Nemetz
14d ago
171 }
172 req, err := metadata.GetRequest(g.Repo, id)
173 if err != nil {
097b2110
Keenan Nemetz
14d ago
174 return nil, err
38b30251
Keenan Nemetz
14d ago
175 }
176 branch := req.Base
097b2110
Keenan Nemetz
14d ago
177 oldTip, err := g.Repo.RevParse(g.Ref(branch))
38b30251
Keenan Nemetz
14d ago
178 if err != nil {
097b2110
Keenan Nemetz
14d ago
179 return nil, err
38b30251
Keenan Nemetz
14d ago
180 }
181 if oldTip == "" {
097b2110
Keenan Nemetz
14d ago
182 return nil, fmt.Errorf("%s does not exist, so there is nothing to land into", g.Ref(branch))
38b30251
Keenan Nemetz
14d ago
183 }
184 if req.Head == "" {
097b2110
Keenan Nemetz
14d ago
185 return nil, fmt.Errorf("change request %s names no head commit", id)
38b30251
Keenan Nemetz
14d ago
186 }
187 head, err := g.Repo.RevParse(req.Head)
188 if err != nil {
097b2110
Keenan Nemetz
14d ago
189 return nil, err
38b30251
Keenan Nemetz
14d ago
190 }
191 if head == "" {
097b2110
Keenan Nemetz
14d ago
192 return nil, fmt.Errorf(
38b30251
Keenan Nemetz
14d ago
193 "change request %s names head %s, which is not in this repository; push the branch again",
194 id, format.SHA(req.Head))
195 }
097b2110
Keenan Nemetz
14d ago
196 plan := &Plan{Request: req, Base: branch, OldTip: oldTip, Head: head, NewTip: head}
38b30251
Keenan Nemetz
14d ago
197 if req.State == "closed" {
097b2110
Keenan Nemetz
14d ago
198 return plan, fmt.Errorf("change request %s is closed; reopen it before merging", id)
38b30251
Keenan Nemetz
14d ago
199 }
200
097b2110
Keenan Nemetz
14d ago
201 // Already in the base branch: nothing to merge, only the record to write.
38b30251
Keenan Nemetz
14d ago
202 if landed, err := g.Repo.IsAncestor(head, oldTip); err != nil {
097b2110
Keenan Nemetz
14d ago
203 return plan, err
38b30251
Keenan Nemetz
14d ago
204 } else if landed {
097b2110
Keenan Nemetz
14d ago
205 plan.AlreadyLanded = true
206 plan.NewTip = oldTip
e5478085
Keenan Nemetz
11d ago
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
097b2110
Keenan Nemetz
14d ago
210 return plan, nil
38b30251
Keenan Nemetz
14d ago
211 }
212
213 ff, err := g.Repo.IsAncestor(oldTip, head)
214 if err != nil {
097b2110
Keenan Nemetz
14d ago
215 return plan, err
38b30251
Keenan Nemetz
14d ago
216 }
217 if ff {
097b2110
Keenan Nemetz
14d ago
218 // The commit that passed CI is the commit that lands.
219 plan.FastForward = true
38b30251
Keenan Nemetz
14d ago
220 } else {
e5478085
Keenan Nemetz
11d ago
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)
38b30251
Keenan Nemetz
14d ago
225 if err != nil {
097b2110
Keenan Nemetz
14d ago
226 return plan, err
38b30251
Keenan Nemetz
14d ago
227 }
228 if len(conflicts) > 0 {
097b2110
Keenan Nemetz
14d ago
229 plan.Conflicts = conflicts
230 plan.NewTip = ""
231 return plan, nil
38b30251
Keenan Nemetz
14d ago
232 }
e5478085
Keenan Nemetz
11d ago
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)
38b30251
Keenan Nemetz
14d ago
244 if err != nil {
097b2110
Keenan Nemetz
14d ago
245 return plan, err
38b30251
Keenan Nemetz
14d ago
246 }
e5478085
Keenan Nemetz
11d ago
247 plan.Approval = merge
248 plan.Blocked = refusal
38b30251
Keenan Nemetz
14d ago
249 }
e5478085
Keenan Nemetz
11d ago
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 }
38b30251
Keenan Nemetz
14d ago
266 }
e5478085
Keenan Nemetz
11d ago
267 plan.Ready = len(plan.Conflicts) == 0 && plan.Blocked == "" && len(plan.Gaps) == 0
097b2110
Keenan Nemetz
14d ago
268 return plan, nil
38b30251
Keenan Nemetz
14d ago
269 }
270
271 // marked writes the merged state and turns a validation problem into an error, so a
2841d922
Keenan Nemetz
13d ago
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
e050e14a
Keenan Nemetz
13d ago
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.
277 func 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)
183d48e5
Keenan Nemetz
14d ago
279 if err != nil {
280 return nil, err
38b30251
Keenan Nemetz
14d ago
281 }
282 if problems := metadata.ValidateRequests(g.Repo, metadata.Ref); len(problems) > 0 {
183d48e5
Keenan Nemetz
14d ago
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, "; "))
38b30251
Keenan Nemetz
14d ago
287 }
183d48e5
Keenan Nemetz
14d ago
288 return closed, nil
38b30251
Keenan Nemetz
14d ago
289 }