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.
|
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 | } |