The merge queue builds, tests and lands each pull request by its own merge method. Each state was built in a sandbox with git merge commits, then landed by fast-forward as it was, so a queued pull request always landed as a merge commit whatever method was asked; and since every sandbox built its own copy of the entries ahead, an entry's state did not descend from the one that landed before it, and a batch fell back to testing again. Now the work service builds every state in the repository itself, with repos' new build_queue_step: the entry landed by its method (merge commit, squash, rebase or fast-forward) on the state of the entry ahead of it, or on the default branch for the first, with the commits land would make there (merge_methods.rs make_on, shared with land_by; queue_step_on decides a step), pushed to the attempt's g1t-queue/ branch. Landing is then a fast-forward to exactly the tested commit, recorded as the entry's method with that commit as its merge commit; settle's resume check reads merge_commit. A fast-forward entry must contain what is ahead of it; a clash names the files and the nearest entry ahead that changed one (clashed_with); an entry that cannot be built leaves, and those behind are built in the next batch without it. The method is the one asked for when merging into the queue, else the merge queue rule's merge_method, each checked against the repository and the branch's rules; the commit message written then is kept (work 0044: queue_entries merge_method, commit_title, commit_message), else the repository's default, worked out when first built and kept in the tested stack (QueueStackItem method, message, author) so that narrowing a failure rebuilds the same commits; probes are built the same way. The landing gate judges the entry's method. No sandbox builds queue states any more: queue_build returns no jobs. Docs: the merge queue guide (how entries are tested and land, by method; no plan needed for building, merge_group runs billed as workflow runs), pull requests' merge methods, the work architecture page, and the API's merge and queue descriptions (OpenAPI regenerated). Deploy work migration 0044 first, then repos, then work. Checked: cargo test for g1t-repos (341, new: each entry lands by its method on the state ahead, a fast-forward entry is its own head and a clash names the files) and g1t-work (186, new: an entry keeps how it lands and one queued before methods merges; a clash names the nearest entry ahead), the API's OpenAPI test, the CLI's operation table and reference.
16 files+869−2630/16 viewed
| 1585 | 1585 | "Search all of g1t: repositories (name, description, topics, README), code on default branches (file names and contents), issues, pull requests, people and workspaces. Covers everything public, and private content in workspaces you belong to; signed out, public only. Write words, \"exact phrases\", -words to leave out, and qualifiers: repo:owner/name, org:workspace, language:rust, path:src/ (a glob with *), is:issue, is:pr, is:open, is:closed, is:merged, author:username, label:bug. type picks the kind of results (repositories, code, issues, pulls or people); without it, the qualifiers decide. Returns one page of results with the matches highlighted, code with line numbers, and how many there are of each kind." | |
| 1586 | 1586 | } | |
| 1587 | 1587 | Op::GetMergeQueue => { | |
| 1588 | − | "A repository's merge queue: the pull requests waiting to land, in order, each with the state it is being tested in (the default branch with the pull requests ahead of it merged in) and how that went; then those that recently landed or left. With the queue on, merging a pull request adds it here." | |
| 1588 | + | "A repository's merge queue: the pull requests waiting to land, in order, each with the state it is being tested in (the default branch with the pull requests ahead of it landed in turn, each by its merge method) and how that went; then those that recently landed or left. With the queue on, merging a pull request adds it here." | |
| 1589 | 1589 | } | |
| 1590 | 1590 | Op::CreateRepo => { | |
| 1591 | 1591 | "Create a repository in one of your workspaces, empty or as a copy of a public git repository elsewhere." | |
| ⋯ | |||
| 1693 | 1693 | "What a pull request changes: the files it touches and their line-by-line diff against the commit it started from. Use it to review a pull request or to compare several made for the same issue. A large one lists every file (up to 300; truncated says when there were more) but has the lines of only the first, about 20,000 lines read: the files after that come with omitted true and no lines or counts. Ask again with paths (up to 50 at a time) to have those files' lines." | |
| 1694 | 1694 | } | |
| 1695 | 1695 | Op::MergePullRequest => { | |
| 1696 | − | "Land a pull request on its base, the branch it merges into (the default branch unless it names another). merge_method says how: merge (a merge commit joins its branch to the base, its commits included), squash (one new commit holds everything it changed), rebase (each of its commits is made again on top of the base, in order) or fast_forward (the base moves to its head, exactly the commit that was checked; only when its branch contains the base's head). Left out, the repository's default_merge_method. A method the repository does not allow (allow_merge_commit, allow_squash_merge, allow_rebase_merge, allow_fast_forward on update_repo_settings), or that a rule for the base does not (allowed_merge_methods, and required linear history, which refuses merge), is refused. commit_title and commit_message are a merge or squash commit's message: its first line and the rest; left out, the repository's merge_commit_message or squash_commit_message decides. With sha, the merge is refused (409) if the pull request's head is no longer that commit. delete_branch deletes its branch once it has merged (left out, the repository's delete_branch_on_merge); restore_pull_request_head_branch brings it back. Merging needs the Write role or higher, and only once it is marked ready and it meets every rule that holds for its base (see rules and required_checks on get_pull_request: approvals, checks, deployments, merge windows and the rest, from the repository's and its workspace's rulesets); the refusal names the first rule not met. With ignore_checks, someone who may merge can bypass required checks where the rule allows it; with bypass_rules, someone a ruleset lists as a bypass actor merges past its rules, and it is recorded. Merging into the default branch closes the issues in its closes: the issue it was made for and those its title and description name after a closing keyword (Fixes #12), where the repository closes issues (auto_close_issues); the other pull requests still in progress for its issue close as superseded. Merging into another branch closes nothing. Where the repository has a merge queue, a pull request into the default branch joins the queue instead of landing at once. If its base has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests into its default branch to be up to date refuses instead: update_pull_request_branch, then merge again. Check status in the result to see whether it has landed, and merge_method and merge_commit for how." | |
| 1696 | + | "Land a pull request on its base, the branch it merges into (the default branch unless it names another). merge_method says how: merge (a merge commit joins its branch to the base, its commits included), squash (one new commit holds everything it changed), rebase (each of its commits is made again on top of the base, in order) or fast_forward (the base moves to its head, exactly the commit that was checked; only when its branch contains the base's head). Left out, the repository's default_merge_method. A method the repository does not allow (allow_merge_commit, allow_squash_merge, allow_rebase_merge, allow_fast_forward on update_repo_settings), or that a rule for the base does not (allowed_merge_methods, and required linear history, which refuses merge), is refused. commit_title and commit_message are a merge or squash commit's message: its first line and the rest; left out, the repository's merge_commit_message or squash_commit_message decides. With sha, the merge is refused (409) if the pull request's head is no longer that commit. delete_branch deletes its branch once it has merged (left out, the repository's delete_branch_on_merge); restore_pull_request_head_branch brings it back. Merging needs the Write role or higher, and only once it is marked ready and it meets every rule that holds for its base (see rules and required_checks on get_pull_request: approvals, checks, deployments, merge windows and the rest, from the repository's and its workspace's rulesets); the refusal names the first rule not met. With ignore_checks, someone who may merge can bypass required checks where the rule allows it; with bypass_rules, someone a ruleset lists as a bypass actor merges past its rules, and it is recorded. Merging into the default branch closes the issues in its closes: the issue it was made for and those its title and description name after a closing keyword (Fixes #12), where the repository closes issues (auto_close_issues); the other pull requests still in progress for its issue close as superseded. Merging into another branch closes nothing. Where the repository has a merge queue, a pull request into the default branch joins the queue instead of landing at once, to be tested and landed by merge_method (the merge queue rule's when absent) with the commit message given. If its base has moved since the pull request was opened, it is brought up to date first and lands when that is done; a repository that requires pull requests into its default branch to be up to date refuses instead: update_pull_request_branch, then merge again. Check status in the result to see whether it has landed, and merge_method and merge_commit for how." | |
| 1697 | 1697 | } | |
| 1698 | 1698 | Op::GetPullRequestMergeOptions => { | |
| 1699 | 1699 | "How a pull request may merge now, as the merge box offers it to you: the methods it may merge by (the repository's, as the rules for its base allow them) and the one offered first (default_method); each method's default commit title and message, and why one cannot be used now (unavailable); whether it is behind its base, can fast-forward as it is, should be offered an update (suggest_update) or must be up to date (require_up_to_date); whether it goes through the merge queue; whether you may turn on auto-merge; whether deleting its branch starts ticked and is possible; and whether commits made merging it are signed off." | |
| 156 | 156 | [The merge queue](/guides/merge-queue/#who-answers-for-it) has the table of | |
| 157 | 157 | outcomes from a person's side. | |
| 158 | 158 | ||
| 159 | − | <Aside title="What a merge queue needs"> | |
| 160 | − | Building candidates takes sandboxes, so the merge queue needs a paid or | |
| 161 | − | trial workspace, or the open-source pool for a public repository. | |
| 159 | + | <Aside title="How states are built"> | |
| 160 | + | `work` builds each candidate in the repository itself, through repos' | |
| 161 | + | `build_queue_step`: the entry landed by its own merge method on the | |
| 162 | + | state ahead of it, with the commits `land` would make there, pushed to | |
| 163 | + | the attempt's `g1t-queue/` branch. Landing is then a fast-forward to | |
| 164 | + | exactly that commit, and no sandbox is involved. | |
| 162 | 165 | [The merge queue](/guides/merge-queue/) has the settings. | |
| 163 | 166 | </Aside> | |
| 164 | 167 |
| 9 | 9 | on, a pull request is tested together with everything ahead of it before it | |
| 10 | 10 | lands, and `main` only ever moves to a state whose required checks passed. | |
| 11 | 11 | ||
| 12 | − | The merge queue runs in g1t's sandboxes, which need | |
| 13 | − | [the g1t plan](/guides/usage-and-billing/#the-g1t-plan) or the trial after | |
| 14 | − | a card check; a public repository can use the open-source pool instead. | |
| 15 | − | Without one, an entry fails at once with a message saying so; turn the | |
| 16 | − | queue off to merge directly. | |
| 12 | + | g1t builds the queue's states in the repository itself, without a | |
| 13 | + | sandbox. What checks them is the repository's `merge_group` | |
| 14 | + | [workflows](/guides/actions/), which run and are billed like any other | |
| 15 | + | workflow run. | |
| 17 | 16 | ||
| 18 | 17 | ## Turn it on | |
| 19 | 18 | ||
| ⋯ | |||
| 60 | 59 | ||
| 61 | 60 | ## How entries are tested | |
| 62 | 61 | ||
| 63 | − | g1t takes up to four entries (the rule's `max_entries_to_build`) from the front of the queue and tests them all | |
| 64 | − | at once, speculatively, each in its own sandbox. Each sandbox builds `main` | |
| 65 | − | with that entry and every entry ahead of it merged in, in queue order: | |
| 62 | + | g1t takes up to four entries (the rule's `max_entries_to_build`) from the | |
| 63 | + | front of the queue and tests them all at once, speculatively. Each entry's | |
| 64 | + | state is the one before it with that entry landed on top, in queue order: | |
| 66 | 65 | ||
| 67 | 66 | | Entry | Tested as | | |
| 68 | 67 | | --- | --- | | |
| ⋯ | |||
| 71 | 70 | | 3rd | `main` + #41 + #44 + #46 | | |
| 72 | 71 | | 4th | `main` + #41 + #44 + #46 + #47 | | |
| 73 | 72 | ||
| 73 | + | Each entry is landed on the state ahead of it by its own | |
| 74 | + | [merge method](/guides/pull-requests/#merge-methods), exactly as merging it | |
| 75 | + | there would: | |
| 76 | + | ||
| 77 | + | - **Merge commit**: a merge commit, with the message for the repository's | |
| 78 | + | merge commits or the one written when it was queued. | |
| 79 | + | - **Squash**: one commit, by the pull request's author, with the squash | |
| 80 | + | message and its `Co-authored-by:` trailers. | |
| 81 | + | - **Rebase**: each of its commits made again on top, keeping their authors. | |
| 82 | + | - **Fast-forward**: its own head, which must already contain everything | |
| 83 | + | ahead of it; otherwise it fails, saying to bring it up to date or merge | |
| 84 | + | it another way. | |
| 85 | + | ||
| 86 | + | The method is the one asked for when it was merged into the queue (the | |
| 87 | + | merge box's choice, `merge_method` in the API), else the merge queue | |
| 88 | + | rule's `merge_method` (merge commit unless set). Both must be methods the | |
| 89 | + | repository and the branch's [rules](/guides/rules/) allow, or merging is | |
| 90 | + | refused, saying which are; linear history rules out merge commits. | |
| 91 | + | A commit message written then (`commit_title`, `commit_message`) is kept | |
| 92 | + | with the entry; otherwise the repository's default message is used, worked | |
| 93 | + | out when the entry is first built. Sign-off is added where the repository | |
| 94 | + | requires it. The same limits apply as to merging directly: g1t makes at | |
| 95 | + | most 1,000 changed files, 100 files merged line by line and 250 rebased | |
| 96 | + | commits itself. | |
| 97 | + | ||
| 74 | 98 | If every entry passes, the four can land one after another without being | |
| 75 | 99 | tested again. The next batch starts when nothing is being tested. A batch | |
| 76 | 100 | that takes longer than 45 minutes (`check_response_timeout_minutes`) is tested again. | |
| ⋯ | |||
| 110 | 134 | Each build has its own attempt token. A result from an old attempt cannot | |
| 111 | 135 | replace a retry or put a removed entry back in the queue. Workflow results | |
| 112 | 136 | apply only to the combined commit still being checked. | |
| 113 | − | Retries use separate Git branches, so a late sandbox cannot overwrite the | |
| 137 | + | Retries use separate Git branches, so a late build cannot overwrite the | |
| 114 | 138 | new attempt's branch. Workflow results wait until the whole group has been | |
| 115 | 139 | registered; a dispatch error does not count as a pass. | |
| 116 | 140 | ||
| 117 | 141 | ## How entries land | |
| 118 | 142 | ||
| 119 | 143 | Entries land in order. When an entry has passed and everything ahead of it | |
| 120 | − | has landed, `main` moves to exactly the state that was tested. The issue | |
| 121 | − | closes and the other pull requests for it are superseded, as with any | |
| 122 | − | merge. | |
| 144 | + | has landed, `main` moves to exactly the state that was tested: since each | |
| 145 | + | state was built on the one ahead of it, that is a fast-forward to the | |
| 146 | + | commits its method made, which are the commits its checks ran on. The pull | |
| 147 | + | request is recorded as merged by its method, with the commit `main` moved | |
| 148 | + | to as its merge commit. The issue closes and the other pull requests for | |
| 149 | + | it are superseded, as with any merge. | |
| 123 | 150 | ||
| 124 | 151 | Current reviews and branch rules are checked again before publication, | |
| 125 | − | against the combined commit's checks. Once publication starts, removal, | |
| 152 | + | against the combined commit's checks and the entry's merge method. Once publication starts, removal, | |
| 126 | 153 | closing or conversion to draft returns a conflict until it finishes. If the | |
| 127 | 154 | service stops after updating Git, the next queue poll resumes bookkeeping | |
| 128 | 155 | for that same commit instead of publishing a different branch tip. | |
| ⋯ | |||
| 145 | 172 | ## When an entry fails | |
| 146 | 173 | ||
| 147 | 174 | An entry fails when its `merge_group` workflows or required checks fail | |
| 148 | − | on the combined state, when it does not merge cleanly with what is ahead of it, or | |
| 149 | − | when the state cannot be built. | |
| 175 | + | on the combined state, when its change clashes with what is ahead of it, or | |
| 176 | + | when the state cannot be built (a fast-forward that does not contain what | |
| 177 | + | is ahead, or more than g1t makes itself). One that cannot be built leaves | |
| 178 | + | the queue, and the entries behind it are built in the next batch without | |
| 179 | + | it. | |
| 150 | 180 | ||
| 151 | 181 | ### Who answers for it | |
| 152 | 182 | ||
| ⋯ | |||
| 210 | 240 | changes together, or may not be deterministic. Diagnosis stops after three | |
| 211 | 241 | extra builds, retaining the last combination demonstrated to fail. | |
| 212 | 242 | ||
| 213 | − | These combinations are built in sandboxes as the queue's states are, on | |
| 214 | − | `g1t-queue/<id>` branches that are deleted afterwards, and never land. They | |
| 243 | + | These combinations are built as the queue's states are, each entry by | |
| 244 | + | its own method and with the same commit messages, on `g1t-queue/<id>` | |
| 245 | + | branches that are deleted afterwards, and never land. They | |
| 215 | 246 | do not hold up the queue: the entries behind are tested again at once. When | |
| 216 | 247 | a combination cannot be built or checked, or its checks are still running | |
| 217 | 248 | past the rule's timeout when the queue next moves, g1t stops and reports | |
| 357 | 357 | those it refuses and nothing changes: squash or merge instead of rebasing, | |
| 358 | 358 | or merge the base in a clone. | |
| 359 | 359 | ||
| 360 | + | With the [merge queue](/guides/merge-queue/#how-entries-are-tested) on, | |
| 361 | + | the method chosen (or the queue rule's, when none is) decides how the | |
| 362 | + | pull request's state is built in the queue, and what is tested is what | |
| 363 | + | lands. | |
| 364 | + | ||
| 360 | 365 | ### Commit messages | |
| 361 | 366 | ||
| 362 | 367 | A merge commit and a squash commit get a message. You can write it in the |
Binary or large file; its contents are not shown.
| 614 | 614 | pub sign_off: bool, | |
| 615 | 615 | } | |
| 616 | 616 | ||
| 617 | + | /// `build_queue_step`: one entry of a merge queue landed, by its method, | |
| 618 | + | /// on a commit of the target that is not a branch's head: the default | |
| 619 | + | /// branch, or the state built for the entries ahead. Makes the commits | |
| 620 | + | /// `land` would make there (`merge`, `squash` and `rebase` new ones; a | |
| 621 | + | /// `fast_forward` none, its head only if that contains `onto`) and points | |
| 622 | + | /// `push_branch` of the target at the result, so that landing it later is | |
| 623 | + | /// a fast-forward to exactly what was tested. The branch it merges into | |
| 624 | + | /// does not move. Returns `Outcome<QueueStep>`. | |
| 625 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 626 | + | #[serde(rename_all = "camelCase")] | |
| 627 | + | pub struct BuildQueueStepArgs { | |
| 628 | + | /// The repository the queue lands on. | |
| 629 | + | pub target_id: String, | |
| 630 | + | /// The commit to build on. | |
| 631 | + | pub onto: String, | |
| 632 | + | /// Where the entry's change is: its fork, or the target itself. | |
| 633 | + | pub source_id: String, | |
| 634 | + | pub branch: String, | |
| 635 | + | /// The entry's head as it was queued; refused if the branch moved. | |
| 636 | + | pub commit: String, | |
| 637 | + | pub method: crate::rules::MergeMethod, | |
| 638 | + | /// For `merge` and `squash`: the commit's message. Required for those. | |
| 639 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 640 | + | pub message: Option<String>, | |
| 641 | + | /// For `squash`: who the commit is by; `actor` when absent. | |
| 642 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 643 | + | pub author: Option<User>, | |
| 644 | + | #[serde(default)] | |
| 645 | + | pub sign_off: bool, | |
| 646 | + | /// Whoever queued it: the committer, who must be able to merge. | |
| 647 | + | pub actor: User, | |
| 648 | + | /// The queue's branch of the target to point at the result. | |
| 649 | + | pub push_branch: String, | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | /// What `build_queue_step` made. | |
| 653 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 654 | + | #[serde(tag = "kind", rename_all = "snake_case")] | |
| 655 | + | pub enum QueueStep { | |
| 656 | + | /// The state with the entry in it, now at `push_branch`: `onto` itself | |
| 657 | + | /// when it held the entry's changes already. | |
| 658 | + | Built { | |
| 659 | + | commit: String, | |
| 660 | + | /// For `rebase`: each commit made, with the one it was made from. | |
| 661 | + | #[serde(default)] | |
| 662 | + | rebased: Vec<(String, String)>, | |
| 663 | + | }, | |
| 664 | + | /// Its change clashes with `onto` in these files. | |
| 665 | + | Conflict { message: String, paths: Vec<String> }, | |
| 666 | + | /// It cannot be built there, and why: a fast-forward that does not | |
| 667 | + | /// contain `onto`, or more than g1t makes itself. | |
| 668 | + | Refused { message: String }, | |
| 669 | + | } | |
| 670 | + | ||
| 617 | 671 | /// How a pull request's branch is brought up to date with its base | |
| 618 | 672 | /// (`update_pull_branch`). | |
| 619 | 673 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 1885 | 1885 | pub suggest_update: bool, | |
| 1886 | 1886 | /// The repository requires it to be up to date before it merges. | |
| 1887 | 1887 | pub require_up_to_date: bool, | |
| 1888 | − | /// It merges through the merge queue, which lands it as tested | |
| 1889 | − | /// whatever method is chosen. | |
| 1888 | + | /// It merges through the merge queue, which builds and tests it by the | |
| 1889 | + | /// method chosen (the queue rule's when none is) and lands it as tested. | |
| 1890 | 1890 | pub merge_queue: bool, | |
| 1891 | 1891 | /// The viewer may turn on auto-merge for it: the repository allows it, | |
| 1892 | 1892 | /// it is open or a draft, and they may merge it. | |
| ⋯ | |||
| 2245 | 2245 | pub viewer: Viewer, | |
| 2246 | 2246 | } | |
| 2247 | 2247 | ||
| 2248 | − | /// `queue_build`: the next batch to test for a repository, if nothing is | |
| 2249 | − | /// being tested now. Returns `Vec<QueueJob>`, one per entry, each testing | |
| 2250 | − | /// the default branch with that entry and everything ahead of it. | |
| 2248 | + | /// `queue_build`: builds the next batch to test for a repository, if | |
| 2249 | + | /// nothing is being tested now: each entry landed by its method on the | |
| 2250 | + | /// state of the entries ahead of it, in the repository itself, with its | |
| 2251 | + | /// `merge_group` workflows started. Returns `Vec<QueueJob>`, which is now | |
| 2252 | + | /// always empty: no state is left for a sandbox to build. | |
| 2251 | 2253 | #[derive(Debug, Serialize, Deserialize)] | |
| 2252 | 2254 | #[serde(rename_all = "camelCase")] | |
| 2253 | 2255 | pub struct QueueBuildArgs { | |
| ⋯ | |||
| 2265 | 2267 | /// The branch of `source` holding it. | |
| 2266 | 2268 | pub branch: String, | |
| 2267 | 2269 | pub commit: String, | |
| 2270 | + | /// How it lands: the state is built with its commits made this way. | |
| 2271 | + | /// States recorded before methods were merged in by merge commit. | |
| 2272 | + | #[serde(default = "merge_method")] | |
| 2273 | + | pub method: MergeMethod, | |
| 2274 | + | /// For `merge` and `squash`: the commit's message, worked out when | |
| 2275 | + | /// the state was first built, so that building it again makes the | |
| 2276 | + | /// same commit. | |
| 2277 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 2278 | + | pub message: Option<String>, | |
| 2279 | + | /// For `squash`: who the commit is by. | |
| 2280 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 2281 | + | pub author: Option<User>, | |
| 2268 | 2282 | } | |
| 2269 | 2283 | ||
| 2270 | − | /// What a sandbox needs to build and check one combined state. | |
| 2284 | + | /// One combined state to build and check. The work service builds every | |
| 2285 | + | /// state in the repository itself now, so `queue_build` returns none. | |
| 2271 | 2286 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 2272 | 2287 | #[serde(rename_all = "camelCase")] | |
| 2273 | 2288 | pub struct QueueJob { | |
| ⋯ | |||
| 2293 | 2308 | pub actor: User, | |
| 2294 | 2309 | } | |
| 2295 | 2310 | ||
| 2296 | − | /// `report_queue`: a sandbox's result for one combined state. Returns | |
| 2311 | + | /// `report_queue`: the result of building one combined state. Returns | |
| 2297 | 2312 | /// `Outcome<QueueState>`. | |
| 2298 | 2313 | #[derive(Debug, Serialize, Deserialize)] | |
| 2299 | 2314 | #[serde(rename_all = "camelCase")] | |
| 1119 | 1119 | source: RepoPath; | |
| 1120 | 1120 | branch: string; | |
| 1121 | 1121 | commit: string; | |
| 1122 | + | /** How it lands: the state is built with its commits made this way. */ | |
| 1123 | + | method?: MergeMethod; | |
| 1124 | + | /** For merge and squash: the commit's message. */ | |
| 1125 | + | message?: string; | |
| 1126 | + | /** For squash: who the commit is by. */ | |
| 1127 | + | author?: User; | |
| 1122 | 1128 | }; | |
| 1123 | 1129 | ||
| 1124 | 1130 | /** What a sandbox needs to build and check one combined state. */ |
| 2863 | 2863 | "behind" => reply(&repos.behind(args(body)?).await?), | |
| 2864 | 2864 | "divergence" => reply(&repos.divergence(args(body)?).await?), | |
| 2865 | 2865 | "land" => reply(&repos.land(args(body)?).await?), | |
| 2866 | + | "build_queue_step" => reply(&repos.build_queue_step(args(body)?).await?), | |
| 2866 | 2867 | "update_pull_branch" => reply(&repos.update_pull_branch(args(body)?).await?), | |
| 2867 | 2868 | // Services only: a pull request's test merge commit (test_merge.rs). | |
| 2868 | 2869 | "pull_merge_ref" => reply(&repos.pull_merge_ref(args(body)?).await?), |
| 26 | 26 | ||
| 27 | 27 | use futures_util::future::{try_join, try_join_all}; | |
| 28 | 28 | use g1t_contracts::repos::{ | |
| 29 | − | Branch, Commit, EntryKind, GitAccess, LandArgs, Landed, NeedsAgentReason, PullBranchUpdate, Repo, TreeEntry, | |
| 29 | + | Branch, BuildQueueStepArgs, Commit, EntryKind, GitAccess, LandArgs, Landed, NeedsAgentReason, PullBranchUpdate, QueueStep, | |
| 30 | + | Repo, TreeEntry, | |
| 30 | 31 | }; | |
| 31 | 32 | use g1t_contracts::rules::MergeMethod; | |
| 32 | 33 | use g1t_contracts::time::parse_rfc3339; | |
| ⋯ | |||
| 536 | 537 | } | |
| 537 | 538 | } | |
| 538 | 539 | ||
| 540 | + | /// A commit's short hash. | |
| 541 | + | fn short(hash: &str) -> &str { | |
| 542 | + | &hash[..hash.len().min(7)] | |
| 543 | + | } | |
| 544 | + | ||
| 539 | 545 | /// A commit as people know it: its short hash and title. | |
| 540 | 546 | fn named_commit(commit: &Commit) -> String { | |
| 541 | 547 | let title = commit.message.lines().next().unwrap_or_default().trim(); | |
| ⋯ | |||
| 553 | 559 | /// Why a landing was refused, or that it was there already. | |
| 554 | 560 | enum NotMade { | |
| 555 | 561 | Refused(Outcome<Landed>), | |
| 562 | + | /// Lines clash in these files. | |
| 563 | + | Clash { message: String, paths: Vec<String> }, | |
| 556 | 564 | /// Nothing to make: the branch already has every change. | |
| 557 | 565 | AlreadyThere, | |
| 558 | 566 | } | |
| ⋯ | |||
| 561 | 569 | NotMade::Refused(Outcome::fail(code, message)) | |
| 562 | 570 | } | |
| 563 | 571 | ||
| 572 | + | /// The pack that sends what was made: with what the pull request's commits | |
| 573 | + | /// bring that the repository lacks, from the fork, when it does not hold | |
| 574 | + | /// them (`held`). | |
| 575 | + | async fn pack_for<R: GitRepo>( | |
| 576 | + | held: bool, | |
| 577 | + | source_git: &R, | |
| 578 | + | new: &str, | |
| 579 | + | merge_base: &str, | |
| 580 | + | objects: &[(ObjectKind, Vec<u8>)], | |
| 581 | + | ) -> Result<Vec<u8>> { | |
| 582 | + | if held { | |
| 583 | + | return Ok(write_pack(objects)); | |
| 584 | + | } | |
| 585 | + | let source_access = source_git.access(Scope::Read).await?; | |
| 586 | + | let fetched = land::fetch_pack(&source_access, new, Some(merge_base)).await?; | |
| 587 | + | extend_pack(&fetched, objects).map_err(worker::Error::RustError) | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | /// What `method` (any but `fast_forward`) makes of a pull request's | |
| 591 | + | /// commits (`history`, its first-parent chain, headed by `new`) on the | |
| 592 | + | /// head of `target_history`, with the objects made added to `objects`; | |
| 593 | + | /// and the merge base, which a pack from a fork starts at. `branch` | |
| 594 | + | /// names where it lands in what is said about a refusal. | |
| 595 | + | #[allow(clippy::too_many_arguments)] | |
| 596 | + | async fn make_on<R: GitRepo>( | |
| 597 | + | method: MergeMethod, | |
| 598 | + | reader: &Reader<'_, R>, | |
| 599 | + | target_history: &[Commit], | |
| 600 | + | history: &[Commit], | |
| 601 | + | new: &str, | |
| 602 | + | message: &str, | |
| 603 | + | (author, by): (&Signature<'_>, &Signature<'_>), | |
| 604 | + | sign_off: Option<&str>, | |
| 605 | + | branch: &str, | |
| 606 | + | objects: &mut Vec<(ObjectKind, Vec<u8>)>, | |
| 607 | + | ) -> Result<std::result::Result<(Made, String), NotMade>> { | |
| 608 | + | let Some(base) = target_history.first() else { | |
| 609 | + | return Ok(Err(refused(FailureCode::Conflict, format!("{branch} has no commits yet.")))); | |
| 610 | + | }; | |
| 611 | + | let shared: HashSet<String> = target_history.iter().map(|commit| commit.hash.clone()).collect(); | |
| 612 | + | let merge_base = nearest_ancestor_in(reader, history, &shared).await?; | |
| 613 | + | let Some((merge_base, merge_base_tree)) = merge_base.and_then(|hash| { | |
| 614 | + | target_history.iter().find(|commit| commit.hash == hash).map(|commit| (hash, commit.tree_hash.clone())) | |
| 615 | + | }) else { | |
| 616 | + | return Ok(Err(refused( | |
| 617 | + | FailureCode::Conflict, | |
| 618 | + | format!("g1t could not find where this pull request left {branch}, so it cannot make its commits there."), | |
| 619 | + | ))); | |
| 620 | + | }; | |
| 621 | + | let made = match method { | |
| 622 | + | MergeMethod::Merge | MergeMethod::Squash => { | |
| 623 | + | let squash = method == MergeMethod::Squash; | |
| 624 | + | let co_authored = if squash { | |
| 625 | + | let commits = match branch_commits(reader, history, &shared, MAX_ANCESTRY as usize).await? { | |
| 626 | + | Some(commits) => commits, | |
| 627 | + | // Too many to walk: those on the branch's own line. | |
| 628 | + | None => { | |
| 629 | + | let mut own: Vec<Commit> = history.iter().take_while(|commit| !shared.contains(&commit.hash)).cloned().collect(); | |
| 630 | + | own.reverse(); | |
| 631 | + | own | |
| 632 | + | } | |
| 633 | + | }; | |
| 634 | + | co_authors(&commits, author.email) | |
| 635 | + | } else { | |
| 636 | + | Vec::new() | |
| 637 | + | }; | |
| 638 | + | let head = &history[0]; | |
| 639 | + | made_merge( | |
| 640 | + | reader, | |
| 641 | + | (&merge_base_tree, &head.tree_hash, base), | |
| 642 | + | new, | |
| 643 | + | squash, | |
| 644 | + | message, | |
| 645 | + | &co_authored, | |
| 646 | + | sign_off, | |
| 647 | + | (author, by), | |
| 648 | + | branch, | |
| 649 | + | objects, | |
| 650 | + | ) | |
| 651 | + | .await? | |
| 652 | + | } | |
| 653 | + | _ => { | |
| 654 | + | let commits = match branch_commits(reader, history, &shared, MAX_REBASE_COMMITS).await? { | |
| 655 | + | Some(commits) => commits, | |
| 656 | + | None => { | |
| 657 | + | return Ok(Err(refused( | |
| 658 | + | FailureCode::Conflict, | |
| 659 | + | format!( | |
| 660 | + | "This pull request has more than {MAX_REBASE_COMMITS} commits, too many for g1t to rebase itself. Squash or merge it instead." | |
| 661 | + | ), | |
| 662 | + | ))); | |
| 663 | + | } | |
| 664 | + | }; | |
| 665 | + | match replay(reader, &commits, (&base.hash, &base.tree_hash), by, sign_off, objects).await? { | |
| 666 | + | Replay::Done(replayed) if replayed.made.is_empty() => Err(NotMade::AlreadyThere), | |
| 667 | + | Replay::Done(replayed) => Ok(Made { | |
| 668 | + | commit: replayed.head, | |
| 669 | + | commits: Some(replayed.made.len() as u32), | |
| 670 | + | rebased: replayed.made, | |
| 671 | + | }), | |
| 672 | + | Replay::Conflict { commit, paths } => Err(NotMade::Clash { | |
| 673 | + | message: format!( | |
| 674 | + | "Rebasing stopped at {}: it and {branch} changed the same lines of {}. Rebase the branch onto {branch} and resolve them, or merge it another way.", | |
| 675 | + | named_commit(&commit), | |
| 676 | + | name_files(&paths) | |
| 677 | + | ), | |
| 678 | + | paths, | |
| 679 | + | }), | |
| 680 | + | Replay::Unsupported { commit, why } => Err(refused( | |
| 681 | + | FailureCode::Conflict, | |
| 682 | + | format!("g1t cannot rebase {} itself: {why}. Rebase the branch onto {branch}, or merge it another way.", named_commit(&commit)), | |
| 683 | + | )), | |
| 684 | + | } | |
| 685 | + | } | |
| 686 | + | }; | |
| 687 | + | Ok(made.map(|made| (made, merge_base))) | |
| 688 | + | } | |
| 689 | + | ||
| 690 | + | /// A merge commit (parents `base` and `new`) or a squashed commit | |
| 691 | + | /// (parent `base`) whose tree is the pull request's changes since | |
| 692 | + | /// `trees.0` (the merge base's tree; `trees.1` is its head's) applied | |
| 693 | + | /// to `base`'s. | |
| 694 | + | #[allow(clippy::too_many_arguments)] | |
| 695 | + | async fn made_merge<R: GitRepo>( | |
| 696 | + | reader: &Reader<'_, R>, | |
| 697 | + | trees: (&str, &str, &Commit), | |
| 698 | + | new: &str, | |
| 699 | + | squash: bool, | |
| 700 | + | message: &str, | |
| 701 | + | co_authored: &[String], | |
| 702 | + | sign_off: Option<&str>, | |
| 703 | + | (author, committer): (&Signature<'_>, &Signature<'_>), | |
| 704 | + | branch: &str, | |
| 705 | + | objects: &mut Vec<(ObjectKind, Vec<u8>)>, | |
| 706 | + | ) -> Result<std::result::Result<Made, NotMade>> { | |
| 707 | + | let (merge_base_tree, head_tree, base) = trees; | |
| 708 | + | let verb = if squash { "squash" } else { "merge" }; | |
| 709 | + | let tree = match apply(reader, Some(merge_base_tree), head_tree, &base.tree_hash, objects).await? { | |
| 710 | + | Applied::Tree(tree) => tree, | |
| 711 | + | Applied::Conflict(paths) => { | |
| 712 | + | return Ok(Err(NotMade::Clash { | |
| 713 | + | message: format!( | |
| 714 | + | "This pull request and {branch} changed the same lines of {}. Bring the pull request up to date with {branch} and resolve them, then merge again.", | |
| 715 | + | name_files(&paths) | |
| 716 | + | ), | |
| 717 | + | paths, | |
| 718 | + | })); | |
| 719 | + | } | |
| 720 | + | Applied::Unsupported(why) => { | |
| 721 | + | return Ok(Err(refused( | |
| 722 | + | FailureCode::Conflict, | |
| 723 | + | format!("g1t cannot {verb} this pull request itself: {why}. Bring it up to date with {branch}, or merge it another way."), | |
| 724 | + | ))); | |
| 725 | + | } | |
| 726 | + | }; | |
| 727 | + | // Squashing what is there already would make an empty commit. | |
| 728 | + | if squash && tree == base.tree_hash { | |
| 729 | + | return Ok(Err(NotMade::AlreadyThere)); | |
| 730 | + | } | |
| 731 | + | let mut trailers: Vec<String> = co_authored.to_vec(); | |
| 732 | + | trailers.extend(sign_off.map(str::to_owned)); | |
| 733 | + | let by = if squash { &author.email } else { &committer.email }; | |
| 734 | + | let message = with_trailers(&tidy_co_authors(message, by, co_authored), &trailers); | |
| 735 | + | let bytes = if squash { | |
| 736 | + | commit_object_by(&tree, &[&base.hash], author, committer, &message) | |
| 737 | + | } else { | |
| 738 | + | commit_object_by(&tree, &[&base.hash, new], committer, committer, &message) | |
| 739 | + | }; | |
| 740 | + | let commit = object_id(ObjectKind::Commit, &bytes); | |
| 741 | + | objects.push((ObjectKind::Commit, bytes)); | |
| 742 | + | Ok(Ok(Made { commit, rebased: Vec::new(), commits: squash.then_some(1) })) | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | /// What one merge queue entry made: the state with it in, and the merge | |
| 746 | + | /// base a pack from its fork starts at; or why it cannot be built. | |
| 747 | + | #[derive(Debug)] | |
| 748 | + | pub(crate) enum StepMade { | |
| 749 | + | Made { commit: String, rebased: Vec<(String, String)>, merge_base: String }, | |
| 750 | + | Conflict { message: String, paths: Vec<String> }, | |
| 751 | + | Refused(String), | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | /// One merge queue entry (`history`, its first-parent chain from the | |
| 755 | + | /// commit queued) landed by `method` on the head of `onto_history`, a | |
| 756 | + | /// state of the queue: its commits made as landing would make them there, | |
| 757 | + | /// with the objects made added to `objects`. A fast-forward makes none and | |
| 758 | + | /// needs its head to contain that state; an entry whose changes are all | |
| 759 | + | /// there already leaves the state as it is. `held`: the repository holds | |
| 760 | + | /// the entry's commits (`git` reads a fork's otherwise). | |
| 761 | + | #[allow(clippy::too_many_arguments)] | |
| 762 | + | pub(crate) async fn queue_step_on<R: GitRepo>( | |
| 763 | + | git: &Reader<'_, R>, | |
| 764 | + | held: bool, | |
| 765 | + | method: MergeMethod, | |
| 766 | + | (onto_history, history): (&[Commit], &[Commit]), | |
| 767 | + | message: Option<&str>, | |
| 768 | + | signatures: (&Signature<'_>, &Signature<'_>), | |
| 769 | + | sign_off: Option<&str>, | |
| 770 | + | place: &str, | |
| 771 | + | objects: &mut Vec<(ObjectKind, Vec<u8>)>, | |
| 772 | + | ) -> Result<StepMade> { | |
| 773 | + | let (Some(head), Some(base)) = (history.first(), onto_history.first()) else { | |
| 774 | + | return Ok(StepMade::Refused("Its commit or the state it is built on is no longer there.".to_owned())); | |
| 775 | + | }; | |
| 776 | + | let there = |commit: &str| StepMade::Made { commit: commit.to_owned(), rebased: Vec::new(), merge_base: base.hash.clone() }; | |
| 777 | + | if base.hash == head.hash || (held && descends_from(git, onto_history, &head.hash).await?) { | |
| 778 | + | return Ok(there(&base.hash)); | |
| 779 | + | } | |
| 780 | + | if method == MergeMethod::FastForward { | |
| 781 | + | return Ok(if descends_from(git, history, &base.hash).await? { | |
| 782 | + | there(&head.hash) | |
| 783 | + | } else { | |
| 784 | + | StepMade::Refused(format!( | |
| 785 | + | "It lands by fast-forward, so its branch must contain {place} with everything queued ahead of it. Bring it up to date, or merge it another way." | |
| 786 | + | )) | |
| 787 | + | }); | |
| 788 | + | } | |
| 789 | + | let message = match message.map(str::trim_end).filter(|text| !text.trim().is_empty()) { | |
| 790 | + | Some(text) => text.to_owned(), | |
| 791 | + | None if method == MergeMethod::Rebase => String::new(), | |
| 792 | + | None => return Ok(StepMade::Refused("Say what the commit's message is: its first line is its title.".to_owned())), | |
| 793 | + | }; | |
| 794 | + | Ok(match make_on(method, git, onto_history, history, &head.hash, &message, signatures, sign_off, place, objects).await? { | |
| 795 | + | Ok((made, merge_base)) => StepMade::Made { commit: made.commit, rebased: made.rebased, merge_base }, | |
| 796 | + | Err(NotMade::AlreadyThere) => there(&base.hash), | |
| 797 | + | Err(NotMade::Clash { message, paths }) => StepMade::Conflict { message, paths }, | |
| 798 | + | Err(NotMade::Refused(Outcome::Fail(failure))) => StepMade::Refused(failure.message), | |
| 799 | + | Err(NotMade::Refused(Outcome::Ok(_))) => StepMade::Refused("It could not be built.".to_owned()), | |
| 800 | + | }) | |
| 801 | + | } | |
| 802 | + | ||
| 564 | 803 | impl<S: GitStore> Repos<S> { | |
| 565 | 804 | /// `land` by `method`, any but `fast_forward`: makes the commits on | |
| 566 | 805 | /// `branch` of `target` from `new`, the checked head of the pull | |
| ⋯ | |||
| 624 | 863 | }; | |
| 625 | 864 | return Ok(Outcome::Ok(Landed { commit, previous: None, method, rebased: Vec::new() })); | |
| 626 | 865 | } | |
| 627 | − | let shared: HashSet<String> = target_history.iter().map(|commit| commit.hash.clone()).collect(); | |
| 628 | − | let merge_base = nearest_ancestor_in(&reader, history, &shared).await?; | |
| 629 | − | let Some((merge_base, merge_base_tree)) = merge_base.and_then(|hash| { | |
| 630 | − | target_history.iter().find(|commit| commit.hash == hash).map(|commit| (hash, commit.tree_hash.clone())) | |
| 631 | − | }) else { | |
| 632 | − | return Ok(Outcome::fail( | |
| 633 | − | FailureCode::Conflict, | |
| 634 | − | format!("g1t could not find where this pull request left {branch}, so it cannot make its commits there."), | |
| 635 | − | )); | |
| 636 | − | }; | |
| 637 | 866 | let mut objects: Vec<(ObjectKind, Vec<u8>)> = Vec::new(); | |
| 638 | 867 | let seconds = now_ms() / 1000; | |
| 639 | 868 | let by = Signature { name: &committer.name, email: &committer.email, seconds }; | |
| 640 | − | let made = match method { | |
| 641 | − | MergeMethod::Merge | MergeMethod::Squash => { | |
| 642 | − | let squash = method == MergeMethod::Squash; | |
| 643 | − | let co_authored = if squash { | |
| 644 | − | let commits = match branch_commits(&reader, history, &shared, MAX_ANCESTRY as usize).await? { | |
| 645 | − | Some(commits) => commits, | |
| 646 | − | // Too many to walk: those on the branch's own line. | |
| 647 | − | None => { | |
| 648 | − | let mut own: Vec<Commit> = history.iter().take_while(|commit| !shared.contains(&commit.hash)).cloned().collect(); | |
| 649 | − | own.reverse(); | |
| 650 | − | own | |
| 651 | − | } | |
| 652 | − | }; | |
| 653 | − | co_authors(&commits, &author.email) | |
| 654 | − | } else { | |
| 655 | − | Vec::new() | |
| 656 | − | }; | |
| 657 | − | let head = &history[0]; | |
| 658 | − | self.made_merge( | |
| 659 | − | &reader, | |
| 660 | − | (&merge_base_tree, &head.tree_hash, &base), | |
| 661 | − | &new, | |
| 662 | − | squash, | |
| 663 | − | &message, | |
| 664 | − | &co_authored, | |
| 665 | − | sign_off.as_deref(), | |
| 666 | − | (&Signature { name: &author.name, email: &author.email, seconds }, &by), | |
| 667 | − | branch, | |
| 668 | − | &mut objects, | |
| 669 | − | ) | |
| 670 | − | .await? | |
| 671 | − | } | |
| 672 | − | _ => { | |
| 673 | − | let commits = match branch_commits(&reader, history, &shared, MAX_REBASE_COMMITS).await? { | |
| 674 | − | Some(commits) => commits, | |
| 675 | − | None => { | |
| 676 | − | return Ok(Outcome::fail( | |
| 677 | − | FailureCode::Conflict, | |
| 678 | − | format!( | |
| 679 | − | "This pull request has more than {MAX_REBASE_COMMITS} commits, too many for g1t to rebase itself. Squash or merge it instead." | |
| 680 | − | ), | |
| 681 | − | )); | |
| 682 | − | } | |
| 683 | − | }; | |
| 684 | − | match replay(&reader, &commits, (&base.hash, &base.tree_hash), &by, sign_off.as_deref(), &mut objects).await? { | |
| 685 | − | Replay::Done(replayed) if replayed.made.is_empty() => Err(NotMade::AlreadyThere), | |
| 686 | − | Replay::Done(replayed) => Ok(Made { | |
| 687 | − | commit: replayed.head, | |
| 688 | − | commits: Some(replayed.made.len() as u32), | |
| 689 | − | rebased: replayed.made, | |
| 690 | − | }), | |
| 691 | − | Replay::Conflict { commit, paths } => Err(refused( | |
| 692 | − | FailureCode::Conflict, | |
| 693 | − | format!( | |
| 694 | − | "Rebasing stopped at {}: it and {branch} changed the same lines of {}. Rebase the branch onto {branch} and resolve them, or merge it another way.", | |
| 695 | − | named_commit(&commit), | |
| 696 | − | name_files(&paths) | |
| 697 | − | ), | |
| 698 | − | )), | |
| 699 | − | Replay::Unsupported { commit, why } => Err(refused( | |
| 700 | − | FailureCode::Conflict, | |
| 701 | − | format!("g1t cannot rebase {} itself: {why}. Rebase the branch onto {branch}, or merge it another way.", named_commit(&commit)), | |
| 702 | − | )), | |
| 703 | − | } | |
| 704 | − | } | |
| 705 | − | }; | |
| 706 | − | let made = match made { | |
| 869 | + | let author = Signature { name: &author.name, email: &author.email, seconds }; | |
| 870 | + | let made = | |
| 871 | + | make_on(method, &reader, &target_history, history, &new, &message, (&author, &by), sign_off.as_deref(), branch, &mut objects) | |
| 872 | + | .await?; | |
| 873 | + | let (made, merge_base) = match made { | |
| 707 | 874 | Ok(made) => made, | |
| 708 | 875 | Err(NotMade::Refused(outcome)) => return Ok(outcome), | |
| 876 | + | Err(NotMade::Clash { message, .. }) => return Ok(Outcome::fail(FailureCode::Conflict, message)), | |
| 709 | 877 | Err(NotMade::AlreadyThere) => { | |
| 710 | 878 | return Ok(Outcome::Ok(Landed { commit: base.hash.clone(), previous: None, method, rebased: Vec::new() })); | |
| 711 | 879 | } | |
| 712 | 880 | }; | |
| 713 | − | // What the pull request's commits bring that the repository | |
| 714 | − | // lacks comes from the fork, with what was made after it. | |
| 715 | − | let pack = if held { | |
| 716 | − | write_pack(&objects) | |
| 717 | − | } else { | |
| 718 | − | let source_access = source_git.access(Scope::Read).await?; | |
| 719 | − | let fetched = land::fetch_pack(&source_access, &new, Some(&merge_base)).await?; | |
| 720 | − | extend_pack(&fetched, &objects).map_err(worker::Error::RustError)? | |
| 721 | − | }; | |
| 881 | + | let pack = pack_for(held, source_git, &new, &merge_base, &objects).await?; | |
| 722 | 882 | let target_access = target_git.access(Scope::Write).await?; | |
| 723 | 883 | let pushed = land::push_pack(&target_access, branch, Some(&base.hash), &made.commit, pack).await?; | |
| 724 | 884 | self.refs_moved(&target.id).await; | |
| ⋯ | |||
| 743 | 903 | } | |
| 744 | 904 | } | |
| 745 | 905 | ||
| 746 | − | /// A merge commit (parents `base` and `new`) or a squashed commit | |
| 747 | − | /// (parent `base`) whose tree is the pull request's changes since | |
| 748 | − | /// `trees.0` (the merge base's tree; `trees.1` is its head's) applied | |
| 749 | − | /// to `base`'s. | |
| 750 | − | #[allow(clippy::too_many_arguments)] | |
| 751 | − | async fn made_merge<R: GitRepo>( | |
| 752 | − | &self, | |
| 753 | − | reader: &Reader<'_, R>, | |
| 754 | − | trees: (&str, &str, &Commit), | |
| 755 | − | new: &str, | |
| 756 | − | squash: bool, | |
| 757 | − | message: &str, | |
| 758 | − | co_authored: &[String], | |
| 759 | − | sign_off: Option<&str>, | |
| 760 | − | (author, committer): (&Signature<'_>, &Signature<'_>), | |
| 761 | − | branch: &str, | |
| 762 | − | objects: &mut Vec<(ObjectKind, Vec<u8>)>, | |
| 763 | − | ) -> Result<std::result::Result<Made, NotMade>> { | |
| 764 | − | let (merge_base_tree, head_tree, base) = trees; | |
| 765 | − | let verb = if squash { "squash" } else { "merge" }; | |
| 766 | − | let tree = match apply(reader, Some(merge_base_tree), head_tree, &base.tree_hash, objects).await? { | |
| 767 | − | Applied::Tree(tree) => tree, | |
| 768 | − | Applied::Conflict(paths) => { | |
| 769 | − | return Ok(Err(refused( | |
| 770 | − | FailureCode::Conflict, | |
| 771 | − | format!( | |
| 772 | − | "This pull request and {branch} changed the same lines of {}. Bring the pull request up to date with {branch} and resolve them, then merge again.", | |
| 773 | − | name_files(&paths) | |
| 774 | − | ), | |
| 775 | − | ))); | |
| 776 | − | } | |
| 777 | − | Applied::Unsupported(why) => { | |
| 778 | − | return Ok(Err(refused( | |
| 779 | − | FailureCode::Conflict, | |
| 780 | − | format!("g1t cannot {verb} this pull request itself: {why}. Bring it up to date with {branch}, or merge it another way."), | |
| 781 | − | ))); | |
| 782 | − | } | |
| 906 | + | /// `build_queue_step`: one merge queue entry landed by its method on | |
| 907 | + | /// `onto`, a commit of the target, as `land_by` would land it on a | |
| 908 | + | /// branch whose head that is, and `push_branch` pointed at what was | |
| 909 | + | /// made. Nothing else moves. The work service decides who may merge; | |
| 910 | + | /// the committer must still be able to. | |
| 911 | + | pub(crate) async fn build_queue_step(&self, a: BuildQueueStepArgs) -> Result<Outcome<QueueStep>> { | |
| 912 | + | let actor = Some(a.actor.clone()); | |
| 913 | + | let (Some(target), Some(source)) = (self.registry.by_id(&a.target_id).await?, self.registry.by_id(&a.source_id).await?) else { | |
| 914 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such repository.")); | |
| 783 | 915 | }; | |
| 784 | − | // Squashing what is there already would make an empty commit. | |
| 785 | − | if squash && tree == base.tree_hash { | |
| 786 | − | return Ok(Err(NotMade::AlreadyThere)); | |
| 916 | + | if !crate::registry::can(&target, &actor, g1t_contracts::access::Capability::Merge) || !a.actor.verified { | |
| 917 | + | return Ok(Outcome::fail(FailureCode::Forbidden, "Whoever queued it can no longer merge here.")); | |
| 787 | 918 | } | |
| 788 | − | let mut trailers: Vec<String> = co_authored.to_vec(); | |
| 789 | − | trailers.extend(sign_off.map(str::to_owned)); | |
| 790 | − | let by = if squash { &author.email } else { &committer.email }; | |
| 791 | − | let message = with_trailers(&tidy_co_authors(message, by, co_authored), &trailers); | |
| 792 | − | let bytes = if squash { | |
| 793 | − | commit_object_by(&tree, &[&base.hash], author, committer, &message) | |
| 919 | + | // A fork's change lands only on a repository in its network. | |
| 920 | + | if source.id != target.id && self.pull_target(&source, Some(&target.id)).await?.is_none_or(|found| found.id != target.id) { | |
| 921 | + | return Ok(Outcome::fail(FailureCode::NotFound, "No such repository.")); | |
| 922 | + | } | |
| 923 | + | let from_fork = source.id != target.id; | |
| 924 | + | self.live(&source).await?; | |
| 925 | + | let source_git = self.store.open(&store_key(&source)).await?; | |
| 926 | + | let target_git = self.store.open(&store_key(&target)).await?; | |
| 927 | + | let refused = |message: String| Ok(Outcome::Ok(QueueStep::Refused { message })); | |
| 928 | + | // What was queued is the commit: its branch may have moved since. | |
| 929 | + | let (history, onto_history) = try_join(source_git.log(&a.commit, MAX_ANCESTRY), target_git.log(&a.onto, MAX_ANCESTRY)).await?; | |
| 930 | + | let (Some(head), Some(base)) = (history.first(), onto_history.first()) else { | |
| 931 | + | return refused(format!("{} or the state it is built on is no longer there.", short(&a.commit))); | |
| 932 | + | }; | |
| 933 | + | let new = head.hash.clone(); | |
| 934 | + | let base = base.hash.clone(); | |
| 935 | + | let held = !from_fork || !target_git.log(&new, 1).await?.is_empty(); | |
| 936 | + | let reader = Reader::new(&target_git, (!held).then_some(&source_git)); | |
| 937 | + | let place = target.default_branch.clone(); | |
| 938 | + | let mut objects: Vec<(ObjectKind, Vec<u8>)> = Vec::new(); | |
| 939 | + | let committer = self.commit_identity(&a.actor).await; | |
| 940 | + | let author = match (&a.author, a.method) { | |
| 941 | + | (Some(author), MergeMethod::Squash) => self.commit_identity(author).await, | |
| 942 | + | _ => committer.clone(), | |
| 943 | + | }; | |
| 944 | + | let sign_off = a.sign_off.then(|| sign_off_trailer(&committer.name, &committer.email)); | |
| 945 | + | let seconds = now_ms() / 1000; | |
| 946 | + | let by = Signature { name: &committer.name, email: &committer.email, seconds }; | |
| 947 | + | let author = Signature { name: &author.name, email: &author.email, seconds }; | |
| 948 | + | let step = queue_step_on( | |
| 949 | + | &reader, | |
| 950 | + | held, | |
| 951 | + | a.method, | |
| 952 | + | (&onto_history, &history), | |
| 953 | + | a.message.as_deref(), | |
| 954 | + | (&author, &by), | |
| 955 | + | sign_off.as_deref(), | |
| 956 | + | &place, | |
| 957 | + | &mut objects, | |
| 958 | + | ) | |
| 959 | + | .await?; | |
| 960 | + | let (commit, rebased, merge_base) = match step { | |
| 961 | + | StepMade::Made { commit, rebased, merge_base } => (commit, rebased, merge_base), | |
| 962 | + | StepMade::Conflict { message, paths } => return Ok(Outcome::Ok(QueueStep::Conflict { message, paths })), | |
| 963 | + | StepMade::Refused(message) => return refused(message), | |
| 964 | + | }; | |
| 965 | + | // Nothing new when it was there already. | |
| 966 | + | let pack = if commit == base { | |
| 967 | + | write_pack(&objects) | |
| 794 | 968 | } else { | |
| 795 | − | commit_object_by(&tree, &[&base.hash, new], committer, committer, &message) | |
| 969 | + | pack_for(held, &source_git, &new, &merge_base, &objects).await? | |
| 796 | 970 | }; | |
| 797 | − | let commit = object_id(ObjectKind::Commit, &bytes); | |
| 798 | − | objects.push((ObjectKind::Commit, bytes)); | |
| 799 | − | Ok(Ok(Made { commit, rebased: Vec::new(), commits: squash.then_some(1) })) | |
| 971 | + | let old = target_git.log(&a.push_branch, 1).await?.into_iter().next().map(|commit| commit.hash); | |
| 972 | + | let target_access = target_git.access(Scope::Write).await?; | |
| 973 | + | let pushed = land::push_pack(&target_access, &a.push_branch, old.as_deref(), &commit, pack).await?; | |
| 974 | + | self.refs_moved(&target.id).await; | |
| 975 | + | Ok(match pushed { | |
| 976 | + | Ok(()) => Outcome::Ok(QueueStep::Built { commit, rebased }), | |
| 977 | + | Err(reason) => Outcome::fail(FailureCode::Conflict, format!("{} could not be updated: {reason}", a.push_branch)), | |
| 978 | + | }) | |
| 800 | 979 | } | |
| 801 | 980 | ||
| 802 | 981 | /// `update_pull_branch` by `rebase`: makes the commits of `branch` of | |
| ⋯ | |||
| 1270 | 1449 | git.keep(&made); | |
| 1271 | 1450 | assert_eq!(block_on(git.read_blob(&object_id(ObjectKind::Blob, b"made\n"))).unwrap().as_deref(), Some(&b"made\n"[..])); | |
| 1272 | 1451 | } | |
| 1452 | + | ||
| 1453 | + | /// A commit made in a test, as the store would list it. | |
| 1454 | + | fn made_commit(objects: &[(ObjectKind, Vec<u8>)], id: &str) -> Commit { | |
| 1455 | + | let bytes = objects | |
| 1456 | + | .iter() | |
| 1457 | + | .find(|(kind, bytes)| *kind == ObjectKind::Commit && object_id(ObjectKind::Commit, bytes) == id) | |
| 1458 | + | .map(|(_, bytes)| String::from_utf8(bytes.clone()).unwrap()) | |
| 1459 | + | .expect("made"); | |
| 1460 | + | let field = |name: &str| bytes.lines().filter_map(|line| line.strip_prefix(name)).map(str::to_owned).collect::<Vec<_>>(); | |
| 1461 | + | Commit { | |
| 1462 | + | hash: id.to_owned(), | |
| 1463 | + | tree_hash: field("tree ").remove(0), | |
| 1464 | + | message: bytes.split_once("\n\n").map(|(_, message)| message.to_owned()).unwrap_or_default(), | |
| 1465 | + | author: Person { name: "Di".to_owned(), email: "di@example.com".to_owned() }, | |
| 1466 | + | parents: field("parent "), | |
| 1467 | + | authored_at: "2026-10-01T00:00:00.000Z".to_owned(), | |
| 1468 | + | } | |
| 1469 | + | } | |
| 1470 | + | ||
| 1471 | + | #[test] | |
| 1472 | + | fn each_queue_entry_lands_by_its_own_method_on_the_state_ahead_of_it() { | |
| 1473 | + | let (mut store, [m, b, h1, h2]) = diverged(); | |
| 1474 | + | // A second change, from the same start, to another file. | |
| 1475 | + | let g = store.commit("g", &["m"], "eve", &[("README.md", "hi\n"), ("src/lib.rs", "a\nb\nc\nd\ne\n"), ("docs.md", "docs\n")]); | |
| 1476 | + | let k = store.commit("k", &["m"], "eve", &[("README.md", "hi\n"), ("src/lib.rs", "a\nb\nc\nd\ne\n"), ("k.md", "k\n")]); | |
| 1477 | + | let git = Reader::new(&store, None); | |
| 1478 | + | let by = Signature { name: "Di", email: "di@example.com", seconds: 7 }; | |
| 1479 | + | let author = Signature { name: "bo", email: "bo@example.com", seconds: 7 }; | |
| 1480 | + | let mut objects = Vec::new(); | |
| 1481 | + | let step = |method, onto: &[Commit], history: &[Commit], objects: &mut Vec<_>| { | |
| 1482 | + | block_on(queue_step_on(&git, true, method, (onto, history), Some("Land it"), (&author, &by), None, "main", objects)).unwrap() | |
| 1483 | + | }; | |
| 1484 | + | let onto = vec![b.clone(), m.clone()]; | |
| 1485 | + | ||
| 1486 | + | // The first squashes onto the default branch: one commit, one parent. | |
| 1487 | + | let StepMade::Made { commit: first, .. } = step(MergeMethod::Squash, &onto, &[h2.clone(), h1.clone(), m.clone()], &mut objects) else { | |
| 1488 | + | panic!("not squashed") | |
| 1489 | + | }; | |
| 1490 | + | let first = made_commit(&objects, &first); | |
| 1491 | + | assert_eq!(first.parents, ["b"]); | |
| 1492 | + | ||
| 1493 | + | // The second is a merge commit on the first's state, so landing it | |
| 1494 | + | // is a fast-forward from there. | |
| 1495 | + | let onto = vec![first.clone(), b.clone(), m.clone()]; | |
| 1496 | + | let StepMade::Made { commit: second, .. } = step(MergeMethod::Merge, &onto, &[g.clone(), m.clone()], &mut objects) else { | |
| 1497 | + | panic!("not merged") | |
| 1498 | + | }; | |
| 1499 | + | let second = made_commit(&objects, &second); | |
| 1500 | + | assert_eq!(second.parents, [first.hash.clone(), "g".to_owned()]); | |
| 1501 | + | assert_eq!( | |
| 1502 | + | Store::files(&git, &second.tree_hash), | |
| 1503 | + | files(&[("README.md", "hello\n"), ("docs.md", "docs\n"), ("src/lib.rs", "A\nb\nc\nd\nE\n"), ("src/new.rs", "new\n")]) | |
| 1504 | + | ); | |
| 1505 | + | ||
| 1506 | + | // Queued again, its changes are there already: the state is kept. | |
| 1507 | + | let onto = vec![second.clone(), first.clone(), b.clone(), m.clone()]; | |
| 1508 | + | let StepMade::Made { commit, .. } = step(MergeMethod::Rebase, &onto, &[h2.clone(), h1.clone(), m.clone()], &mut objects) else { | |
| 1509 | + | panic!("not kept") | |
| 1510 | + | }; | |
| 1511 | + | assert_eq!(commit, second.hash); | |
| 1512 | + | ||
| 1513 | + | // A fast-forward needs its head to hold the state ahead of it. | |
| 1514 | + | let StepMade::Refused(why) = step(MergeMethod::FastForward, &onto, &[k, m.clone()], &mut objects) else { | |
| 1515 | + | panic!("fast-forwarded") | |
| 1516 | + | }; | |
| 1517 | + | assert!(why.contains("fast-forward"), "{why}"); | |
| 1518 | + | } | |
| 1519 | + | ||
| 1520 | + | #[test] | |
| 1521 | + | fn a_fast_forward_entry_is_its_own_head_and_a_clash_names_the_files() { | |
| 1522 | + | let (mut store, [m, b, h1, h2]) = diverged(); | |
| 1523 | + | let ahead = store.commit("f", &["b"], "eve", &[("README.md", "hello\n"), ("src/lib.rs", "a\nb\nc\nd\nE\n"), ("f.md", "f\n")]); | |
| 1524 | + | let clash = store.commit("x", &["m"], "eve", &[("README.md", "hey\n"), ("src/lib.rs", "a\nb\nc\nd\ne\n")]); | |
| 1525 | + | let git = Reader::new(&store, None); | |
| 1526 | + | let by = Signature { name: "Di", email: "di@example.com", seconds: 7 }; | |
| 1527 | + | let mut objects = Vec::new(); | |
| 1528 | + | let onto = vec![b.clone(), m.clone()]; | |
| 1529 | + | let made = block_on(queue_step_on(&git, true, MergeMethod::FastForward, (&onto, &[ahead.clone(), b.clone(), m.clone()]), None, (&by, &by), None, "main", &mut objects)).unwrap(); | |
| 1530 | + | assert!(matches!(made, StepMade::Made { ref commit, .. } if commit == "f"), "{made:?}"); | |
| 1531 | + | assert!(objects.is_empty()); | |
| 1532 | + | let clashed = block_on(queue_step_on(&git, true, MergeMethod::Rebase, (&onto, &[clash, m.clone()]), None, (&by, &by), None, "main", &mut objects)).unwrap(); | |
| 1533 | + | assert!(matches!(clashed, StepMade::Conflict { ref paths, .. } if paths == &["README.md".to_owned()]), "{clashed:?}"); | |
| 1534 | + | let _ = (h1, h2); | |
| 1535 | + | } | |
| 1273 | 1536 | } | |
| 1 | + | -- How each merge queue entry lands (queue.rs): the method whoever queued | |
| 2 | + | -- it asked for, else the merge queue rule's, and the commit message they | |
| 3 | + | -- wrote for a merge or a squash. NULL method: queued before methods, when | |
| 4 | + | -- every state was built with merge commits. | |
| 5 | + | ALTER TABLE queue_entries ADD COLUMN merge_method TEXT; | |
| 6 | + | ALTER TABLE queue_entries ADD COLUMN commit_title TEXT; | |
| 7 | + | ALTER TABLE queue_entries ADD COLUMN commit_message TEXT; |
| 50 | 50 | use crate::Work; | |
| 51 | 51 | use crate::checks::{hash, new_token}; | |
| 52 | 52 | use crate::decisions::NewDecision; | |
| 53 | − | use crate::queue::{BRANCH_PREFIX, EntryRow}; | |
| 53 | + | use crate::queue::{BRANCH_PREFIX, Built, EntryRow}; | |
| 54 | 54 | use crate::rows::ValueRow; | |
| 55 | 55 | ||
| 56 | 56 | /// The most extra combinations built to narrow down one failure. | |
| ⋯ | |||
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | 122 | impl Failure { | |
| 123 | − | /// What a sandbox reported for a state that failed. `unchecked` is set | |
| 123 | + | /// What was reported for a state that failed. `unchecked` is set | |
| 124 | 124 | /// when the state was built but nothing could check it. | |
| 125 | 125 | pub(crate) fn of_report(report: &ReportQueueArgs, unchecked: Option<&[String]>) -> Failure { | |
| 126 | 126 | let (kind, check) = if report.conflict_with.is_some() || !report.conflicts.is_empty() { | |
| ⋯ | |||
| 231 | 231 | }; | |
| 232 | 232 | } | |
| 233 | 233 | match failure.kind { | |
| 234 | − | // The sandbox names the entry ahead that changed the same files. | |
| 234 | + | // The build names the entry ahead that changed the same files. | |
| 235 | 235 | FailureKind::Conflict => Plan::Blame(failure.conflict_with.filter(|n| ahead.contains(n)).into_iter().collect()), | |
| 236 | 236 | // Neither is about how the changes combine. | |
| 237 | 237 | FailureKind::Missing | FailureKind::Build => Plan::Blame(Vec::new()), | |
| ⋯ | |||
| 651 | 651 | ||
| 652 | 652 | // --- Smaller combinations ------------------------------------------------- | |
| 653 | 653 | ||
| 654 | − | /// One job for each smaller combination waiting to be built, and the | |
| 655 | − | /// end of any that took too long. | |
| 656 | − | pub(crate) async fn probe_jobs(&self, repo_id: &str) -> Result<Vec<QueueJob>> { | |
| 654 | + | /// Builds each smaller combination waiting to be built, and ends any | |
| 655 | + | /// that took too long. | |
| 656 | + | pub(crate) async fn build_probes(&self, repo_id: &str) -> Result<()> { | |
| 657 | 657 | let probes = self | |
| 658 | 658 | .db | |
| 659 | 659 | .prepare("SELECT * FROM queue_probes WHERE repo_id = ? AND state IN ('waiting', 'testing')") | |
| ⋯ | |||
| 662 | 662 | .await? | |
| 663 | 663 | .results::<ProbeRow>()?; | |
| 664 | 664 | if probes.is_empty() { | |
| 665 | − | return Ok(Vec::new()); | |
| 665 | + | return Ok(()); | |
| 666 | 666 | } | |
| 667 | 667 | let rule = self.queue_rule(repo_id).await?; | |
| 668 | 668 | let stale = rfc3339(now_ms().saturating_sub(u64::from(rule.check_response_timeout_minutes.max(1)) * 60 * 1000)); | |
| 669 | − | let mut jobs = Vec::new(); | |
| 670 | 669 | for probe in probes { | |
| 671 | 670 | if probe.state == "testing" { | |
| 672 | 671 | if probe.tested_at.as_deref().is_none_or(|at| at < stale.as_str()) { | |
| ⋯ | |||
| 674 | 673 | } | |
| 675 | 674 | continue; | |
| 676 | 675 | } | |
| 677 | − | if let Some(job) = self.probe_job(&probe).await? { | |
| 678 | − | jobs.push(job); | |
| 679 | − | } | |
| 676 | + | self.build_probe(&probe).await?; | |
| 680 | 677 | } | |
| 681 | − | Ok(jobs) | |
| 678 | + | Ok(()) | |
| 682 | 679 | } | |
| 683 | 680 | ||
| 684 | − | async fn probe_job(&self, probe: &ProbeRow) -> Result<Option<QueueJob>> { | |
| 681 | + | /// Builds a smaller combination as the queue builds a state: from the | |
| 682 | + | /// base it was tested on, each entry landed by its method on the one | |
| 683 | + | /// before, every step pushed to the probe's branch; then reports it. | |
| 684 | + | async fn build_probe(&self, probe: &ProbeRow) -> Result<()> { | |
| 685 | 685 | let found = match self.entry_by_id(&probe.entry_id).await? { | |
| 686 | 686 | Some(row) => { | |
| 687 | 687 | let diagnosis = row | |
| ⋯ | |||
| 698 | 698 | None => None, | |
| 699 | 699 | }; | |
| 700 | 700 | // Nothing is waiting on it any more. | |
| 701 | − | let Some((diagnosis, pull, actor)) = found else { | |
| 701 | + | let Some((diagnosis, _, actor)) = found else { | |
| 702 | 702 | self.probe_result(probe, ProbeResult::Stopped, Some("Its failure was already reported.")).await?; | |
| 703 | − | return Ok(None); | |
| 703 | + | return Ok(()); | |
| 704 | 704 | }; | |
| 705 | 705 | let repo: Outcome<Repo> = g1t_kit::call( | |
| 706 | 706 | &self.repos, | |
| 707 | 707 | "get_by_id", | |
| 708 | 708 | &GetByIdArgs { | |
| 709 | 709 | id: probe.repo_id.clone(), | |
| 710 | − | viewer: Some(actor), | |
| 710 | + | viewer: Some(actor.clone()), | |
| 711 | 711 | }, | |
| 712 | 712 | ) | |
| 713 | 713 | .await?; | |
| 714 | 714 | let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else { | |
| 715 | 715 | self.probe_result(probe, ProbeResult::Stopped, Some("The repository is archived.")).await?; | |
| 716 | − | return Ok(None); | |
| 716 | + | return Ok(()); | |
| 717 | 717 | }; | |
| 718 | 718 | let subset = probe.subset(); | |
| 719 | 719 | let mut stack = Vec::new(); | |
| ⋯ | |||
| 749 | 749 | .first::<ValueRow>(None) | |
| 750 | 750 | .await?; | |
| 751 | 751 | if claimed.is_none() { | |
| 752 | − | return Ok(None); | |
| 752 | + | return Ok(()); | |
| 753 | 753 | } | |
| 754 | − | Ok(Some(QueueJob { | |
| 754 | + | let sign_off = self.settings(&repo.id).await?.require_web_commit_sign_off; | |
| 755 | + | let mut report = ReportQueueArgs { | |
| 755 | 756 | entry_id: probe.id.clone(), | |
| 756 | 757 | token, | |
| 757 | − | repo: RepoPath { | |
| 758 | − | namespace: repo.namespace.clone(), | |
| 759 | − | name: repo.name.clone(), | |
| 760 | − | }, | |
| 761 | − | default_branch: repo.default_branch.clone(), | |
| 762 | − | base_commit: diagnosis.base_commit.clone(), | |
| 763 | − | branch: probe.branch(), | |
| 764 | − | stack, | |
| 765 | − | checks: Vec::new(), | |
| 766 | − | contract_checks: Vec::new(), | |
| 767 | − | actor: pull.owner().clone(), | |
| 768 | − | })) | |
| 758 | + | combined_commit: None, | |
| 759 | + | results: Vec::new(), | |
| 760 | + | error: None, | |
| 761 | + | conflict_with: None, | |
| 762 | + | conflicts: Vec::new(), | |
| 763 | + | }; | |
| 764 | + | let mut onto = diagnosis.base_commit.clone(); | |
| 765 | + | for item in &stack { | |
| 766 | + | match self.build_step(&repo, &onto, item, &probe.branch(), &actor, sign_off).await? { | |
| 767 | + | Built::State(commit) => onto = commit, | |
| 768 | + | Built::Conflict { message, paths } => { | |
| 769 | + | report.error = Some(message); | |
| 770 | + | report.conflicts = paths; | |
| 771 | + | break; | |
| 772 | + | } | |
| 773 | + | Built::Failed(message) => { | |
| 774 | + | report.error = Some(message); | |
| 775 | + | break; | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | } | |
| 779 | + | if report.error.is_none() { | |
| 780 | + | report.combined_commit = Some(onto); | |
| 781 | + | } | |
| 782 | + | self.report_probe(report).await?; | |
| 783 | + | Ok(()) | |
| 769 | 784 | } | |
| 770 | 785 | ||
| 771 | − | /// A sandbox's result for a smaller combination: built, it waits for | |
| 786 | + | /// The result of building a smaller combination: built, it waits for | |
| 772 | 787 | /// its `merge_group` workflows as a queue state does. | |
| 773 | 788 | pub(crate) async fn report_probe(&self, a: ReportQueueArgs) -> Result<Outcome<QueueState>> { | |
| 774 | 789 | let probe = self | |
| 2250 | 2250 | ||
| 2251 | 2251 | // A repository that merges through a queue: it joins the queue, and | |
| 2252 | 2252 | // lands once its state together with everything ahead has passed. | |
| 2253 | − | // The queue lands the state it tested, whatever the method. | |
| 2253 | + | // Its state is built by the method asked for, else the merge queue | |
| 2254 | + | // rule's, and lands exactly as tested (queue.rs). | |
| 2254 | 2255 | if settings.merge_queue { | |
| 2255 | 2256 | if !a.actor.verified { | |
| 2256 | 2257 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 2257 | 2258 | } | |
| 2258 | 2259 | check!(allowed(Some(&a.actor), &repo, Capability::Merge)); | |
| 2260 | + | let queued = match a.merge_method { | |
| 2261 | + | Some(_) => method, | |
| 2262 | + | None => { | |
| 2263 | + | let rule = self.queue_rule(&repo.id).await?; | |
| 2264 | + | match merging::choose_method(Some(rule.merge_method), &choosing, &requirements, &base) { | |
| 2265 | + | Ok(method) => method, | |
| 2266 | + | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 2267 | + | } | |
| 2268 | + | } | |
| 2269 | + | }; | |
| 2259 | 2270 | self.record_merge_evaluations(&repo, &pull, &gate).await; | |
| 2260 | 2271 | // Landing honours the same bypass request against the rules as | |
| 2261 | 2272 | // they are then (queue.rs `settle`). | |
| 2262 | − | return self.enqueue(&repo, &pull, &a.actor, a.keep_issue_open, a.bypass_rules).await; | |
| 2273 | + | let message = (a.commit_title.as_deref(), a.commit_message.as_deref()); | |
| 2274 | + | return self.enqueue(&repo, &pull, &a.actor, a.keep_issue_open, a.bypass_rules, queued, message).await; | |
| 2263 | 2275 | } | |
| 2264 | 2276 | ||
| 2265 | 2277 | // The commit it makes, for a merge or a squash: what the merger |
| 2 | 2 | //! of them, and only a combination that passed reaches the default branch. | |
| 3 | 3 | //! | |
| 4 | 4 | //! A batch of up to the merge queue rule's `max_entries_to_build` entries (4 unless | |
| 5 | − | //! the default branch's rules say otherwise) is tested at once, speculatively: one | |
| 6 | − | //! sandbox per entry builds the default branch with that entry and every | |
| 7 | − | //! entry ahead of it merged in, and pushes the result to | |
| 5 | + | //! the default branch's rules say otherwise) is tested at once, speculatively. | |
| 6 | + | //! Each entry's state is built in the repository itself (repos | |
| 7 | + | //! `build_queue_step`): the entry landed by its own method (merge commit, | |
| 8 | + | //! squash, rebase or fast-forward: the one asked for when it was queued, | |
| 9 | + | //! else the merge queue rule's) on the state of the entry ahead of it, or | |
| 10 | + | //! on the default branch for the first, and pushed to | |
| 8 | 11 | //! `g1t-queue/<entry>-<attempt>`. The repository's `merge_group` workflows then run | |
| 9 | 12 | //! on that commit, and the default branch's required checks must pass | |
| 10 | 13 | //! there. Entries land in order, each by moving the default branch to its | |
| 11 | − | //! tested state, once everything ahead has landed. One that fails leaves | |
| 14 | + | //! tested state once everything ahead has landed: a fast-forward to | |
| 15 | + | //! exactly the commits that were tested, each built as its method makes | |
| 16 | + | //! them. One that fails leaves | |
| 12 | 17 | //! the queue with a failed check run; the entries behind it are tested | |
| 13 | 18 | //! again without it. Which entry answers for a failure, which entries | |
| 14 | 19 | //! ahead it breaks with, who is told and how it rejoins: integration.rs. | |
| ⋯ | |||
| 16 | 21 | use futures_util::future::try_join_all; | |
| 17 | 22 | use g1t_contracts::events::{ChecksEvent, QueueChanged}; | |
| 18 | 23 | use g1t_contracts::repos::{ | |
| 19 | − | DeleteBranchArgs, GetByIdArgs, HeadArgs, LandArgs, Landed, Repo, RepoPath, | |
| 24 | + | BuildQueueStepArgs, DeleteBranchArgs, GetByIdArgs, HeadArgs, LandArgs, Landed, QueueStep, Repo, RepoPath, | |
| 20 | 25 | }; | |
| 21 | 26 | use g1t_contracts::time::rfc3339; | |
| 22 | 27 | use g1t_contracts::work::*; | |
| 28 | + | use g1t_contracts::rules::MergeMethod; | |
| 23 | 29 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; | |
| 24 | 30 | use g1t_kit::now_ms; | |
| 25 | 31 | use serde::Deserialize; | |
| ⋯ | |||
| 44 | 50 | WHERE id = ?6 AND state = 'waiting' RETURNING id AS value"; | |
| 45 | 51 | ||
| 46 | 52 | const ENQUEUE: &str = "INSERT INTO queue_entries | |
| 47 | − | (id, repo_id, pull_id, number, state, enqueued_by, keep_issue_open, created_at, bypass_rules) | |
| 48 | − | SELECT ?1, ?2, ?3, ?4, 'waiting', ?5, ?6, ?7, ?8 | |
| 53 | + | (id, repo_id, pull_id, number, state, enqueued_by, keep_issue_open, created_at, bypass_rules, | |
| 54 | + | merge_method, commit_title, commit_message) | |
| 55 | + | SELECT ?1, ?2, ?3, ?4, 'waiting', ?5, ?6, ?7, ?8, ?9, ?10, ?11 | |
| 49 | 56 | WHERE NOT EXISTS (SELECT 1 FROM queue_entries WHERE pull_id = ?3 AND state IN ('waiting', 'testing', 'passed')) | |
| 50 | 57 | RETURNING id AS value"; | |
| 51 | 58 | ||
| ⋯ | |||
| 149 | 156 | /// How many merge_group runs this attempt's dispatch registered. | |
| 150 | 157 | #[serde(default)] | |
| 151 | 158 | workflow_runs: u32, | |
| 159 | + | /// How it lands (`MergeMethod`); none for an entry queued before | |
| 160 | + | /// methods, whose states were built with merge commits. | |
| 161 | + | #[serde(default)] | |
| 162 | + | merge_method: Option<String>, | |
| 163 | + | /// What whoever queued it wrote for a merge or squash commit. | |
| 164 | + | #[serde(default)] | |
| 165 | + | commit_title: Option<String>, | |
| 166 | + | #[serde(default)] | |
| 167 | + | commit_message: Option<String>, | |
| 152 | 168 | } | |
| 153 | 169 | ||
| 154 | 170 | impl EntryRow { | |
| ⋯ | |||
| 178 | 194 | attempt_branch(&self.id, self.attempt_id.as_deref()) | |
| 179 | 195 | } | |
| 180 | 196 | ||
| 197 | + | /// How it lands. | |
| 198 | + | pub(crate) fn method(&self) -> MergeMethod { | |
| 199 | + | MergeMethod::ALL | |
| 200 | + | .into_iter() | |
| 201 | + | .find(|method| self.merge_method.as_deref() == Some(method.as_str())) | |
| 202 | + | .unwrap_or(MergeMethod::Merge) | |
| 203 | + | } | |
| 204 | + | ||
| 181 | 205 | /// Exactly what this attempt built, as recorded when it was claimed. | |
| 182 | 206 | pub(crate) fn tested_stack(&self) -> Option<Vec<QueueStackItem>> { | |
| 183 | 207 | self.tested_stack.as_deref().and_then(|stack| serde_json::from_str(stack).ok()) | |
| ⋯ | |||
| 201 | 225 | } | |
| 202 | 226 | } | |
| 203 | 227 | ||
| 228 | + | /// What building one entry's state in the repository came to. | |
| 229 | + | pub(crate) enum Built { | |
| 230 | + | /// The state's commit. | |
| 231 | + | State(String), | |
| 232 | + | /// Its change clashes with the state ahead in these files. | |
| 233 | + | Conflict { message: String, paths: Vec<String> }, | |
| 234 | + | /// It could not be built, and why. | |
| 235 | + | Failed(String), | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | /// The nearest entry ahead (each with the files its pull request changes) | |
| 239 | + | /// that changed one of `paths`, which an entry's change clashed in. | |
| 240 | + | pub(crate) fn clashed_with(ahead: &[(QueueStackItem, Vec<String>)], paths: &[String]) -> Option<u32> { | |
| 241 | + | ahead | |
| 242 | + | .iter() | |
| 243 | + | .rev() | |
| 244 | + | .find(|(_, files)| paths.iter().any(|path| files.contains(path))) | |
| 245 | + | .map(|(item, _)| item.number) | |
| 246 | + | } | |
| 247 | + | ||
| 204 | 248 | /// Why an entry left because what it would build is gone. | |
| 205 | 249 | const BRANCH_GONE: &str = "Its branch no longer exists."; | |
| 206 | 250 | const PULL_GONE: &str = "Its pull request no longer exists."; | |
| ⋯ | |||
| 268 | 312 | .await | |
| 269 | 313 | } | |
| 270 | 314 | ||
| 271 | − | /// Puts a pull request that may merge into the queue instead. Merging | |
| 272 | − | /// it again while it is queued changes nothing. | |
| 315 | + | /// Puts a pull request that may merge into the queue instead, to land | |
| 316 | + | /// by `method` with the commit message `message` (title, body) asked | |
| 317 | + | /// for, if any. Merging it again while it is queued changes nothing. | |
| 318 | + | #[allow(clippy::too_many_arguments)] | |
| 273 | 319 | pub(crate) async fn enqueue( | |
| 274 | 320 | &self, | |
| 275 | 321 | repo: &Repo, | |
| ⋯ | |||
| 277 | 323 | actor: &User, | |
| 278 | 324 | keep_issue_open: bool, | |
| 279 | 325 | bypass_rules: bool, | |
| 326 | + | method: MergeMethod, | |
| 327 | + | message: (Option<&str>, Option<&str>), | |
| 280 | 328 | ) -> Result<Outcome<Pull>> { | |
| 281 | 329 | if self.queued_entry(&pull.id).await?.is_some() { | |
| 282 | 330 | return Ok(Outcome::Ok(pull.clone())); | |
| ⋯ | |||
| 294 | 342 | u32::from(keep_issue_open).into(), | |
| 295 | 343 | rfc3339(now).into(), | |
| 296 | 344 | u32::from(bypass_rules).into(), | |
| 345 | + | method.as_str().into(), | |
| 346 | + | message.0.map_or(JsValue::NULL, JsValue::from), | |
| 347 | + | message.1.map_or(JsValue::NULL, JsValue::from), | |
| 297 | 348 | ])? | |
| 298 | 349 | .first::<crate::rows::ValueRow>(None) | |
| 299 | 350 | .await?; | |
| ⋯ | |||
| 537 | 588 | Ok(()) | |
| 538 | 589 | } | |
| 539 | 590 | ||
| 540 | − | /// The next batch to test, if nothing is being tested now: one job per | |
| 541 | − | /// entry, each building the default branch with that entry and every | |
| 542 | − | /// entry ahead of it. | |
| 591 | + | /// Builds the next batch, if nothing is being tested now: each entry | |
| 592 | + | /// landed by its own method on the state of the entries ahead of it, | |
| 593 | + | /// in the repository itself (`build_step`), and its `merge_group` | |
| 594 | + | /// workflows started there. No sandbox builds anything, so the jobs | |
| 595 | + | /// returned are always none. | |
| 543 | 596 | /// | |
| 544 | 597 | /// Smaller combinations a failure is being narrowed down with | |
| 545 | 598 | /// (integration.rs) are built alongside, whatever the queue is doing. | |
| 546 | 599 | pub(crate) async fn queue_build(&self, a: QueueBuildArgs) -> Result<Vec<QueueJob>> { | |
| 547 | 600 | // Also resumes a publication interrupted after the Git update. | |
| 548 | 601 | self.settle(&a.repo_id).await?; | |
| 549 | − | let mut jobs = self.probe_jobs(&a.repo_id).await?; | |
| 550 | − | jobs.extend(self.queue_batch(a).await?); | |
| 551 | − | Ok(jobs) | |
| 602 | + | self.build_probes(&a.repo_id).await?; | |
| 603 | + | self.queue_batch(a).await?; | |
| 604 | + | Ok(Vec::new()) | |
| 552 | 605 | } | |
| 553 | 606 | ||
| 554 | − | async fn queue_batch(&self, a: QueueBuildArgs) -> Result<Vec<QueueJob>> { | |
| 607 | + | async fn queue_batch(&self, a: QueueBuildArgs) -> Result<()> { | |
| 555 | 608 | let active = self.entries(&a.repo_id, true).await?; | |
| 556 | 609 | // How the default branch's merge queue rule batches (rulesets.rs). | |
| 557 | 610 | let rule = self.queue_rule(&a.repo_id).await?; | |
| ⋯ | |||
| 572 | 625 | return Box::pin(self.queue_batch(a)).await; | |
| 573 | 626 | } | |
| 574 | 627 | if active.iter().any(|row| row.state() != QueueState::Waiting) { | |
| 575 | − | return Ok(Vec::new()); | |
| 628 | + | return Ok(()); | |
| 576 | 629 | } | |
| 577 | 630 | let batch: Vec<EntryRow> = active | |
| 578 | 631 | .into_iter() | |
| 579 | 632 | .take(rule.max_entries_to_build.max(1) as usize) | |
| 580 | 633 | .collect(); | |
| 581 | 634 | let Some(first) = batch.first() else { | |
| 582 | − | return Ok(Vec::new()); | |
| 635 | + | return Ok(()); | |
| 583 | 636 | }; | |
| 584 | 637 | // Too few to start yet, and the oldest has not waited long enough. | |
| 585 | 638 | if batch.len() < rule.min_entries_to_merge as usize | |
| 586 | 639 | && first.created_at.as_str() | |
| 587 | 640 | > minutes_ago(u64::from(rule.min_entries_wait_minutes)).as_str() | |
| 588 | 641 | { | |
| 589 | − | return Ok(Vec::new()); | |
| 642 | + | return Ok(()); | |
| 590 | 643 | } | |
| 591 | 644 | let Some(actor) = first.actor() else { | |
| 592 | − | return Ok(Vec::new()); | |
| 645 | + | return Ok(()); | |
| 593 | 646 | }; | |
| 594 | 647 | let repo: Outcome<Repo> = g1t_kit::call( | |
| 595 | 648 | &self.repos, | |
| ⋯ | |||
| 601 | 654 | ) | |
| 602 | 655 | .await?; | |
| 603 | 656 | let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else { | |
| 604 | − | return Ok(Vec::new()); | |
| 657 | + | return Ok(()); | |
| 605 | 658 | }; | |
| 606 | 659 | let base: Option<String> = g1t_kit::call( | |
| 607 | 660 | &self.repos, | |
| ⋯ | |||
| 613 | 666 | ) | |
| 614 | 667 | .await?; | |
| 615 | 668 | let Some(base) = base else { | |
| 616 | − | return Ok(Vec::new()); | |
| 669 | + | return Ok(()); | |
| 617 | 670 | }; | |
| 618 | 671 | ||
| 672 | + | let settings = self.settings(&repo.id).await?; | |
| 619 | 673 | // Each entry's change, as it is now. | |
| 620 | 674 | let mut items: Vec<(EntryRow, Pull, QueueStackItem)> = Vec::new(); | |
| 621 | 675 | for row in batch { | |
| ⋯ | |||
| 648 | 702 | namespace: repo.namespace.clone(), | |
| 649 | 703 | name: repo.name.clone(), | |
| 650 | 704 | }); | |
| 705 | + | // How it lands, and the commit message it lands with: worked | |
| 706 | + | // out once, so that the state is the one landing makes and | |
| 707 | + | // building it again makes the same commits. | |
| 708 | + | let method = row.method(); | |
| 709 | + | let message = match self | |
| 710 | + | .merge_message_for(&repo, &pull, &settings, method, row.commit_title.as_deref(), row.commit_message.as_deref()) | |
| 711 | + | .await? | |
| 712 | + | { | |
| 713 | + | Ok(message) => message, | |
| 714 | + | Err(problem) => { | |
| 715 | + | self.leave(&repo.id, &pull, QueueState::Removed, Some(problem)).await?; | |
| 716 | + | continue; | |
| 717 | + | } | |
| 718 | + | }; | |
| 651 | 719 | let item = QueueStackItem { | |
| 652 | 720 | number: pull.number, | |
| 653 | 721 | title: pull.title.clone(), | |
| ⋯ | |||
| 657 | 725 | .clone() | |
| 658 | 726 | .unwrap_or_else(|| repo.default_branch.clone()), | |
| 659 | 727 | commit, | |
| 728 | + | method, | |
| 729 | + | message, | |
| 730 | + | author: crate::merging::squash_author(&pull, method), | |
| 660 | 731 | }; | |
| 661 | 732 | items.push((row, pull, item)); | |
| 662 | 733 | } | |
| 663 | 734 | ||
| 735 | + | // Each built on the one before it: landing them in order moves the | |
| 736 | + | // default branch to each tested state in turn. | |
| 664 | 737 | let now = rfc3339(now_ms()); | |
| 665 | − | let mut jobs = Vec::new(); | |
| 666 | − | for index in 0..items.len() { | |
| 667 | − | let (row, pull, _) = &items[index]; | |
| 668 | − | let stack: Vec<QueueStackItem> = items[..=index] | |
| 669 | − | .iter() | |
| 670 | − | .map(|(_, _, item)| item.clone()) | |
| 671 | − | .collect(); | |
| 672 | − | let ahead: Vec<u32> = stack[..index].iter().map(|item| item.number).collect(); | |
| 738 | + | let mut onto = base.clone(); | |
| 739 | + | let mut built: Vec<(QueueStackItem, Vec<String>)> = Vec::new(); | |
| 740 | + | for (row, pull, item) in items { | |
| 741 | + | let mut stack: Vec<QueueStackItem> = built.iter().map(|(item, _)| item.clone()).collect(); | |
| 742 | + | stack.push(item.clone()); | |
| 743 | + | let ahead: Vec<u32> = built.iter().map(|(item, _)| item.number).collect(); | |
| 673 | 744 | let token = new_token(); | |
| 674 | 745 | let claimed = self | |
| 675 | 746 | .db | |
| 676 | 747 | .prepare(CLAIM_BUILD) | |
| 677 | 748 | .bind(&[ | |
| 678 | 749 | hash(&token).into(), | |
| 679 | − | stack[index].commit.as_str().into(), | |
| 750 | + | item.commit.as_str().into(), | |
| 680 | 751 | base.as_str().into(), | |
| 681 | 752 | serde_json::to_string(&ahead)?.into(), | |
| 682 | 753 | now.as_str().into(), | |
| ⋯ | |||
| 686 | 757 | ])? | |
| 687 | 758 | .first::<crate::rows::ValueRow>(None) | |
| 688 | 759 | .await?; | |
| 760 | + | // Another request is building it: what follows waits for it. | |
| 689 | 761 | if claimed.is_none() { | |
| 690 | − | continue; | |
| 762 | + | break; | |
| 691 | 763 | } | |
| 692 | − | // The sandbox reads the change and pushes the tested state as | |
| 693 | − | // the pull request's owner (whoever asked g1t for it, or its | |
| 694 | − | // author): a real account, whose token carries its memberships. | |
| 695 | − | // (Whoever queued it may be g1t itself.) | |
| 696 | − | let actor = pull.owner().clone(); | |
| 764 | + | let Some(actor) = row.actor() else { break }; | |
| 697 | 765 | let branch = attempt_branch(&row.id, Some(&hash(&token))); | |
| 698 | − | jobs.push(QueueJob { | |
| 766 | + | let step = self.build_step(&repo, &onto, &item, &branch, &actor, settings.require_web_commit_sign_off).await?; | |
| 767 | + | let mut report = ReportQueueArgs { | |
| 699 | 768 | entry_id: row.id.clone(), | |
| 700 | 769 | token, | |
| 701 | − | repo: RepoPath { | |
| 702 | − | namespace: repo.namespace.clone(), | |
| 703 | − | name: repo.name.clone(), | |
| 704 | − | }, | |
| 705 | − | default_branch: repo.default_branch.clone(), | |
| 706 | − | base_commit: base.clone(), | |
| 707 | − | branch, | |
| 708 | − | // The state is checked by its merge_group workflows. | |
| 709 | − | contract_checks: Vec::new(), | |
| 710 | − | stack, | |
| 711 | − | checks: Vec::new(), | |
| 712 | − | actor, | |
| 713 | − | }); | |
| 770 | + | combined_commit: None, | |
| 771 | + | results: Vec::new(), | |
| 772 | + | error: None, | |
| 773 | + | conflict_with: None, | |
| 774 | + | conflicts: Vec::new(), | |
| 775 | + | }; | |
| 776 | + | let next = match step { | |
| 777 | + | Built::State(commit) => { | |
| 778 | + | report.combined_commit = Some(commit.clone()); | |
| 779 | + | Some(commit) | |
| 780 | + | } | |
| 781 | + | Built::Conflict { message, paths } => { | |
| 782 | + | // The entry ahead whose change touched the same files. | |
| 783 | + | report.conflict_with = clashed_with(&built, &paths); | |
| 784 | + | report.error = Some(message); | |
| 785 | + | report.conflicts = paths; | |
| 786 | + | None | |
| 787 | + | } | |
| 788 | + | Built::Failed(message) => { | |
| 789 | + | report.error = Some(message); | |
| 790 | + | None | |
| 791 | + | } | |
| 792 | + | }; | |
| 793 | + | self.report_queue(report).await?; | |
| 794 | + | // One that could not be built leaves; those behind it are built | |
| 795 | + | // in the next batch, without it. | |
| 796 | + | let Some(next) = next else { break }; | |
| 797 | + | onto = next; | |
| 798 | + | built.push((item, pull.files.iter().map(|file| file.path.clone()).collect())); | |
| 714 | 799 | } | |
| 715 | − | Ok(jobs) | |
| 800 | + | Ok(()) | |
| 716 | 801 | } | |
| 717 | 802 | ||
| 718 | − | /// A sandbox's result for one combined state. | |
| 803 | + | /// `item` landed by its method on `onto`, a state of the queue, in the | |
| 804 | + | /// repository itself (repos `build_queue_step`), and `branch` pointed at | |
| 805 | + | /// the state that makes, as `actor` (whoever queued it). | |
| 806 | + | pub(crate) async fn build_step( | |
| 807 | + | &self, | |
| 808 | + | repo: &Repo, | |
| 809 | + | onto: &str, | |
| 810 | + | item: &QueueStackItem, | |
| 811 | + | branch: &str, | |
| 812 | + | actor: &User, | |
| 813 | + | sign_off: bool, | |
| 814 | + | ) -> Result<Built> { | |
| 815 | + | let Some(source_id) = self.source_of(repo, item).await? else { | |
| 816 | + | return Ok(Built::Failed(format!("#{}'s repository is no longer there.", item.number))); | |
| 817 | + | }; | |
| 818 | + | // A state recorded before methods: merged in as it was then. | |
| 819 | + | let message = item.message.clone().or_else(|| { | |
| 820 | + | matches!(item.method, MergeMethod::Merge | MergeMethod::Squash).then(|| format!("Merge #{}: {}", item.number, item.title)) | |
| 821 | + | }); | |
| 822 | + | let step: Outcome<QueueStep> = g1t_kit::call( | |
| 823 | + | &self.repos, | |
| 824 | + | "build_queue_step", | |
| 825 | + | &BuildQueueStepArgs { | |
| 826 | + | target_id: repo.id.clone(), | |
| 827 | + | onto: onto.to_owned(), | |
| 828 | + | source_id, | |
| 829 | + | branch: item.branch.clone(), | |
| 830 | + | commit: item.commit.clone(), | |
| 831 | + | method: item.method, | |
| 832 | + | message, | |
| 833 | + | author: item.author.clone(), | |
| 834 | + | sign_off, | |
| 835 | + | actor: actor.clone(), | |
| 836 | + | push_branch: branch.to_owned(), | |
| 837 | + | }, | |
| 838 | + | ) | |
| 839 | + | .await?; | |
| 840 | + | Ok(match step { | |
| 841 | + | Outcome::Ok(QueueStep::Built { commit, .. }) => Built::State(commit), | |
| 842 | + | Outcome::Ok(QueueStep::Conflict { message, paths }) => Built::Conflict { message, paths }, | |
| 843 | + | Outcome::Ok(QueueStep::Refused { message }) => Built::Failed(message), | |
| 844 | + | Outcome::Fail(failure) => Built::Failed(failure.message), | |
| 845 | + | }) | |
| 846 | + | } | |
| 847 | + | ||
| 848 | + | /// The repository holding a stack item's change: the queue's own, or | |
| 849 | + | /// its pull request's fork. | |
| 850 | + | async fn source_of(&self, repo: &Repo, item: &QueueStackItem) -> Result<Option<String>> { | |
| 851 | + | if item.source.namespace == repo.namespace && item.source.name == repo.name { | |
| 852 | + | return Ok(Some(repo.id.clone())); | |
| 853 | + | } | |
| 854 | + | // A fork's, through its pull request, whoever can read the fork. | |
| 855 | + | Ok(self.pull(&repo.id, item.number).await?.map(|pull| pull.source_id().to_owned())) | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | /// The result of building one combined state. | |
| 719 | 859 | pub(crate) async fn report_queue(&self, a: ReportQueueArgs) -> Result<Outcome<QueueState>> { | |
| 720 | 860 | // A smaller combination built to narrow down a failure. | |
| 721 | 861 | if a.entry_id.starts_with("qpr_") { | |
| ⋯ | |||
| 1201 | 1341 | // the merge record exists, only queue bookkeeping remains. | |
| 1202 | 1342 | if row.landing_commit.is_some() | |
| 1203 | 1343 | && pull.status == PullStatus::Merged | |
| 1204 | − | && pull.head_commit == row.landing_commit | |
| 1344 | + | && pull.merge_commit == row.landing_commit | |
| 1205 | 1345 | { | |
| 1206 | 1346 | self.finish_landing(&row).await?; | |
| 1207 | 1347 | continue; | |
| ⋯ | |||
| 1254 | 1394 | row.combined_commit.as_deref().unwrap(), | |
| 1255 | 1395 | row.bypass_rules != 0, | |
| 1256 | 1396 | evidence.all(), | |
| 1397 | + | row.method(), | |
| 1257 | 1398 | ) | |
| 1258 | 1399 | .await?; | |
| 1259 | 1400 | if let Some(refusal) = gate.refusal() { | |
| ⋯ | |||
| 1289 | 1430 | target_branch: None, | |
| 1290 | 1431 | expected_commit: row.combined_commit.clone(), | |
| 1291 | 1432 | target_id: None, | |
| 1292 | − | // The state the queue tested lands as it is: the queue | |
| 1293 | − | // rule's method is not applied to it. | |
| 1294 | − | method: Some(g1t_contracts::rules::MergeMethod::FastForward), | |
| 1433 | + | // The state tested was built by the entry's method on what | |
| 1434 | + | // landed before it: it lands exactly as tested, the default | |
| 1435 | + | // branch moving to it. | |
| 1436 | + | method: Some(MergeMethod::FastForward), | |
| 1295 | 1437 | message: None, | |
| 1296 | 1438 | author: None, | |
| 1297 | 1439 | sign_off: false, | |
| ⋯ | |||
| 1309 | 1451 | landed = g1t_kit::call(&self.repos, "land", &land).await?; | |
| 1310 | 1452 | } | |
| 1311 | 1453 | let landed = match landed { | |
| 1312 | − | Outcome::Ok(landed) => landed, | |
| 1454 | + | // Recorded as the method it was built by. | |
| 1455 | + | Outcome::Ok(landed) => Landed { method: row.method(), ..landed }, | |
| 1313 | 1456 | Outcome::Fail(_) => { | |
| 1314 | 1457 | let abandoned = self | |
| 1315 | 1458 | .db | |
| 36 | 36 | let enqueue = |id: &str| { | |
| 37 | 37 | db.query_row( | |
| 38 | 38 | ENQUEUE, | |
| 39 | − | params![id, "repo", "pull", 1, "{}", 0, "later", 0], | |
| 39 | + | params![id, "repo", "pull", 1, "{}", 0, "later", 0, "merge", None::<String>, None::<String>], | |
| 40 | 40 | |_| Ok(()), | |
| 41 | 41 | ) | |
| 42 | 42 | .optional() | |
| ⋯ | |||
| 507 | 507 | fn a_bypass_asked_for_at_admission_is_kept_for_landing() { | |
| 508 | 508 | let db = database(); | |
| 509 | 509 | db.execute("DELETE FROM queue_entries", []).unwrap(); | |
| 510 | − | assert!(exists(&db, ENQUEUE, params!["first", "repo", "pull", 1, "{}", 0, "later", 1])); | |
| 510 | + | assert!(exists(&db, ENQUEUE, params!["first", "repo", "pull", 1, "{}", 0, "later", 1, "merge", None::<String>, None::<String>])); | |
| 511 | 511 | assert_eq!( | |
| 512 | 512 | db.query_row("SELECT bypass_rules FROM queue_entries", [], |row| row.get::<_, u32>(0)).unwrap(), | |
| 513 | 513 | 1 | |
| ⋯ | |||
| 597 | 597 | "its branch no longer exists, so it cannot be tested or landed. Push the branch again and merge to rejoin." | |
| 598 | 598 | ); | |
| 599 | 599 | } | |
| 600 | + | ||
| 601 | + | #[test] | |
| 602 | + | fn an_entry_keeps_how_it_lands_and_one_queued_before_methods_merges() { | |
| 603 | + | let db = database(); | |
| 604 | + | db.execute("DELETE FROM queue_entries", []).unwrap(); | |
| 605 | + | assert!(exists(&db, ENQUEUE, params!["first", "repo", "pull", 1, "{}", 0, "later", 0, "squash", "Ship it", "All of it"])); | |
| 606 | + | let (method, title, body): (String, String, String) = db | |
| 607 | + | .query_row("SELECT merge_method, commit_title, commit_message FROM queue_entries", [], |row| { | |
| 608 | + | Ok((row.get(0)?, row.get(1)?, row.get(2)?)) | |
| 609 | + | }) | |
| 610 | + | .unwrap(); | |
| 611 | + | assert_eq!((method.as_str(), title.as_str(), body.as_str()), ("squash", "Ship it", "All of it")); | |
| 612 | + | let row = |method: serde_json::Value| -> super::EntryRow { | |
| 613 | + | serde_json::from_value(serde_json::json!({ | |
| 614 | + | "id": "e", "repo_id": "r", "pull_id": "p", "number": 1, "state": "waiting", "enqueued_by": "{}", | |
| 615 | + | "keep_issue_open": 0, "head_commit": null, "base_commit": null, "ahead": null, "combined_commit": null, | |
| 616 | + | "results": null, "error": null, "tested_at": null, "created_at": "now", "finished_at": null, | |
| 617 | + | "merge_method": method, | |
| 618 | + | })) | |
| 619 | + | .unwrap() | |
| 620 | + | }; | |
| 621 | + | use g1t_contracts::rules::MergeMethod; | |
| 622 | + | assert_eq!(row("rebase".into()).method(), MergeMethod::Rebase); | |
| 623 | + | assert_eq!(row("fast_forward".into()).method(), MergeMethod::FastForward); | |
| 624 | + | // Its states were built with merge commits then. | |
| 625 | + | assert_eq!(row(serde_json::Value::Null).method(), MergeMethod::Merge); | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | #[test] | |
| 629 | + | fn a_clash_names_the_nearest_entry_ahead_that_changed_the_same_file() { | |
| 630 | + | let item = |number: u32| g1t_contracts::work::QueueStackItem { | |
| 631 | + | number, | |
| 632 | + | title: String::new(), | |
| 633 | + | source: g1t_contracts::repos::RepoPath { namespace: "n".into(), name: "r".into() }, | |
| 634 | + | branch: "b".into(), | |
| 635 | + | commit: "c".into(), | |
| 636 | + | method: g1t_contracts::rules::MergeMethod::Squash, | |
| 637 | + | message: None, | |
| 638 | + | author: None, | |
| 639 | + | }; | |
| 640 | + | let ahead = vec![ | |
| 641 | + | (item(1), vec!["a.rs".to_owned(), "b.rs".to_owned()]), | |
| 642 | + | (item(2), vec!["b.rs".to_owned()]), | |
| 643 | + | (item(3), vec!["c.rs".to_owned()]), | |
| 644 | + | ]; | |
| 645 | + | assert_eq!(super::clashed_with(&ahead, &["b.rs".to_owned()]), Some(2)); | |
| 646 | + | assert_eq!(super::clashed_with(&ahead, &["a.rs".to_owned()]), Some(1)); | |
| 647 | + | // With the default branch itself. | |
| 648 | + | assert_eq!(super::clashed_with(&ahead, &["d.rs".to_owned()]), None); | |
| 649 | + | } | |
| 824 | 824 | commit: &str, | |
| 825 | 825 | bypass_rules: bool, | |
| 826 | 826 | statuses: Vec<g1t_contracts::work::CommitStatus>, | |
| 827 | + | method: MergeMethod, | |
| 827 | 828 | ) -> Result<Gate> { | |
| 828 | − | // The tested state lands as it was built, which the queue rule's | |
| 829 | − | // method does not change (queue.rs): no method is judged. | |
| 830 | − | let gate = self.merge_gate_at(repo, pull, Some(actor), false, true, Some((commit, statuses)), None).await?; | |
| 829 | + | // The tested state was built by the entry's method (queue.rs), which | |
| 830 | + | // the rules judge as they are now. | |
| 831 | + | let gate = self.merge_gate_at(repo, pull, Some(actor), false, true, Some((commit, statuses)), Some(method)).await?; | |
| 831 | 832 | Ok(if landing_honors_bypass(actor, bypass_rules) { gate } else { gate.without_bypass() }) | |
| 832 | 833 | } | |
| 833 | 834 |
Comments
Sign in to comment.