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.
| 1 | package landing |
| 2 | |
| 3 | import ( |
| 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. |
| 25 | type 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. |
| 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 |
| 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. |
| 104 | func 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. |
| 168 | func 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. |
| 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) |
| 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 |