├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cfg.production.toml ├── cfg.sample.toml ├── homu ├── assets │ ├── jquery.dataTables.min.js │ └── jquery.min.js ├── auth.py ├── comments.py ├── git_helper.py ├── html │ ├── 404.html │ ├── build_res.html │ ├── index.html │ ├── queue.html │ └── retry_log.html ├── main.py ├── parse_issue_comment.py ├── server.py ├── tests │ ├── __init__.py │ ├── test_parse_issue_comment.py │ └── test_pr_body.py └── utils.py ├── requirements.txt ├── setup.cfg └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | *.min.js binary 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: CI/CD 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: {} 9 | 10 | env: 11 | PYTHON_VERSION: 3.8 12 | 13 | jobs: 14 | cicd: 15 | name: Test and deploy 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Clone the source code 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup Python ${{ env.PYTHON_VERSION }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ env.PYTHON_VERSION }} 25 | 26 | - name: Install flake8 27 | run: pip install flake8 28 | 29 | - name: Ensure the code passes lints 30 | run: flake8 homu/ 31 | 32 | - name: Preinstall pinned Python dependencies 33 | run: pip install -r requirements.txt 34 | 35 | - name: Install homu on the builder 36 | run: pip install -e . 37 | 38 | - name: Run the test suite 39 | run: python3 setup.py test 40 | 41 | - name: Build the Docker image 42 | run: docker build -t homu . 43 | 44 | - name: Upload the Docker image to AWS ECR 45 | uses: rust-lang/simpleinfra/github-actions/upload-docker-image@master 46 | with: 47 | image: homu 48 | repository: bors 49 | region: us-west-1 50 | redeploy_ecs_cluster: rust-ecs-prod 51 | redeploy_ecs_service: bors 52 | aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} 53 | aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 54 | if: github.event_name == 'push' && github.repository == 'rust-lang/homu' && github.ref == 'refs/heads/master' 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /homu/__pycache__/ 2 | /.venv/ 3 | /cfg.toml 4 | /cfg.json 5 | /homu.egg-info/ 6 | /.eggs/ 7 | /main.db 8 | /cache 9 | *.pyc 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | # We need an older Ubuntu as github3 depends on < Python 3.10 to avoid errors 3 | 4 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 5 | python3-pip \ 6 | git \ 7 | ssh 8 | 9 | COPY setup.py cfg.production.toml /src/ 10 | COPY homu/ /src/homu/ 11 | COPY requirements.txt /src/ 12 | 13 | # Pre-install dependencies from a lockfile 14 | RUN pip3 install -r /src/requirements.txt 15 | 16 | # Homu needs to be installed in "editable mode" (-e): when pip installs an 17 | # application it resets the permissions of all source files to 644, but 18 | # homu/git_helper.py needs to be executable (755). Installing in editable mode 19 | # works around the issue since pip just symlinks the package to the source 20 | # directory. 21 | RUN pip3 install -e /src/ 22 | 23 | # Ensure the host SSH key for github.com is trusted by the container. If this 24 | # is not run, homu will fail to authenticate SSH connections with GitHub. 25 | RUN mkdir /root/.ssh && \ 26 | ssh-keyscan github.com >> /root/.ssh/known_hosts 27 | 28 | # Allow logs to show up timely on CloudWatch. 29 | ENV PYTHONUNBUFFERED=1 30 | 31 | CMD ["homu", "--verbose", "--config", "/src/cfg.production.toml"] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Barosl Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Homu 2 | 3 | [![Hommando]][Akemi Homura] 4 | 5 | Homu is a bot that integrates with GitHub and your favorite continuous 6 | integration service such as [Travis CI], [Appveyor] or [Buildbot]. 7 | 8 | [Hommando]: https://i.imgur.com/j0jNvHF.png 9 | [Akemi Homura]: https://wiki.puella-magi.net/Homura_Akemi 10 | [Buildbot]: http://buildbot.net/ 11 | [Travis CI]: https://travis-ci.org/ 12 | [Appveyor]: https://www.appveyor.com/ 13 | 14 | ## Why is it needed? 15 | 16 | Let's take Travis CI as an example. If you send a pull request to a repository, 17 | Travis CI instantly shows you the test result, which is great. However, after 18 | several other pull requests are merged into the `master` branch, your pull 19 | request can *still* break things after being merged into `master`. The 20 | traditional continuous integration solutions don't protect you from this. 21 | 22 | In fact, that's why they provide the build status badges. If anything pushed to 23 | `master` is completely free from any breakage, those badges will **not** be 24 | necessary, as they will always be green. The badges themselves prove that there 25 | can still be some breakages, even when continuous integration services are used. 26 | 27 | To solve this problem, the test procedure should be executed *just before the 28 | merge*, not just after the pull request is received. You can manually click the 29 | "restart build" button each time before you merge a pull request, but Homu can 30 | automate this process. It listens to the pull request comments, waiting for an 31 | approval comment from one of the configured reviewers. When the pull request is 32 | approved, Homu tests it using your favorite continuous integration service, and 33 | only when it passes all the tests, it is merged into `master`. 34 | 35 | Note that Homu is **not** a replacement of Travis CI, Buildbot or Appveyor. It 36 | works on top of them. Homu itself doesn't have the ability to test pull 37 | requests. 38 | 39 | ## Influences of bors 40 | 41 | Homu is largely inspired by [bors]. The concept of "tests should be done just 42 | before the merge" came from bors. However, there are also some differences: 43 | 44 | 1. Stateful: Unlike bors, which intends to be stateless, Homu is stateful. It 45 | means that Homu does not need to retrieve all the information again and again 46 | from GitHub at every run. This is essential because of GitHub's rate 47 | limiting. Once it downloads the initial state, the following changes are 48 | delivered with the [Webhooks] API. 49 | 2. Pushing over polling: Homu prefers pushing wherever possible. The pull 50 | requests from GitHub are retrieved using Webhooks, as stated above. The test 51 | results from Buildbot are pushed back to Homu with the [HttpStatusPush] 52 | feature. This approach improves the overall performance and the response 53 | time, because the bot is informed about the status changes immediately. 54 | 55 | And also, Homu has more features, such as `rollup`, `try`, and the Travis CI & 56 | Appveyor support. 57 | 58 | [bors]: https://github.com/graydon/bors 59 | [Webhooks]: https://developer.github.com/webhooks/ 60 | [HttpStatusPush]: http://docs.buildbot.net/current/manual/cfg-statustargets.html#httpstatuspush 61 | 62 | ## Usage 63 | 64 | ### How to install 65 | 66 | ```sh 67 | $ sudo apt-get install python3-venv python3-wheel 68 | $ python3 -m venv .venv 69 | $ . .venv/bin/activate 70 | $ pip install -U pip 71 | $ git clone https://github.com/rust-lang/homu.git 72 | $ pip install -e homu 73 | ``` 74 | 75 | ### How to configure 76 | 77 | In the following instructions, `HOST` refers to the hostname (or IP address) 78 | where you are running your custom homu instance. `PORT` is the port the service 79 | is listening to and is configured in `web.port` in `cfg.toml`. `NAME` refers to 80 | the name of the repository you are configuring homu for. 81 | 82 | 1. Copy `cfg.sample.toml` to `cfg.toml`. You'll need to edit this file to set up 83 | your configuration. The following steps explain where you can find important 84 | config values. 85 | 86 | 2. Create a GitHub account that will be used by Homu. You can also use an 87 | existing account. In the [developer settings][settings], go to "OAuth 88 | Apps" and create a new application: 89 | - Make note of the "Client ID" and "Client Secret"; you will need to put them in 90 | your `cfg.toml`. 91 | - The OAuth Callback URL should be `http://HOST:PORT/callback`. 92 | - The homepage URL isn't necessary; you could set `http://HOST:PORT/`. 93 | 94 | 3. Go back to the developer settings of the GitHub account you created/used in the 95 | previous step. Go to "Personal access tokens". Click "Generate new token" and 96 | choose the "repo" and "user" scopes. Put the token value in your `cfg.toml`. 97 | 98 | 4. Add your new GitHub account as a Collaborator to the GitHub repo you are 99 | setting up homu for. This can be done in repo (NOT user) "Settings", then 100 | "Collaborators". Enable "Write" access. 101 | 102 | 4.1. Make sure you login as the new GitHub account and that you **accept 103 | the collaborator invitation** you just sent! 104 | 105 | 5. Add a Webhook to your repository. This is done under repo (NOT user) 106 | "Settings", then "Webhooks". Click "Add webhook", then set: 107 | - Payload URL: `http://HOST:PORT/github` 108 | - Content type: `application/json` 109 | - Secret: The same as `repo.NAME.github.secret` in `cfg.toml` 110 | - Events: click "Let me select individual events", then pick 111 | `Issue comments`, `Pull requests`, `Pushes`, `Statuses`, `Check runs` 112 | 113 | 6. Add a Webhook to your continuous integration service, if necessary. You don't 114 | need this if using Travis/Appveyor. 115 | - Buildbot 116 | 117 | Insert the following code to the `master.cfg` file: 118 | 119 | ```python 120 | from buildbot.status.status_push import HttpStatusPush 121 | 122 | c['status'].append(HttpStatusPush( 123 | serverUrl='http://HOST:PORT/buildbot', 124 | extra_post_params={'secret': 'repo.NAME.buildbot.secret in cfg.toml'}, 125 | )) 126 | ``` 127 | 128 | 7. Go through the rest of your `cfg.toml` and uncomment (and change, if needed) 129 | parts of the config you'll need. 130 | 131 | [settings]: https://github.com/settings/apps 132 | [travis]: https://travis-ci.org/profile/info 133 | 134 | ### How to run 135 | 136 | ```sh 137 | $ . .venv/bin/activate 138 | $ homu 139 | ``` 140 | -------------------------------------------------------------------------------- /cfg.production.toml: -------------------------------------------------------------------------------- 1 | max_priority = 9001 2 | 3 | [db] 4 | file = '/efs/main.db' 5 | 6 | [github] 7 | access_token = "${GITHUB_TOKEN}" 8 | app_client_id = "${GITHUB_CLIENT_ID}" 9 | app_client_secret = "${GITHUB_CLIENT_SECRET}" 10 | 11 | [git] 12 | local_git = true 13 | ssh_key = """ 14 | ${HOMU_SSH_KEY} 15 | """ 16 | 17 | [web] 18 | host = '0.0.0.0' 19 | port = 80 20 | 21 | base_url = "https://bors.rust-lang.org" 22 | canonical_url = "https://bors.rust-lang.org" 23 | remove_path_prefixes = ["homu"] 24 | 25 | #announcement = "Hello world!" 26 | 27 | ########## 28 | # Rust # 29 | ########## 30 | 31 | [repo.rust] 32 | owner = "rust-lang" 33 | name = "rust" 34 | timeout = 21600 # 6 hours 35 | 36 | # Permissions managed through rust-lang/team 37 | rust_team = true 38 | reviewers = [] 39 | try_users = [] 40 | 41 | [repo.rust.github] 42 | secret = "${HOMU_WEBHOOK_SECRET_RUST}" 43 | [repo.rust.checks.actions] 44 | name = "bors build finished" 45 | 46 | # Automatic relabeling 47 | [repo.rust.labels.approved] # after homu received `r+` 48 | remove = ['S-blocked', 'S-waiting-on-author', 'S-waiting-on-bors', 'S-waiting-on-crater', 'S-waiting-on-review', 'S-waiting-on-team'] 49 | add = ['S-waiting-on-bors'] 50 | 51 | [repo.rust.labels.rejected] # after homu received `r-` 52 | remove = ['S-blocked', 'S-waiting-on-author', 'S-waiting-on-bors', 'S-waiting-on-crater', 'S-waiting-on-review', 'S-waiting-on-team'] 53 | add = ['S-waiting-on-author'] 54 | 55 | [repo.rust.labels.failed] # test failed (maybe spurious, so fall back to -on-review) 56 | remove = ['S-blocked', 'S-waiting-on-author', 'S-waiting-on-bors', 'S-waiting-on-crater', 'S-waiting-on-review', 'S-waiting-on-team'] 57 | add = ['S-waiting-on-review'] 58 | 59 | [repo.rust.labels.timed_out] # test timed out after 4 hours (almost always spurious, let reviewer retry) 60 | remove = ['S-blocked', 'S-waiting-on-author', 'S-waiting-on-bors', 'S-waiting-on-crater', 'S-waiting-on-review', 'S-waiting-on-team'] 61 | add = ['S-waiting-on-review'] 62 | 63 | [repo.rust.labels.try_failed] # try-build failed (almost always legit, tell author to fix the PR) 64 | remove = ['S-waiting-on-review', 'S-waiting-on-crater'] 65 | add = ['S-waiting-on-author'] 66 | 67 | [repo.rust.labels.pushed] # user pushed a commit after `r+`/`try` 68 | remove = ['S-waiting-on-bors', 'S-waiting-on-author'] 69 | add = ['S-waiting-on-review'] 70 | unless = ['S-blocked', 'S-waiting-on-crater', 'S-waiting-on-team'] 71 | 72 | [repo.rust.labels.conflict] # a merge conflict is detected (tell author to rebase) 73 | remove = ['S-waiting-on-bors'] 74 | add = ['S-waiting-on-author'] 75 | unless = ['S-blocked', 'S-waiting-on-crater', 'S-waiting-on-team', 'S-waiting-on-review'] 76 | 77 | [repo.rust.labels.succeed] 78 | add = ['merged-by-bors'] 79 | 80 | [repo.rust.labels.rollup_made] 81 | add = ['rollup'] 82 | -------------------------------------------------------------------------------- /cfg.sample.toml: -------------------------------------------------------------------------------- 1 | # The configuration file supports variable interpolation. In any string field, 2 | # ${VARIABLE_NAME} will be replaced with the value of the VARIABLE_NAME 3 | # environment variable. 4 | 5 | # Priority values above max_priority will be refused. 6 | max_priority = 9001 7 | 8 | # How long to keep the retry log 9 | # Should be a negative interval of time recognized by SQLite3. 10 | retry_log_expire = '-42 days' 11 | 12 | [github] 13 | 14 | # Information for securely interacting with GitHub. These are found/generated 15 | # under . 16 | 17 | # A GitHub personal access token. 18 | access_token = "" 19 | 20 | # A GitHub oauth application for this instance of homu: 21 | app_client_id = "" 22 | app_client_secret = "" 23 | 24 | 25 | [git] 26 | # Use the local Git command. Required to use some advanced features. It also 27 | # speeds up Travis by reducing temporary commits. 28 | #local_git = false 29 | 30 | # Directory storing the local clones of the git repositories. If this is on an 31 | # ephemeral file system, there will be a delay to start new builds after a 32 | # restart while homu clones the repository. 33 | # cache_dir = "cache" 34 | 35 | # SSH private key. Needed only when the local Git command is used. 36 | #ssh_key = """ 37 | #""" 38 | 39 | # By default, Homu extracts the name+email from the Github account it will be 40 | # using. However, you may want to use a private email for the account, and 41 | # associate the commits with a public email address. 42 | #user = "Some Cool Project Bot" 43 | #email = "coolprojectbot-devel@example.com" 44 | 45 | [web] 46 | 47 | # The port homu listens on. 48 | port = 54856 49 | 50 | # Synchronize all open PRs on startup. "Synchronize" means fetch the state of 51 | # all open PRs. 52 | sync_on_start = true 53 | 54 | # The base url used for links pointing to this homu instance. 55 | # If base_url is not present, links will use canonical_url as a fallback. 56 | # If neither base_url nor canonical_url are present, no links to this homu 57 | # instance will be generated. 58 | #base_url = "https://bors.example.com" 59 | 60 | # The canonical URL of this homu instance. If a user reaches the instance 61 | # through a different path they will be redirected. If this is not present in 62 | # the configuration homu will still work, but no redirect will be performed. 63 | #canonical_url = "https://bors.example.com" 64 | 65 | # List of path prefixes to remove from the URL. This is useful if the homu 66 | # instance was moved from a subdirectory of a domain to the top level. 67 | #remove_path_prefixes = ["homu"] 68 | 69 | # Message to display on top of the web interface. It's possible to use 70 | # arbitrary HTML tags in the message. 71 | #announcement = "Homu will be offline tomorrow for maintenance." 72 | 73 | # Custom hooks can be added as well. 74 | # Homu will ping the given endpoint with POSTdata of the form: 75 | # {'body': 'comment body', 'extra_data': 'extra data', 'pull': pull req number} 76 | # The extra data is the text specified in `@homu hookname=text` 77 | # 78 | # [hooks.hookname] 79 | # trigger = "hookname" # will be triggered by @homu hookname or @homu hookname=text 80 | # endpoint = "http://path/to/endpoint" 81 | # access = "try" # access level required 82 | # has_response = true # Should the response be posted back to github? Only allowed if realtime=true 83 | # realtime = true # Should it only run in realtime mode? If false, this will be replayed each time homu is started (probably not what you want) 84 | 85 | # An example configuration for repository (there can be many of these). NAME 86 | # refers to your repo name. 87 | [repo.NAME] 88 | 89 | # Which repo are we talking about? You can get these fields from your repo URL: 90 | # github.com// 91 | owner = "" 92 | name = "" 93 | 94 | # If this repo should be integrated with the permissions defined in 95 | # https://github.com/rust-lang/team uncomment the following line. 96 | # Note that the other ACLs will *also* apply. 97 | #rust_team = true 98 | 99 | # Who can approve PRs (r+ rights)? You can put GitHub usernames here. 100 | reviewers = [] 101 | # Alternatively, set this allow any github collaborator; 102 | # note that you can *also* specify reviewers above. 103 | # auth_collaborators = true 104 | 105 | # Who has 'try' rights? (try, retry, force, clean, prioritization). It's fine to 106 | # keep this empty. 107 | try_users = [] 108 | 109 | # Keep the commit history linear. Requires the local Git command. 110 | #linear = false 111 | 112 | # Auto-squash commits. Requires the local Git command. 113 | #autosquash = true 114 | 115 | # If the PR already has the same success statuses that we expect on the `auto` 116 | # branch, then push directly to branch if safe to do so. Requires the local Git 117 | # command. 118 | #status_based_exemption = false 119 | 120 | # Maximum test duration allowed for testing a PR in this repository. 121 | # Default to 10 hours. 122 | #timeout = 36000 123 | 124 | # Branch names. These settings are the defaults; it makes sense to leave these 125 | # as-is. 126 | #[repo.NAME.branch] 127 | # 128 | #auto = "auto" 129 | #try = "try" 130 | 131 | # test-on-fork allows you to run the CI builds for a project in a separate fork 132 | # instead of the main repository, while still approving PRs and merging the 133 | # commits in the main one. 134 | # 135 | # To enable test-on-fork you need to uncomment the section below and fill the 136 | # fork's owner and repository name. The fork MUST BE AN ACTUAL GITHUB FORK for 137 | # this feature to work. That means it will likely need to be in a separate 138 | # GitHub organization. 139 | # 140 | # This only works when `local_git = true`. 141 | # 142 | #[repo.NAME.test-on-fork] 143 | #owner = "" 144 | #name = "" 145 | 146 | [repo.NAME.github] 147 | # Arbitrary secret. You can generate one with: openssl rand -hex 20 148 | secret = "" 149 | 150 | # Remove and add GitHub labels when some event happened. 151 | # See servo/homu#141 for detail. 152 | # 153 | #[repo.NAME.labels.approved] # after homu received `r+` 154 | #[repo.NAME.labels.rejected] # after homu received `r-` 155 | #[repo.NAME.labels.conflict] # a merge conflict is detected 156 | #[repo.NAME.labels.succeed] # test successful 157 | #[repo.NAME.labels.failed] # test failed 158 | #[repo.NAME.labels.exempted] # test exempted 159 | #[repo.NAME.labels.timed_out] # test timed out (after 10 hours) 160 | #[repo.NAME.labels.interrupted] # test interrupted (buildbot only) 161 | #[repo.NAME.labels.try] # after homu received `try` 162 | #[repo.NAME.labels.try_succeed] # try-build successful 163 | #[repo.NAME.labels.try_failed] # try-build failed 164 | #[repo.NAME.labels.pushed] # user pushed a commit after `r+`/`try` 165 | #remove = ['list', 'of', 'labels', 'to', 'remove'] 166 | #add = ['list', 'of', 'labels', 'to', 'add'] 167 | #unless = [ 168 | # 'avoid', 'relabeling', 'if', 169 | # 'any', 'of', 'these', 'labels', 'are', 'present', 170 | #] 171 | 172 | # Travis integration. Don't forget to allow Travis to test the `auto` branch! 173 | [repo.NAME.checks.travis] 174 | # Name of the Checks API run. Don't touch this unless you really know what 175 | # you're doing. 176 | name = "Travis CI - Branch" 177 | 178 | # Appveyor integration. Don't forget to allow Appveyor to test the `auto` branch! 179 | #[repo.NAME.status.appveyor] 180 | # 181 | # String label set by status updates. Don't touch this unless you really know 182 | # what you're doing. 183 | #context = 'continuous-integration/appveyor/branch' 184 | 185 | # Generic GitHub Status API support. You don't need this if you're using the 186 | # above examples for Travis/Appveyor. 187 | #[repo.NAME.status.LABEL] 188 | # 189 | # String label set by status updates. 190 | #context = "" 191 | # 192 | # Equivalent context to look for on the PR itself if checking whether the 193 | # build should be exempted. If omitted, looks for the same context. This is 194 | # only used if status_based_exemption is true. 195 | #pr_context = "" 196 | 197 | # Generic GitHub Checks API support. You don't need this if you're using the 198 | # above examples for Travis/Appveyor. 199 | #[repo.NAME.checks.LABEL] 200 | # 201 | # String name of the Checks run. 202 | #name = "" 203 | # 204 | # String name of the Checks run used for try runs. 205 | # If the field is omitted the same name as the auto build will be used. 206 | #try_name = "" 207 | 208 | # Use buildbot for running tests 209 | #[repo.NAME.buildbot] 210 | # 211 | #url = "" 212 | #secret = "" 213 | # 214 | #builders = ["auto-linux", "auto-mac"] 215 | #try_builders = ["try-linux", "try-mac"] 216 | # 217 | #username = "" 218 | #password = "" 219 | 220 | # 221 | ## Boolean which indicates whether the builder is included in try builds (defaults to true) 222 | #try = false 223 | 224 | # The database homu uses 225 | [db] 226 | # SQLite file 227 | file = "main.db" 228 | -------------------------------------------------------------------------------- /homu/auth.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | RUST_TEAM_BASE = "https://team-api.infra.rust-lang.org/v1/" 4 | RETRIES = 5 5 | 6 | 7 | def fetch_rust_team(repo_label, level): 8 | repo = repo_label.replace('-', '_') 9 | url = RUST_TEAM_BASE + "permissions/bors." + repo + "." + level + ".json" 10 | for retry in range(RETRIES): 11 | try: 12 | resp = requests.get(url) 13 | resp.raise_for_status() 14 | return resp.json()["github_ids"] 15 | except requests.exceptions.RequestException as e: 16 | msg = "error while fetching " + url 17 | msg += " (try " + str(retry) + "): " + str(e) 18 | print(msg) 19 | continue 20 | return [] 21 | 22 | 23 | def verify_level(username, user_id, repo_label, repo_cfg, state, toml_keys, 24 | rust_team_level): 25 | authorized = False 26 | if repo_cfg.get('auth_collaborators', False): 27 | authorized = state.get_repo().is_collaborator(username) 28 | if repo_cfg.get('rust_team', False): 29 | authorized = user_id in fetch_rust_team(repo_label, rust_team_level) 30 | if not authorized: 31 | authorized = username.lower() == state.delegate.lower() 32 | for toml_key in toml_keys: 33 | if not authorized: 34 | authorized = username in repo_cfg.get(toml_key, []) 35 | return authorized 36 | 37 | 38 | def verify(username, user_id, repo_label, repo_cfg, state, auth, realtime, 39 | my_username): 40 | # The import is inside the function to prevent circular imports: main.py 41 | # requires auth.py and auth.py requires main.py 42 | from .main import AuthState 43 | 44 | # In some cases (e.g. non-fully-qualified r+) we recursively talk to 45 | # ourself via a hidden markdown comment in the message. This is so that 46 | # when re-synchronizing after shutdown we can parse these comments and 47 | # still know the SHA for the approval. 48 | # 49 | # So comments from self should always be allowed 50 | if username == my_username: 51 | return True 52 | 53 | authorized = False 54 | if auth == AuthState.REVIEWER: 55 | authorized = verify_level( 56 | username, user_id, repo_label, repo_cfg, state, ['reviewers'], 57 | 'review', 58 | ) 59 | elif auth == AuthState.TRY: 60 | authorized = verify_level( 61 | username, user_id, repo_label, repo_cfg, state, 62 | ['reviewers', 'try_users'], 'try', 63 | ) 64 | 65 | if authorized: 66 | return True 67 | else: 68 | if realtime: 69 | reply = '@{}: :key: Insufficient privileges: '.format(username) 70 | if auth == AuthState.REVIEWER: 71 | if repo_cfg.get('auth_collaborators', False): 72 | reply += 'Collaborator required' 73 | else: 74 | reply += 'Not in reviewers' 75 | elif auth == AuthState.TRY: 76 | reply += 'not in try users' 77 | state.add_comment(reply) 78 | return False 79 | -------------------------------------------------------------------------------- /homu/comments.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class Comment: 5 | def __init__(self, **args): 6 | if len(args) != len(self.params): 7 | raise KeyError("different number of params") 8 | for key, value in args.items(): 9 | if key in self.params: 10 | setattr(self, key, value) 11 | else: 12 | raise KeyError("unknown attribute: %s" % key) 13 | 14 | def jsonify(self): 15 | out = {"type": self.__class__.__name__} 16 | for param in self.params: 17 | out[param] = getattr(self, param) 18 | return json.dumps(out, separators=(',', ':')) 19 | 20 | 21 | class Approved(Comment): 22 | def __init__(self, bot=None, **args): 23 | # Because homu needs to leave a comment for itself to kick off a build, 24 | # we need to know the correct botname to use. However, we don't want to 25 | # save that botname in our state JSON. So we need a custom constructor 26 | # to grab the botname and delegate the rest of the keyword args to the 27 | # Comment constructor. 28 | super().__init__(**args) 29 | self.bot = bot 30 | 31 | params = ["sha", "approver", "queue"] 32 | 33 | def render(self): 34 | # The comment here is required because Homu wants a full, unambiguous, 35 | # pinned commit hash to kick off the build, and this note-to-self is 36 | # how it gets it. This is to safeguard against situations where Homu 37 | # reloads and another commit has been pushed since the approval. 38 | message = ":pushpin: Commit {sha} has been " + \ 39 | "approved by `{approver}`\n\n" + \ 40 | "It is now in the [queue]({queue}) for this repository.\n\n" + \ 41 | "" 42 | return message.format( 43 | sha=self.sha, 44 | approver=self.approver, 45 | bot=self.bot, 46 | queue=self.queue 47 | ) 48 | 49 | 50 | class ApprovalIgnoredWip(Comment): 51 | def __init__(self, wip_keyword=None, **args): 52 | # We want to use the wip keyword in the message, but not in the json 53 | # blob. 54 | super().__init__(**args) 55 | self.wip_keyword = wip_keyword 56 | 57 | params = ["sha"] 58 | 59 | def render(self): 60 | message = ':clipboard:' + \ 61 | ' Looks like this PR is still in progress,' + \ 62 | ' ignoring approval.\n\n' + \ 63 | 'Hint: Remove **{wip_keyword}** from this PR\'s title when' + \ 64 | ' it is ready for review.' 65 | return message.format(wip_keyword=self.wip_keyword) 66 | 67 | 68 | class Delegated(Comment): 69 | def __init__(self, bot=None, **args): 70 | # Because homu needs to leave a comment for the delegated person, 71 | # we need to know the correct botname to use. However, we don't want to 72 | # save that botname in our state JSON. So we need a custom constructor 73 | # to grab the botname and delegate the rest of the keyword args to the 74 | # Comment constructor. 75 | super().__init__(**args) 76 | self.bot = bot 77 | 78 | params = ["delegator", "delegate"] 79 | 80 | def render(self): 81 | message = \ 82 | ':v: @{delegate}, you can now approve this pull request!\n\n' + \ 83 | 'If @{delegator} told you to "`r=me`" after making some ' + \ 84 | 'further change, please make that change, then do ' + \ 85 | '`@{bot} r=@{delegator}`' 86 | return message.format( 87 | delegate=self.delegate, 88 | bot=self.bot, 89 | delegator=self.delegator 90 | ) 91 | 92 | 93 | class BuildStarted(Comment): 94 | params = ["head_sha", "merge_sha"] 95 | 96 | def render(self): 97 | return ":hourglass: Testing commit %s with merge %s..." % ( 98 | self.head_sha, self.merge_sha, 99 | ) 100 | 101 | 102 | class TryBuildStarted(Comment): 103 | params = ["head_sha", "merge_sha"] 104 | 105 | def render(self): 106 | return ":hourglass: Trying commit %s with merge %s..." % ( 107 | self.head_sha, self.merge_sha, 108 | ) 109 | 110 | 111 | class BuildCompleted(Comment): 112 | params = ["approved_by", "base_ref", "builders", "merge_sha"] 113 | 114 | def render(self): 115 | urls = ", ".join( 116 | "[%s](%s)" % kv for kv in sorted(self.builders.items()) 117 | ) 118 | return ( 119 | ":sunny: Test successful - %s\n" 120 | "Approved by: %s\n" 121 | "Pushing %s to %s..." 122 | % ( 123 | urls, self.approved_by, self.merge_sha, self.base_ref, 124 | ) 125 | ) 126 | 127 | 128 | class TryBuildCompleted(Comment): 129 | params = ["builders", "merge_sha"] 130 | 131 | def render(self): 132 | urls = ", ".join( 133 | "[%s](%s)" % kv for kv in sorted(self.builders.items()) 134 | ) 135 | return ":sunny: Try build successful - %s\nBuild commit: %s (`%s`)" % ( 136 | urls, self.merge_sha, self.merge_sha, 137 | ) 138 | 139 | 140 | class BuildFailed(Comment): 141 | params = ["builder_url", "builder_name"] 142 | 143 | def render(self): 144 | return ":broken_heart: Test failed - [%s](%s)" % ( 145 | self.builder_name, self.builder_url 146 | ) 147 | 148 | 149 | class TryBuildFailed(Comment): 150 | params = ["builder_url", "builder_name"] 151 | 152 | def render(self): 153 | return ":broken_heart: Test failed - [%s](%s)" % ( 154 | self.builder_name, self.builder_url 155 | ) 156 | 157 | 158 | class TimedOut(Comment): 159 | params = [] 160 | 161 | def render(self): 162 | return ":boom: Test timed out" 163 | -------------------------------------------------------------------------------- /homu/git_helper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | 6 | SSH_KEY_FILE = os.path.join(os.path.dirname(__file__), '../cache/key') 7 | 8 | 9 | def main(): 10 | args = ['ssh', '-i', SSH_KEY_FILE, '-S', 'none'] + sys.argv[1:] 11 | os.execvp('ssh', args) 12 | 13 | 14 | if __name__ == '__main__': 15 | main() 16 | -------------------------------------------------------------------------------- /homu/html/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page not found 6 | 11 | 12 | 13 |

Page not found

14 |

Go back to the index

15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /homu/html/build_res.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Homu build result {{repo_label}}#{{pull}} 6 | 31 | 32 | 33 |

Homu build results - {{repo_label}}#{{pull}}

34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% for builder in builders %} 47 | 48 | 49 | 50 | 57 | 58 | {% endfor %} 59 | 60 |
Sort keyBuilderStatus
{{loop.index}}{{builder.name}} 51 | {%- if builder.url -%} 52 | {{builder.result}} 53 | {%- else -%} 54 | {{ builder.result }} 55 | {%- endif -%} 56 |
61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /homu/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Homu 6 | 29 | 30 | 31 | {% if announcement != None %} 32 |
{{ announcement | safe }}
33 | {% endif %} 34 | 35 |
36 | 37 |

Homu

38 | 39 |

Repositories

40 | 41 |
    42 | {% for repo in repos %} 43 |
  • {{repo.repo_label}} {% if repo.treeclosed >= 0 %} [TREE CLOSED] {% endif %}
  • 44 | {% endfor %} 45 |
46 | 47 |
48 | 49 |

Homu Cheatsheet

50 | 51 |

Commands

52 | 53 |

54 | Here's a quick reference for the commands Homu accepts. Commands must be posted as 55 | comments on the PR they refer to. Comments may include multiple commands. Homu will 56 | only listen to official reviewers that it is configured to listen to. A comment 57 | must mention the GitHub account Homu is configured to use. (e.g. for the Rust project this is @bors) 58 | Note that Homu will only recognize comments in open PRs. 59 |

60 | 61 |
    62 |
  • r+ (SHA): Accept a PR. Optionally, the SHA of the last commit in the PR can be provided as a guard against synchronization issues or malicious users. Regardless of the form used, PRs will automatically be unaccepted if the contents are changed.
  • 63 |
  • r=NAME (SHA): Accept a PR on the behalf of NAME.
  • 64 |
  • r-: Unacccept a PR.
  • 65 |
  • p=NUMBER: Set the priority of the accepted PR (defaults to 0).
  • 66 |
  • rollup: Mark the PR as likely to merge without issue, short for rollup=always
  • 67 |
  • rollup-: Unmark the PR as rollup.
  • 68 |
  • rollup=maybe|always|iffy|never: Mark the PR as "always", "maybe", "iffy", and "never" rollup-able.
  • 69 |
  • retry: Signal that the PR is not bad, and should be retried by buildbot.
  • 70 |
  • try: Request that the PR be tested by buildbot, without accepting it.
  • 71 |
  • force: Stop all the builds on the configured builders, and proceed to the next PR.
  • 72 |
  • clean: Clean up the previous build results.
  • 73 |
  • delegate=NAME: Allow NAME to issue all homu commands for this PR.
  • 74 |
  • delegate+: Delegate to the PR owner.
  • 75 |
  • delegate-: Remove the delegatee.
  • 76 |
  • treeclosed=NUMBER: Any PR below priority NUMBER will not test. Please consider if you really want to do this.
  • 77 |
  • treeclosed-: Undo a previous treeclosed=NUMBER.
  • 78 |
79 | 80 |

Examples

81 | 82 |
    83 |
  • @bors r+ p=1
  • 84 |
  • @bors r+ 123456
  • 85 |
  • @bors r=barosl rollup
  • 86 |
  • @bors retry
  • 87 |
  • @bors try @rust-timer queue: Short-hand for compile-perf benchmarking of PRs.
  • 88 |
89 | 90 |

Customizing the Queue's Contents

91 | 92 |

93 | Homu provides a few simple ways to customize the queue's contents to fit your needs: 94 |

95 | 96 |
    97 |
  • queue/rust+cargo will combine the queues of the rust and cargo repos (for example).
  • 98 |
  • queue/all will combine the queues of all registered repos.
  • 99 |
  • Rows can be sorted by column by clicking on column headings.
  • 100 |
  • Rows can be filtered by contents using the search box (only naive substring matching supported).
  • 101 |
102 | 103 |
104 | 105 | 106 | -------------------------------------------------------------------------------- /homu/html/queue.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Homu queue - {{repo_label}} {% if treeclosed %} [TREE CLOSED] {% endif %} 6 | 143 | 144 | 145 | {% if announcement != None %} 146 |
{{ announcement | safe }}
147 | {% endif %} 148 | 149 |

Homu queue - {% if repo_url %}{{repo_label}}{% else %}{{repo_label}}{% endif %} {% if treeclosed %} [TREE CLOSED below priority {{treeclosed}}] {% endif %}

150 | 151 |

152 | 153 |

154 | 155 |
156 |

This will create a new pull request consisting of 0 PRs.

157 |

A rollup is useful for shortening the queue, but jumping the queue is unfair to older PRs who have waited too long.

158 |

When creating a real rollup, see this instruction for reference.

159 |

160 | 161 | — 162 | 163 |

164 |
165 | 166 |

167 | {{ total }} total, {{ approved }} approved, {{ rolled_up }} rolled up, {{ failed }} failed 168 | / 169 | 170 | / 171 | 172 | 173 |

174 | 175 | 176 | 177 | 178 | 179 | 180 | {% if multiple %} 181 | 182 | {% endif %} 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | {% for state in states %} 197 | {% set checkbox_state = 198 | ('checked' if state.prechecked else '') if 199 | ((state.status == 'approved' or (state.status == 'pending' and not state.try_)) and state.rollup != 'never') 200 | else 'disabled' 201 | %} 202 | 203 | 204 | 205 | {% if multiple %} 206 | 207 | {% endif %} 208 | 209 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | {% endfor %} 225 | 226 |
Sort keyRepository#StatusMergeableTitleHead refAssigneeApproved byPriorityRollup
{{loop.index}}{{state.repo_label}}{{state.num}} 210 | {% if state.status == "pending" or state.status == "failure" or state.status == "success" %} 211 | {{state.status}}{{state.status_ext}} 212 | {% else %} 213 | {{state.status}}{{state.status_ext}} 214 | {% endif %} 215 | {{state.mergeable}}{{state.title}}{{state.head_ref}}{{state.assignee}}{{state.approved_by}}{{state.priority}}{{state.rollup}}
227 | 228 |

Open retry log

229 | 230 |

231 | 232 |

233 |

Caution: Synchronization has some caveats. Please follow the steps described in Fixing inconsistencies in the bors queue.

234 | 235 | 236 | 237 | 238 | 345 | 346 | 347 | -------------------------------------------------------------------------------- /homu/html/retry_log.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Homu retry log {{repo_label}} 6 | 17 | 18 | 19 |

Homu retry log - {{repo_label}}

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for log in logs %} 32 | 33 | 34 | 35 | 36 | 37 | {% endfor %} 38 | 39 |
Time (UTC)PRMessage
{{log.time}}{{log.num}}
{{log.msg}}
40 | 41 | 42 | -------------------------------------------------------------------------------- /homu/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import github3 3 | import toml 4 | import json 5 | import re 6 | import functools 7 | from . import comments 8 | from . import utils 9 | from .parse_issue_comment import parse_issue_comment 10 | from .auth import verify as verify_auth 11 | from .utils import lazy_debug 12 | import logging 13 | from threading import Thread, Lock, Timer 14 | import time 15 | import traceback 16 | import sqlite3 17 | import requests 18 | from contextlib import contextmanager 19 | from queue import Queue 20 | import os 21 | import sys 22 | from enum import IntEnum, Enum 23 | import subprocess 24 | from .git_helper import SSH_KEY_FILE 25 | import shlex 26 | import random 27 | import weakref 28 | 29 | STATUS_TO_PRIORITY = { 30 | 'pending': 1, 31 | 'approved': 2, 32 | '': 3, 33 | 'error': 4, 34 | 'failure': 5, 35 | 'success': 6, 36 | } 37 | 38 | INTERRUPTED_BY_HOMU_FMT = 'Interrupted by Homu ({})' 39 | INTERRUPTED_BY_HOMU_RE = re.compile(r'Interrupted by Homu \((.+?)\)') 40 | DEFAULT_TEST_TIMEOUT = 3600 * 10 41 | 42 | VARIABLES_RE = re.compile(r'\${([a-zA-Z_]+)}') 43 | 44 | IGNORE_BLOCK_START = '' 45 | IGNORE_BLOCK_END = '' 46 | IGNORE_BLOCK_RE = re.compile( 47 | r'' 48 | r'.*' 49 | r'', 50 | flags=re.MULTILINE | re.DOTALL | re.IGNORECASE 51 | ) 52 | 53 | global_cfg = {} 54 | 55 | 56 | # Replace @mention with `@mention` to suppress pings in merge commits. 57 | # Note: Don't replace non-mentions like "email@gmail.com". 58 | def suppress_pings(text): 59 | return re.sub(r'\B(@\S+)', r'`\g<1>`', text) # noqa 60 | 61 | 62 | # Replace any text between IGNORE_BLOCK_START and IGNORE_BLOCK_END 63 | # HTML comments with an empty string in merge commits 64 | def suppress_ignore_block(text): 65 | return IGNORE_BLOCK_RE.sub('', text) 66 | 67 | 68 | @contextmanager 69 | def buildbot_sess(repo_cfg): 70 | sess = requests.Session() 71 | 72 | sess.post( 73 | repo_cfg['buildbot']['url'] + '/login', 74 | allow_redirects=False, 75 | data={ 76 | 'username': repo_cfg['buildbot']['username'], 77 | 'passwd': repo_cfg['buildbot']['password'], 78 | }) 79 | 80 | yield sess 81 | 82 | sess.get(repo_cfg['buildbot']['url'] + '/logout', allow_redirects=False) 83 | 84 | 85 | db_query_lock = Lock() 86 | 87 | 88 | def db_query(db, *args): 89 | with db_query_lock: 90 | db.execute(*args) 91 | 92 | 93 | class Repository: 94 | treeclosed = -1 95 | treeclosed_src = None 96 | gh = None 97 | gh_test_on_fork = None 98 | label = None 99 | db = None 100 | 101 | def __init__(self, gh, repo_label, db): 102 | self.gh = gh 103 | self.repo_label = repo_label 104 | self.db = db 105 | db_query( 106 | db, 107 | 'SELECT treeclosed, treeclosed_src FROM repos WHERE repo = ?', 108 | [repo_label] 109 | ) 110 | row = db.fetchone() 111 | if row: 112 | self.treeclosed = row[0] 113 | self.treeclosed_src = row[1] 114 | else: 115 | self.treeclosed = -1 116 | self.treeclosed_src = None 117 | 118 | def update_treeclosed(self, value, src): 119 | self.treeclosed = value 120 | self.treeclosed_src = src 121 | db_query( 122 | self.db, 123 | 'DELETE FROM repos where repo = ?', 124 | [self.repo_label] 125 | ) 126 | if value > 0: 127 | db_query( 128 | self.db, 129 | ''' 130 | INSERT INTO repos (repo, treeclosed, treeclosed_src) 131 | VALUES (?, ?, ?) 132 | ''', 133 | [self.repo_label, value, src] 134 | ) 135 | 136 | def __lt__(self, other): 137 | return self.gh < other.gh 138 | 139 | 140 | class PullReqState: 141 | num = 0 142 | priority = 0 143 | rollup = 0 144 | squash = False 145 | title = '' 146 | body = '' 147 | head_ref = '' 148 | base_ref = '' 149 | assignee = '' 150 | delegate = '' 151 | 152 | def __init__(self, num, head_sha, status, db, repo_label, mergeable_que, 153 | gh, owner, name, label_events, repos, test_on_fork): 154 | self.head_advanced('', use_db=False) 155 | 156 | self.num = num 157 | self.head_sha = head_sha 158 | self.status = status 159 | self.db = db 160 | self.repo_label = repo_label 161 | self.mergeable_que = mergeable_que 162 | self.gh = gh 163 | self.owner = owner 164 | self.name = name 165 | self.repos = repos 166 | self.timeout_timer = None 167 | self.test_started = time.time() 168 | self.label_events = label_events 169 | self.test_on_fork = test_on_fork 170 | 171 | def head_advanced(self, head_sha, *, use_db=True): 172 | self.head_sha = head_sha 173 | self.approved_by = '' 174 | self.status = '' 175 | self.merge_sha = '' 176 | self.build_res = {} 177 | self.try_ = False 178 | self.mergeable = None 179 | 180 | if use_db: 181 | self.set_status('') 182 | self.set_mergeable(None) 183 | self.init_build_res([]) 184 | 185 | def __repr__(self): 186 | fmt = 'PullReqState:{}/{}#{}(approved_by={}, priority={}, status={})' 187 | return fmt.format( 188 | self.owner, 189 | self.name, 190 | self.num, 191 | self.approved_by, 192 | self.priority, 193 | self.status, 194 | ) 195 | 196 | def sort_key(self): 197 | return [ 198 | STATUS_TO_PRIORITY.get(self.get_status(), -1), 199 | 1 if self.mergeable is False else 0, 200 | 0 if self.approved_by else 1, 201 | -self.priority, 202 | # Cap `rollup` below at -1 (the value for iffy), so iffy and never 203 | # are treated the same. 204 | max(self.rollup, -1), 205 | self.num, 206 | ] 207 | 208 | def __lt__(self, other): 209 | return self.sort_key() < other.sort_key() 210 | 211 | def get_issue(self): 212 | issue = getattr(self, 'issue', None) 213 | if not issue: 214 | issue = self.issue = self.get_repo().issue(self.num) 215 | return issue 216 | 217 | def add_comment(self, comment): 218 | if isinstance(comment, comments.Comment): 219 | comment = "%s\n" % ( 220 | comment.render(), comment.jsonify(), 221 | ) 222 | self.get_issue().create_comment(comment) 223 | 224 | def change_labels(self, event): 225 | event = self.label_events.get(event.value, {}) 226 | removes = event.get('remove', []) 227 | adds = event.get('add', []) 228 | unless = event.get('unless', []) 229 | if not removes and not adds: 230 | return 231 | 232 | issue = self.get_issue() 233 | labels = {label.name for label in issue.iter_labels()} 234 | if labels.isdisjoint(unless): 235 | labels.difference_update(removes) 236 | labels.update(adds) 237 | issue.replace_labels(list(labels)) 238 | 239 | def set_status(self, status): 240 | self.status = status 241 | if self.timeout_timer: 242 | self.timeout_timer.cancel() 243 | self.timeout_timer = None 244 | 245 | db_query( 246 | self.db, 247 | 'UPDATE pull SET status = ? WHERE repo = ? AND num = ?', 248 | [self.status, self.repo_label, self.num] 249 | ) 250 | 251 | # FIXME: self.try_ should also be saved in the database 252 | if not self.try_: 253 | db_query( 254 | self.db, 255 | 'UPDATE pull SET merge_sha = ? WHERE repo = ? AND num = ?', 256 | [self.merge_sha, self.repo_label, self.num] 257 | ) 258 | 259 | def get_status(self): 260 | if self.status == '' and self.approved_by: 261 | if self.mergeable is not False: 262 | return 'approved' 263 | return self.status 264 | 265 | def set_mergeable(self, mergeable, *, cause=None, que=True): 266 | if mergeable is not None: 267 | self.mergeable = mergeable 268 | 269 | db_query( 270 | self.db, 271 | 'INSERT OR REPLACE INTO mergeable (repo, num, mergeable) VALUES (?, ?, ?)', # noqa 272 | [self.repo_label, self.num, self.mergeable] 273 | ) 274 | else: 275 | if que: 276 | self.mergeable_que.put([self, cause]) 277 | else: 278 | self.mergeable = None 279 | 280 | db_query( 281 | self.db, 282 | 'DELETE FROM mergeable WHERE repo = ? AND num = ?', 283 | [self.repo_label, self.num] 284 | ) 285 | 286 | def init_build_res(self, builders, *, use_db=True): 287 | self.build_res = {x: { 288 | 'res': None, 289 | 'url': '', 290 | } for x in builders} 291 | 292 | if use_db: 293 | db_query( 294 | self.db, 295 | 'DELETE FROM build_res WHERE repo = ? AND num = ?', 296 | [self.repo_label, self.num] 297 | ) 298 | 299 | def set_build_res(self, builder, res, url): 300 | if builder not in self.build_res: 301 | raise Exception('Invalid builder: {}'.format(builder)) 302 | 303 | self.build_res[builder] = { 304 | 'res': res, 305 | 'url': url, 306 | } 307 | 308 | db_query( 309 | self.db, 310 | 'INSERT OR REPLACE INTO build_res (repo, num, builder, res, url, merge_sha) VALUES (?, ?, ?, ?, ?, ?)', # noqa 311 | [ 312 | self.repo_label, 313 | self.num, 314 | builder, 315 | res, 316 | url, 317 | self.merge_sha, 318 | ]) 319 | 320 | def build_res_summary(self): 321 | return ', '.join('{}: {}'.format(builder, data['res']) 322 | for builder, data in self.build_res.items()) 323 | 324 | def get_repo(self): 325 | repo = self.repos[self.repo_label].gh 326 | if not repo: 327 | repo = self.gh.repository(self.owner, self.name) 328 | self.repos[self.repo_label].gh = repo 329 | 330 | assert repo.owner.login == self.owner 331 | assert repo.name == self.name 332 | return repo 333 | 334 | def get_test_on_fork_repo(self): 335 | if not self.test_on_fork: 336 | return None 337 | 338 | repo = self.repos[self.repo_label].gh_test_on_fork 339 | if not repo: 340 | repo = self.gh.repository( 341 | self.test_on_fork['owner'], 342 | self.test_on_fork['name'], 343 | ) 344 | self.repos[self.repo_label].gh_test_on_fork = repo 345 | 346 | assert repo.owner.login == self.test_on_fork['owner'] 347 | assert repo.name == self.test_on_fork['name'] 348 | return repo 349 | 350 | def save(self): 351 | db_query( 352 | self.db, 353 | 'INSERT OR REPLACE INTO pull (repo, num, status, merge_sha, title, body, head_sha, head_ref, base_ref, assignee, approved_by, priority, try_, rollup, squash, delegate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', # noqa 354 | [ 355 | self.repo_label, 356 | self.num, 357 | self.status, 358 | self.merge_sha, 359 | self.title, 360 | self.body, 361 | self.head_sha, 362 | self.head_ref, 363 | self.base_ref, 364 | self.assignee, 365 | self.approved_by, 366 | self.priority, 367 | self.try_, 368 | self.rollup, 369 | self.squash, 370 | self.delegate, 371 | ]) 372 | 373 | def refresh(self): 374 | issue = self.get_repo().issue(self.num) 375 | 376 | self.title = issue.title 377 | self.body = suppress_pings(issue.body or "") 378 | self.body = suppress_ignore_block(self.body) 379 | 380 | def fake_merge(self, repo_cfg): 381 | if not repo_cfg.get('linear', False): 382 | return 383 | if repo_cfg.get('autosquash', False): 384 | return 385 | 386 | issue = self.get_issue() 387 | title = issue.title 388 | # We tell github to close the PR via the commit message, but it 389 | # doesn't know that constitutes a merge. Edit the title so that it's 390 | # clearer. 391 | merged_prefix = '[merged] ' 392 | if not title.startswith(merged_prefix): 393 | title = merged_prefix + title 394 | issue.edit(title=title) 395 | 396 | def change_treeclosed(self, value, src): 397 | self.repos[self.repo_label].update_treeclosed(value, src) 398 | 399 | def blocked_by_closed_tree(self): 400 | treeclosed = self.repos[self.repo_label].treeclosed 401 | return (treeclosed if self.priority < treeclosed else None, 402 | self.repos[self.repo_label].treeclosed_src) 403 | 404 | def start_testing(self, timeout): 405 | self.test_started = time.time() # FIXME: Save in the local database 406 | self.set_status('pending') 407 | 408 | wm = weakref.WeakMethod(self.timed_out) 409 | 410 | def timed_out(): 411 | m = wm() 412 | if m: 413 | m() 414 | timer = Timer(timeout, timed_out) 415 | timer.start() 416 | self.timeout_timer = timer 417 | 418 | def timed_out(self): 419 | print('* Test timed out: {}'.format(self)) 420 | 421 | self.merge_sha = '' 422 | self.save() 423 | self.set_status('failure') 424 | 425 | utils.github_create_status( 426 | self.get_repo(), 427 | self.head_sha, 428 | 'failure', 429 | '', 430 | 'Test timed out', 431 | context='homu') 432 | self.add_comment(comments.TimedOut()) 433 | self.change_labels(LabelEvent.TIMED_OUT) 434 | 435 | def record_retry_log(self, src, body): 436 | # destroy ancient records 437 | db_query( 438 | self.db, 439 | "DELETE FROM retry_log WHERE repo = ? AND time < date('now', ?)", 440 | [self.repo_label, global_cfg.get('retry_log_expire', '-42 days')], 441 | ) 442 | db_query( 443 | self.db, 444 | 'INSERT INTO retry_log (repo, num, src, msg) VALUES (?, ?, ?, ?)', 445 | [self.repo_label, self.num, src, body], 446 | ) 447 | 448 | @property 449 | def author(self): 450 | """ 451 | Get the GitHub login name of the author of the pull request 452 | """ 453 | return self.get_issue().user.login 454 | 455 | 456 | def sha_cmp(short, full): 457 | return len(short) >= 4 and short == full[:len(short)] 458 | 459 | 460 | def sha_or_blank(sha): 461 | return sha if re.match(r'^[0-9a-f]+$', sha) else '' 462 | 463 | 464 | class AuthState(IntEnum): 465 | # Higher is more privileged 466 | REVIEWER = 3 467 | TRY = 2 468 | NONE = 1 469 | 470 | 471 | class LabelEvent(Enum): 472 | APPROVED = 'approved' 473 | REJECTED = 'rejected' 474 | CONFLICT = 'conflict' 475 | SUCCEED = 'succeed' 476 | FAILED = 'failed' 477 | TRY = 'try' 478 | TRY_SUCCEED = 'try_succeed' 479 | TRY_FAILED = 'try_failed' 480 | EXEMPTED = 'exempted' 481 | TIMED_OUT = 'timed_out' 482 | INTERRUPTED = 'interrupted' 483 | PUSHED = 'pushed' 484 | 485 | 486 | PORTAL_TURRET_DIALOG = ["Target acquired", "Activated", "There you are"] 487 | PORTAL_TURRET_IMAGE = "https://cloud.githubusercontent.com/assets/1617736/22222924/c07b2a1c-e16d-11e6-91b3-ac659550585c.png" # noqa 488 | 489 | 490 | def parse_commands(body, username, user_id, repo_label, repo_cfg, state, 491 | my_username, db, states, *, realtime=False, sha='', 492 | command_src=''): 493 | global global_cfg 494 | state_changed = False 495 | 496 | _reviewer_auth_verified = functools.partial( 497 | verify_auth, 498 | username, 499 | user_id, 500 | repo_label, 501 | repo_cfg, 502 | state, 503 | AuthState.REVIEWER, 504 | realtime, 505 | my_username, 506 | ) 507 | _try_auth_verified = functools.partial( 508 | verify_auth, 509 | username, 510 | user_id, 511 | repo_label, 512 | repo_cfg, 513 | state, 514 | AuthState.TRY, 515 | realtime, 516 | my_username, 517 | ) 518 | 519 | hooks = [] 520 | if 'hooks' in global_cfg: 521 | hooks = list(global_cfg['hooks'].keys()) 522 | 523 | commands = parse_issue_comment(username, body, sha, my_username, hooks) 524 | 525 | for command in commands: 526 | found = True 527 | if command.action == 'approve': 528 | if not _reviewer_auth_verified(): 529 | continue 530 | 531 | approver = command.actor 532 | cur_sha = command.commit 533 | 534 | # Ignore WIP PRs 535 | is_wip = False 536 | for wip_kw in ['WIP', 'TODO', '[WIP]', '[TODO]', '[DO NOT MERGE]']: 537 | if state.title.upper().startswith(wip_kw): 538 | if realtime: 539 | state.add_comment(comments.ApprovalIgnoredWip( 540 | sha=state.head_sha, 541 | wip_keyword=wip_kw, 542 | )) 543 | is_wip = True 544 | break 545 | if is_wip: 546 | continue 547 | 548 | # Sometimes, GitHub sends the head SHA of a PR as 0000000 549 | # through the webhook. This is called a "null commit", and 550 | # seems to happen when GitHub internally encounters a race 551 | # condition. Last time, it happened when squashing commits 552 | # in a PR. In this case, we just try to retrieve the head 553 | # SHA manually. 554 | if all(x == '0' for x in state.head_sha): 555 | if realtime: 556 | state.add_comment( 557 | ':bangbang: Invalid head SHA found, retrying: `{}`' 558 | .format(state.head_sha) 559 | ) 560 | 561 | state.head_sha = state.get_repo().pull_request(state.num).head.sha # noqa 562 | state.save() 563 | 564 | assert any(x != '0' for x in state.head_sha) 565 | 566 | if state.approved_by and realtime and username != my_username: 567 | for _state in states[state.repo_label].values(): 568 | if _state.status == 'pending': 569 | break 570 | else: 571 | _state = None 572 | 573 | lines = [] 574 | 575 | if state.status in ['failure', 'error']: 576 | lines.append('- This pull request previously failed. You should add more commits to fix the bug, or use `retry` to trigger a build again.') # noqa 577 | 578 | if _state: 579 | if state == _state: 580 | lines.append('- This pull request is currently being tested. If there\'s no response from the continuous integration service, you may use `retry` to trigger a build again.') # noqa 581 | else: 582 | lines.append('- There\'s another pull request that is currently being tested, blocking this pull request: #{}'.format(_state.num)) # noqa 583 | 584 | if lines: 585 | lines.insert(0, '') 586 | lines.insert(0, ':bulb: This pull request was already approved, no need to approve it again.') # noqa 587 | 588 | state.add_comment('\n'.join(lines)) 589 | 590 | if sha_cmp(cur_sha, state.head_sha): 591 | state.approved_by = approver 592 | state.try_ = False 593 | state.set_status('') 594 | 595 | state.save() 596 | elif realtime and username != my_username: 597 | if cur_sha: 598 | msg = '`{}` is not a valid commit SHA.'.format(cur_sha) 599 | state.add_comment( 600 | ':scream_cat: {} Please try again with `{}`.' 601 | .format(msg, state.head_sha) 602 | ) 603 | else: 604 | state.add_comment(comments.Approved( 605 | sha=state.head_sha, 606 | approver=approver, 607 | bot=my_username, 608 | queue="https://bors.rust-lang.org/homu/queue/{}" 609 | .format(repo_label) 610 | )) 611 | treeclosed, treeclosed_src = state.blocked_by_closed_tree() 612 | if treeclosed: 613 | state.add_comment( 614 | ':evergreen_tree: The tree is currently [closed]({}) for pull requests below priority {}. This pull request will be tested once the tree is reopened.' # noqa 615 | .format(treeclosed_src, treeclosed) 616 | ) 617 | state.change_labels(LabelEvent.APPROVED) 618 | 619 | elif command.action == 'unapprove': 620 | # Allow the author of a pull request to unapprove their own PR. The 621 | # author can already perform other actions that effectively 622 | # unapprove the PR (change the target branch, push more commits, 623 | # etc.) so allowing them to directly unapprove it is also allowed. 624 | 625 | # Because verify_auth has side-effects (especially, it may leave a 626 | # comment on the pull request if the user is not authorized), we 627 | # need to do the author check BEFORE the verify_auth check. 628 | if state.author != username: 629 | if not verify_auth(username, user_id, repo_label, repo_cfg, 630 | state, AuthState.REVIEWER, realtime, 631 | my_username): 632 | continue 633 | 634 | state.approved_by = '' 635 | state.save() 636 | if realtime: 637 | state.change_labels(LabelEvent.REJECTED) 638 | 639 | elif command.action == 'prioritize': 640 | if not verify_auth(username, user_id, repo_label, repo_cfg, state, 641 | AuthState.TRY, realtime, my_username): 642 | continue 643 | 644 | pvalue = command.priority 645 | 646 | if pvalue > global_cfg['max_priority']: 647 | if realtime: 648 | state.add_comment( 649 | ':stop_sign: Priority higher than {} is ignored.' 650 | .format(global_cfg['max_priority']) 651 | ) 652 | continue 653 | state.priority = pvalue 654 | state.save() 655 | 656 | elif command.action == 'delegate': 657 | if not verify_auth(username, user_id, repo_label, repo_cfg, state, 658 | AuthState.REVIEWER, realtime, my_username): 659 | continue 660 | 661 | state.delegate = command.delegate_to 662 | state.save() 663 | 664 | if realtime: 665 | state.add_comment(comments.Delegated( 666 | delegator=username, 667 | delegate=state.delegate, 668 | bot=my_username 669 | )) 670 | 671 | elif command.action == 'undelegate': 672 | # TODO: why is this a TRY? 673 | if not _try_auth_verified(): 674 | continue 675 | state.delegate = '' 676 | state.save() 677 | 678 | elif command.action == 'delegate-author': 679 | if not _reviewer_auth_verified(): 680 | continue 681 | 682 | state.delegate = state.get_repo().pull_request(state.num).user.login # noqa 683 | state.save() 684 | 685 | if realtime: 686 | state.add_comment(comments.Delegated( 687 | delegator=username, 688 | delegate=state.delegate, 689 | bot=my_username 690 | )) 691 | 692 | elif command.action == 'retry' and realtime: 693 | if not _try_auth_verified(): 694 | continue 695 | state.set_status('') 696 | if realtime: 697 | event = LabelEvent.TRY if state.try_ else LabelEvent.APPROVED 698 | state.record_retry_log(command_src, body) 699 | state.change_labels(event) 700 | 701 | elif command.action in ['try', 'untry'] and realtime: 702 | if not _try_auth_verified(): 703 | continue 704 | if state.status == '' and state.approved_by: 705 | state.add_comment( 706 | ':no_good: ' 707 | 'Please do not `try` after a pull request has been `r+`ed.' 708 | ' If you need to `try`, unapprove (`r-`) it first.' 709 | ) 710 | continue 711 | 712 | state.try_ = command.action == 'try' 713 | 714 | state.merge_sha = '' 715 | state.init_build_res([]) 716 | 717 | state.save() 718 | if realtime and state.try_: 719 | # If we've tried before, the status will be 'success', and this 720 | # new try will not be picked up. Set the status back to '' 721 | # so the try will be run again. 722 | state.set_status('') 723 | # `try-` just resets the `try` bit and doesn't correspond to 724 | # any meaningful labeling events. 725 | state.change_labels(LabelEvent.TRY) 726 | 727 | elif command.action == 'rollup': 728 | if not _try_auth_verified(): 729 | continue 730 | state.rollup = command.rollup_value 731 | 732 | state.save() 733 | 734 | elif command.action == 'squash': 735 | if not _try_auth_verified(): 736 | continue 737 | state.squash = True 738 | 739 | state.save() 740 | 741 | elif command.action == 'unsquash': 742 | if not _try_auth_verified(): 743 | continue 744 | state.squash = False 745 | 746 | state.save() 747 | 748 | elif command.action == 'force' and realtime: 749 | if not _try_auth_verified(): 750 | continue 751 | if 'buildbot' in repo_cfg: 752 | with buildbot_sess(repo_cfg) as sess: 753 | res = sess.post( 754 | repo_cfg['buildbot']['url'] + '/builders/_selected/stopselected', # noqa 755 | allow_redirects=False, 756 | data={ 757 | 'selected': repo_cfg['buildbot']['builders'], 758 | 'comments': INTERRUPTED_BY_HOMU_FMT.format(int(time.time())), # noqa 759 | } 760 | ) 761 | 762 | if 'authzfail' in res.text: 763 | err = 'Authorization failed' 764 | else: 765 | mat = re.search('(?s)
(.*?)
', res.text) 766 | if mat: 767 | err = mat.group(1).strip() 768 | if not err: 769 | err = 'Unknown error' 770 | else: 771 | err = '' 772 | 773 | if err: 774 | state.add_comment( 775 | ':bomb: Buildbot returned an error: `{}`'.format(err) 776 | ) 777 | 778 | elif command.action == 'clean' and realtime: 779 | if not _try_auth_verified(): 780 | continue 781 | state.merge_sha = '' 782 | state.init_build_res([]) 783 | 784 | state.save() 785 | 786 | elif command.action == 'ping' and realtime: 787 | if command.ping_type == 'portal': 788 | state.add_comment( 789 | ":cake: {}\n\n![]({})".format( 790 | random.choice(PORTAL_TURRET_DIALOG), 791 | PORTAL_TURRET_IMAGE) 792 | ) 793 | else: 794 | state.add_comment(":sleepy: I'm awake I'm awake") 795 | 796 | elif command.action == 'treeclosed': 797 | if not _reviewer_auth_verified(): 798 | continue 799 | state.change_treeclosed(command.treeclosed_value, command_src) 800 | state.save() 801 | 802 | elif command.action == 'untreeclosed': 803 | if not _reviewer_auth_verified(): 804 | continue 805 | state.change_treeclosed(-1, None) 806 | state.save() 807 | 808 | elif command.action == 'hook': 809 | hook = command.hook_name 810 | hook_cfg = global_cfg['hooks'][hook] 811 | if hook_cfg['realtime'] and not realtime: 812 | continue 813 | if hook_cfg['access'] == "reviewer": 814 | if not _reviewer_auth_verified(): 815 | continue 816 | else: 817 | if not _try_auth_verified(): 818 | continue 819 | Thread( 820 | target=handle_hook_response, 821 | args=[state, hook_cfg, body, command.hook_extra] 822 | ).start() 823 | 824 | else: 825 | found = False 826 | 827 | if found: 828 | state_changed = True 829 | 830 | return state_changed 831 | 832 | 833 | def handle_hook_response(state, hook_cfg, body, extra_data): 834 | post_data = {} 835 | post_data["pull"] = state.num 836 | post_data["body"] = body 837 | post_data["extra_data"] = extra_data 838 | print(post_data) 839 | response = requests.post(hook_cfg['endpoint'], json=post_data) 840 | print(response.text) 841 | 842 | # We only post a response if we're configured to have a response 843 | # non-realtime hooks cannot post 844 | if hook_cfg['has_response'] and hook_cfg['realtime']: 845 | state.add_comment(response.text) 846 | 847 | 848 | def git_push(git_cmd, branch, state): 849 | merge_sha = subprocess.check_output(git_cmd('rev-parse', 'HEAD')).decode('ascii').strip() # noqa 850 | 851 | if utils.silent_call(git_cmd('push', '-f', 'test-origin', branch)): 852 | utils.logged_call(git_cmd('branch', '-f', 'homu-tmp', branch)) 853 | utils.logged_call(git_cmd('push', '-f', 'test-origin', 'homu-tmp')) 854 | 855 | def inner(): 856 | utils.github_create_status( 857 | state.get_repo(), 858 | merge_sha, 859 | 'success', 860 | '', 861 | 'Branch protection bypassed', 862 | context='homu', 863 | ) 864 | 865 | def fail(err): 866 | state.add_comment( 867 | ':boom: Unable to create a status for {} ({})' 868 | .format(merge_sha, err) 869 | ) 870 | 871 | utils.retry_until(inner, fail, state) 872 | 873 | utils.logged_call(git_cmd('push', '-f', 'test-origin', branch)) 874 | 875 | return merge_sha 876 | 877 | 878 | def init_local_git_cmds(repo_cfg, git_cfg): 879 | fpath = os.path.join(git_cfg["cache_dir"], repo_cfg['owner'], repo_cfg['name']) # noqa 880 | genurl = lambda cfg: 'git@github.com:{}/{}.git'.format(cfg['owner'], cfg['name']) # noqa 881 | 882 | if not os.path.exists(SSH_KEY_FILE): 883 | os.makedirs(os.path.dirname(SSH_KEY_FILE), exist_ok=True) 884 | with open(SSH_KEY_FILE, 'w') as fp: 885 | fp.write(git_cfg['ssh_key']) 886 | os.chmod(SSH_KEY_FILE, 0o600) 887 | 888 | if not os.path.exists(fpath): 889 | print("initialized local git repository at", fpath) 890 | utils.logged_call(['git', 'init', fpath]) 891 | 892 | remotes = { 893 | 'origin': genurl(repo_cfg), 894 | 'test-origin': genurl(repo_cfg.get('test-on-fork', repo_cfg)), 895 | } 896 | 897 | for remote, url in remotes.items(): 898 | try: 899 | utils.logged_call(['git', '-C', fpath, 'remote', 'set-url', remote, url]) # noqa 900 | utils.logged_call(['git', '-C', fpath, 'remote', 'set-url', '--push', remote, url]) # noqa 901 | except subprocess.CalledProcessError: 902 | utils.logged_call(['git', '-C', fpath, 'remote', 'add', remote, url]) # noqa 903 | 904 | return lambda *args: ['git', '-C', fpath] + list(args) 905 | 906 | 907 | def branch_equal_to_merge(git_cmd, state, branch): 908 | utils.logged_call(git_cmd('fetch', 'origin', 909 | 'pull/{}/merge'.format(state.num))) 910 | return utils.silent_call(git_cmd('diff', '--quiet', 'FETCH_HEAD', branch)) == 0 # noqa 911 | 912 | 913 | def create_merge(state, repo_cfg, branch, logger, git_cfg, 914 | ensure_merge_equal=False): 915 | # Add some delay to try to make sure the base repo fetch is accurate. 916 | # It seems like in some cases we're getting the previous commit from GH, 917 | # e.g., https://github.com/rust-lang/homu/issues/75#issuecomment-1729058969 918 | # Hopefully a delay helps. 919 | time.sleep(60) 920 | base_sha = state.get_repo().ref('heads/' + state.base_ref).object.sha 921 | 922 | state.refresh() 923 | 924 | lazy_debug(logger, 925 | lambda: "create_merge: attempting merge {} into {} on {!r}" 926 | .format(state.head_sha, branch, state.get_repo())) 927 | 928 | merge_msg = 'Auto merge of #{} - {}, r={}\n\n{}\n\n{}'.format( 929 | state.num, 930 | state.head_ref, 931 | '' if state.try_ else state.approved_by, 932 | state.title, 933 | state.body) 934 | 935 | squash_msg = '{}\n\n{}'.format( 936 | state.title, 937 | state.body) 938 | 939 | desc = 'Merge conflict' 940 | comment = ( 941 | 'This pull request and the master branch diverged in a way that cannot' 942 | ' be automatically merged. Please rebase on top of the latest master' 943 | ' branch, and let the reviewer approve again.\n' 944 | '\n' 945 | '
How do I rebase?\n\n' 946 | 'Assuming `self` is your fork and `upstream` is this repository,' 947 | ' you can resolve the conflict following these steps:\n\n' 948 | '1. `git checkout {branch}` *(switch to your branch)*\n' 949 | '2. `git fetch upstream master` *(retrieve the latest master)*\n' 950 | '3. `git rebase upstream/master -p` *(rebase on top of it)*\n' 951 | '4. Follow the on-screen instruction to resolve conflicts' 952 | ' (check `git status` if you got lost).\n' 953 | '5. `git push self {branch} --force-with-lease` *(update this PR)*\n\n' 954 | 'You may also read' 955 | ' [*Git Rebasing to Resolve Conflicts* by Drew Blessing](http://blessing.io/git/git-rebase/open-source/2015/08/23/git-rebasing-to-resolve-conflicts.html)' # noqa 956 | ' for a short tutorial.\n\n' 957 | 'Please avoid the ["**Resolve conflicts**" button](https://help.github.com/articles/resolving-a-merge-conflict-on-github/) on GitHub.' # noqa 958 | ' It uses `git merge` instead of `git rebase` which makes the PR commit' # noqa 959 | ' history more difficult to read.\n\n' 960 | 'Sometimes step 4 will complete without asking for resolution. This is' 961 | ' usually due to difference between how `Cargo.lock` conflict is' 962 | ' handled during merge and rebase. This is normal, and you should still' # noqa 963 | ' perform step 5 to update this PR.\n\n' 964 | '
\n\n' 965 | ).format(branch=state.head_ref.split(':', 1)[1]) 966 | 967 | if git_cfg['local_git']: 968 | 969 | git_cmd = init_local_git_cmds(repo_cfg, git_cfg) 970 | 971 | utils.logged_call(git_cmd('fetch', 'origin', state.base_ref, 972 | 'pull/{}/head'.format(state.num))) 973 | utils.silent_call(git_cmd('reset', '--hard')) 974 | utils.silent_call(git_cmd('rebase', '--abort')) 975 | utils.silent_call(git_cmd('merge', '--abort')) 976 | 977 | if repo_cfg.get('linear', False): 978 | utils.logged_call( 979 | git_cmd('checkout', '-B', branch, state.head_sha)) 980 | try: 981 | args = [base_sha] 982 | if repo_cfg.get('autosquash', False): 983 | args += ['-i', '--autosquash'] 984 | utils.logged_call(git_cmd('-c', 985 | 'user.name=' + git_cfg['name'], 986 | '-c', 987 | 'user.email=' + git_cfg['email'], 988 | 'rebase', 989 | *args)) 990 | except subprocess.CalledProcessError: 991 | if repo_cfg.get('autosquash', False): 992 | utils.silent_call(git_cmd('rebase', '--abort')) 993 | if utils.silent_call(git_cmd('rebase', base_sha)) == 0: 994 | desc = 'Auto-squashing failed' 995 | comment = '' 996 | else: 997 | ap = '' if state.try_ else state.approved_by 998 | text = '\nCloses: #{}\nApproved by: {}'.format(state.num, ap) 999 | msg_code = 'cat && echo {}'.format(shlex.quote(text)) 1000 | env_code = 'export GIT_COMMITTER_NAME={} && export GIT_COMMITTER_EMAIL={} && unset GIT_COMMITTER_DATE'.format(shlex.quote(git_cfg['name']), shlex.quote(git_cfg['email'])) # noqa 1001 | utils.logged_call(git_cmd('filter-branch', '-f', 1002 | '--msg-filter', msg_code, 1003 | '--env-filter', env_code, 1004 | '{}..'.format(base_sha))) 1005 | 1006 | if ensure_merge_equal: 1007 | if not branch_equal_to_merge(git_cmd, state, branch): 1008 | return '' 1009 | 1010 | return git_push(git_cmd, branch, state) 1011 | else: 1012 | utils.logged_call(git_cmd( 1013 | 'checkout', 1014 | '-f', 1015 | '-B', 1016 | 'homu-tmp', 1017 | state.head_sha)) 1018 | 1019 | ok = True 1020 | if repo_cfg.get('autosquash', False): 1021 | try: 1022 | merge_base_sha = subprocess.check_output( 1023 | git_cmd( 1024 | 'merge-base', 1025 | base_sha, 1026 | state.head_sha)).decode('ascii').strip() 1027 | utils.logged_call(git_cmd( 1028 | '-c', 1029 | 'user.name=' + git_cfg['name'], 1030 | '-c', 1031 | 'user.email=' + git_cfg['email'], 1032 | 'rebase', 1033 | '-i', 1034 | '--autosquash', 1035 | '--onto', 1036 | merge_base_sha, base_sha)) 1037 | except subprocess.CalledProcessError: 1038 | desc = 'Auto-squashing failed' 1039 | comment = '' 1040 | ok = False 1041 | if state.squash: 1042 | try: 1043 | merge_base_sha = subprocess.check_output( 1044 | git_cmd( 1045 | 'merge-base', 1046 | base_sha, 1047 | state.head_sha)).decode('ascii').strip() 1048 | utils.logged_call(git_cmd( 1049 | 'reset', 1050 | '--soft', 1051 | merge_base_sha)) 1052 | utils.logged_call(git_cmd( 1053 | '-c', 1054 | 'user.name=' + git_cfg['name'], 1055 | '-c', 1056 | 'user.email=' + git_cfg['email'], 1057 | 'commit', 1058 | '-m', 1059 | squash_msg)) 1060 | except subprocess.CalledProcessError: 1061 | desc = 'Squashing failed' 1062 | comment = '' 1063 | ok = False 1064 | 1065 | if ok: 1066 | utils.logged_call(git_cmd('checkout', '-B', branch, base_sha)) 1067 | try: 1068 | subprocess.check_output( 1069 | git_cmd( 1070 | '-c', 1071 | 'user.name=' + git_cfg['name'], 1072 | '-c', 1073 | 'user.email=' + git_cfg['email'], 1074 | 'merge', 1075 | 'heads/homu-tmp', 1076 | '--no-ff', 1077 | '-m', 1078 | merge_msg), 1079 | stderr=subprocess.STDOUT, 1080 | universal_newlines=True) 1081 | except subprocess.CalledProcessError as e: 1082 | comment += '
Error message\n\n```text\n' # noqa 1083 | comment += e.output 1084 | comment += '\n```\n\n
' 1085 | pass 1086 | else: 1087 | if ensure_merge_equal: 1088 | if not branch_equal_to_merge(git_cmd, state, branch): 1089 | return '' 1090 | 1091 | return git_push(git_cmd, branch, state) 1092 | else: 1093 | if repo_cfg.get('linear', False) or repo_cfg.get('autosquash', False): 1094 | raise RuntimeError('local_git must be turned on to use this feature') # noqa 1095 | 1096 | # if we're merging using the GitHub API, we have no way to predict 1097 | # with certainty what the final result will be so make sure the caller 1098 | # isn't asking us to keep any promises (see also discussions at 1099 | # https://github.com/servo/homu/pull/57) 1100 | assert ensure_merge_equal is False 1101 | 1102 | if branch != state.base_ref: 1103 | utils.github_set_ref( 1104 | state.get_repo(), 1105 | 'heads/' + branch, 1106 | base_sha, 1107 | force=True, 1108 | ) 1109 | 1110 | try: 1111 | merge_commit = state.get_repo().merge( 1112 | branch, 1113 | state.head_sha, 1114 | merge_msg) 1115 | except github3.models.GitHubError as e: 1116 | if e.code != 409: 1117 | raise 1118 | else: 1119 | return merge_commit.sha if merge_commit else '' 1120 | 1121 | state.set_status('error') 1122 | utils.github_create_status( 1123 | state.get_repo(), 1124 | state.head_sha, 1125 | 'error', 1126 | '', 1127 | desc, 1128 | context='homu') 1129 | 1130 | state.add_comment(':lock: {}\n\n{}'.format(desc, comment)) 1131 | state.change_labels(LabelEvent.CONFLICT) 1132 | 1133 | return '' 1134 | 1135 | 1136 | def pull_is_rebased(state, repo_cfg, git_cfg, base_sha): 1137 | assert git_cfg['local_git'] 1138 | git_cmd = init_local_git_cmds(repo_cfg, git_cfg) 1139 | 1140 | utils.logged_call(git_cmd('fetch', 'origin', state.base_ref, 1141 | 'pull/{}/head'.format(state.num))) 1142 | 1143 | return utils.silent_call(git_cmd('merge-base', '--is-ancestor', 1144 | base_sha, state.head_sha)) == 0 1145 | 1146 | 1147 | # We could fetch this from GitHub instead, but that API is being deprecated: 1148 | # https://developer.github.com/changes/2013-04-25-deprecating-merge-commit-sha/ 1149 | def get_github_merge_sha(state, repo_cfg, git_cfg): 1150 | assert git_cfg['local_git'] 1151 | git_cmd = init_local_git_cmds(repo_cfg, git_cfg) 1152 | 1153 | if state.mergeable is not True: 1154 | return None 1155 | 1156 | utils.logged_call(git_cmd('fetch', 'origin', 1157 | 'pull/{}/merge'.format(state.num))) 1158 | 1159 | return subprocess.check_output(git_cmd('rev-parse', 'FETCH_HEAD')).decode('ascii').strip() # noqa 1160 | 1161 | 1162 | def do_exemption_merge(state, logger, repo_cfg, git_cfg, url, check_merge, 1163 | reason): 1164 | 1165 | try: 1166 | merge_sha = create_merge( 1167 | state, 1168 | repo_cfg, 1169 | state.base_ref, 1170 | logger, 1171 | git_cfg, 1172 | check_merge) 1173 | except subprocess.CalledProcessError: 1174 | print('* Unable to create a merge commit for the exempted PR: {}'.format(state)) # noqa 1175 | traceback.print_exc() 1176 | return False 1177 | 1178 | if not merge_sha: 1179 | return False 1180 | 1181 | desc = 'Test exempted' 1182 | 1183 | state.set_status('success') 1184 | utils.github_create_status(state.get_repo(), state.head_sha, 'success', 1185 | url, desc, context='homu') 1186 | state.add_comment(':zap: {}: {}.'.format(desc, reason)) 1187 | state.change_labels(LabelEvent.EXEMPTED) 1188 | 1189 | state.merge_sha = merge_sha 1190 | state.save() 1191 | 1192 | state.fake_merge(repo_cfg) 1193 | return True 1194 | 1195 | 1196 | def try_travis_exemption(state, logger, repo_cfg, git_cfg): 1197 | 1198 | travis_info = None 1199 | for info in utils.github_iter_statuses(state.get_repo(), state.head_sha): 1200 | if info.context == 'continuous-integration/travis-ci/pr': 1201 | travis_info = info 1202 | break 1203 | 1204 | if travis_info is None or travis_info.state != 'success': 1205 | return False 1206 | 1207 | mat = re.search('/builds/([0-9]+)$', travis_info.target_url) 1208 | if not mat: 1209 | return False 1210 | 1211 | url = 'https://api.travis-ci.org/{}/{}/builds/{}'.format(state.owner, 1212 | state.name, 1213 | mat.group(1)) 1214 | try: 1215 | res = requests.get(url) 1216 | except Exception as ex: 1217 | print('* Unable to gather build info from Travis CI: {}'.format(ex)) 1218 | return False 1219 | 1220 | travis_sha = json.loads(res.text)['commit'] 1221 | travis_commit = state.get_repo().commit(travis_sha) 1222 | 1223 | if not travis_commit: 1224 | return False 1225 | 1226 | base_sha = state.get_repo().ref('heads/' + state.base_ref).object.sha 1227 | 1228 | if (travis_commit.parents[0]['sha'] == base_sha and 1229 | travis_commit.parents[1]['sha'] == state.head_sha): 1230 | # make sure we check against the github merge sha before pushing 1231 | return do_exemption_merge(state, logger, repo_cfg, git_cfg, 1232 | travis_info.target_url, True, 1233 | "merge already tested by Travis CI") 1234 | 1235 | return False 1236 | 1237 | 1238 | def try_status_exemption(state, logger, repo_cfg, git_cfg): 1239 | 1240 | # If all the builders are status-based, then we can do some checks to 1241 | # exempt testing under the following cases: 1242 | # 1. The PR head commit has the equivalent statuses set to 'success' and 1243 | # it is fully rebased on the HEAD of the target base ref. 1244 | # 2. The PR head and merge commits have the equivalent statuses set to 1245 | # state 'success' and the merge commit's first parent is the HEAD of 1246 | # the target base ref. 1247 | 1248 | if not git_cfg['local_git']: 1249 | raise RuntimeError('local_git is required to use status exemption') 1250 | 1251 | statuses_all = set() 1252 | 1253 | # equivalence dict: pr context --> auto context 1254 | status_equivalences = {} 1255 | 1256 | for key, value in repo_cfg['status'].items(): 1257 | context = value.get('context') 1258 | pr_context = value.get('pr_context', context) 1259 | if context is not None: 1260 | statuses_all.add(context) 1261 | status_equivalences[pr_context] = context 1262 | 1263 | assert len(statuses_all) > 0 1264 | 1265 | # let's first check that all the statuses we want are set to success 1266 | statuses_pass = set() 1267 | for info in utils.github_iter_statuses(state.get_repo(), state.head_sha): 1268 | if info.context in status_equivalences and info.state == 'success': 1269 | statuses_pass.add(status_equivalences[info.context]) 1270 | 1271 | if statuses_all != statuses_pass: 1272 | return False 1273 | 1274 | # is the PR fully rebased? 1275 | base_sha = state.get_repo().ref('heads/' + state.base_ref).object.sha 1276 | if pull_is_rebased(state, repo_cfg, git_cfg, base_sha): 1277 | return do_exemption_merge(state, logger, repo_cfg, git_cfg, '', False, 1278 | "pull fully rebased and already tested") 1279 | 1280 | # check if we can use the github merge sha as proof 1281 | merge_sha = get_github_merge_sha(state, repo_cfg, git_cfg) 1282 | if merge_sha is None: 1283 | return False 1284 | 1285 | statuses_merge_pass = set() 1286 | for info in utils.github_iter_statuses(state.get_repo(), merge_sha): 1287 | if info.context in status_equivalences and info.state == 'success': 1288 | statuses_merge_pass.add(status_equivalences[info.context]) 1289 | 1290 | merge_commit = state.get_repo().commit(merge_sha) 1291 | if (statuses_all == statuses_merge_pass and 1292 | merge_commit.parents[0]['sha'] == base_sha and 1293 | merge_commit.parents[1]['sha'] == state.head_sha): 1294 | # make sure we check against the github merge sha before pushing 1295 | return do_exemption_merge(state, logger, repo_cfg, git_cfg, '', True, 1296 | "merge already tested") 1297 | 1298 | return False 1299 | 1300 | 1301 | def start_build(state, repo_cfgs, buildbot_slots, logger, db, git_cfg): 1302 | if buildbot_slots[0]: 1303 | return True 1304 | 1305 | lazy_debug(logger, lambda: "start_build on {!r}".format(state.get_repo())) 1306 | 1307 | pr = state.get_repo().pull_request(state.num) 1308 | assert state.head_sha == pr.head.sha 1309 | assert state.base_ref == pr.base.ref 1310 | 1311 | repo_cfg = repo_cfgs[state.repo_label] 1312 | 1313 | builders = [] 1314 | branch = 'try' if state.try_ else 'auto' 1315 | branch = repo_cfg.get('branch', {}).get(branch, branch) 1316 | can_try_travis_exemption = False 1317 | 1318 | only_status_builders = True 1319 | if 'buildbot' in repo_cfg: 1320 | if state.try_: 1321 | builders += repo_cfg['buildbot']['try_builders'] 1322 | else: 1323 | builders += repo_cfg['buildbot']['builders'] 1324 | only_status_builders = False 1325 | if 'travis' in repo_cfg: 1326 | builders += ['travis'] 1327 | only_status_builders = False 1328 | if 'status' in repo_cfg: 1329 | found_travis_context = False 1330 | for key, value in repo_cfg['status'].items(): 1331 | context = value.get('context') 1332 | if context is not None: 1333 | if state.try_ and not value.get('try', True): 1334 | # Skip this builder for tries. 1335 | continue 1336 | builders += ['status-' + key] 1337 | # We have an optional fast path if the Travis test passed 1338 | # for a given commit and master is unchanged, we can do 1339 | # a direct push. 1340 | if context == 'continuous-integration/travis-ci/push': 1341 | found_travis_context = True 1342 | 1343 | if found_travis_context and len(builders) == 1: 1344 | can_try_travis_exemption = True 1345 | if 'checks' in repo_cfg: 1346 | builders += [ 1347 | 'checks-' + key 1348 | for key, value in repo_cfg['checks'].items() 1349 | if 'name' in value or (state.try_ and 'try_name' in value) 1350 | ] 1351 | only_status_builders = False 1352 | 1353 | if len(builders) == 0: 1354 | raise RuntimeError('Invalid configuration') 1355 | 1356 | lazy_debug(logger, lambda: "start_build: builders={!r}".format(builders)) 1357 | 1358 | if (only_status_builders and state.approved_by and 1359 | repo_cfg.get('status_based_exemption', False)): 1360 | if can_try_travis_exemption: 1361 | if try_travis_exemption(state, logger, repo_cfg, git_cfg): 1362 | return True 1363 | if try_status_exemption(state, logger, repo_cfg, git_cfg): 1364 | return True 1365 | 1366 | merge_sha = create_merge(state, repo_cfg, branch, logger, git_cfg) 1367 | lazy_debug(logger, lambda: "start_build: merge_sha={}".format(merge_sha)) 1368 | if not merge_sha: 1369 | return False 1370 | 1371 | state.init_build_res(builders) 1372 | state.merge_sha = merge_sha 1373 | 1374 | state.save() 1375 | 1376 | if 'buildbot' in repo_cfg: 1377 | buildbot_slots[0] = state.merge_sha 1378 | 1379 | logger.info('Starting build of {}/{}#{} on {}: {}'.format( 1380 | state.owner, 1381 | state.name, 1382 | state.num, 1383 | branch, 1384 | state.merge_sha)) 1385 | 1386 | timeout = repo_cfg.get('timeout', DEFAULT_TEST_TIMEOUT) 1387 | state.start_testing(timeout) 1388 | 1389 | desc = '{} commit {} with merge {}...'.format( 1390 | 'Trying' if state.try_ else 'Testing', 1391 | state.head_sha, 1392 | state.merge_sha, 1393 | ) 1394 | utils.github_create_status( 1395 | state.get_repo(), 1396 | state.head_sha, 1397 | 'pending', 1398 | '', 1399 | desc, 1400 | context='homu') 1401 | 1402 | if state.try_: 1403 | state.add_comment(comments.TryBuildStarted( 1404 | head_sha=state.head_sha, 1405 | merge_sha=state.merge_sha, 1406 | )) 1407 | else: 1408 | state.add_comment(comments.BuildStarted( 1409 | head_sha=state.head_sha, 1410 | merge_sha=state.merge_sha, 1411 | )) 1412 | 1413 | return True 1414 | 1415 | 1416 | def start_rebuild(state, repo_cfgs): 1417 | repo_cfg = repo_cfgs[state.repo_label] 1418 | 1419 | if 'buildbot' not in repo_cfg or not state.build_res: 1420 | return False 1421 | 1422 | builders = [] 1423 | succ_builders = [] 1424 | 1425 | for builder, info in state.build_res.items(): 1426 | if not info['url']: 1427 | return False 1428 | 1429 | if info['res']: 1430 | succ_builders.append([builder, info['url']]) 1431 | else: 1432 | builders.append([builder, info['url']]) 1433 | 1434 | if not builders or not succ_builders: 1435 | return False 1436 | 1437 | base_sha = state.get_repo().ref('heads/' + state.base_ref).object.sha 1438 | _parents = state.get_repo().commit(state.merge_sha).parents 1439 | parent_shas = [x['sha'] for x in _parents] 1440 | 1441 | if base_sha not in parent_shas: 1442 | return False 1443 | 1444 | utils.github_set_ref( 1445 | state.get_repo(), 1446 | 'tags/homu-tmp', 1447 | state.merge_sha, 1448 | force=True) 1449 | 1450 | builders.sort() 1451 | succ_builders.sort() 1452 | 1453 | with buildbot_sess(repo_cfg) as sess: 1454 | for builder, url in builders: 1455 | res = sess.post(url + '/rebuild', allow_redirects=False, data={ 1456 | 'useSourcestamp': 'exact', 1457 | 'comments': 'Initiated by Homu', 1458 | }) 1459 | 1460 | if 'authzfail' in res.text: 1461 | err = 'Authorization failed' 1462 | elif builder in res.text: 1463 | err = '' 1464 | else: 1465 | mat = re.search('(.+?)', res.text) 1466 | err = mat.group(1) if mat else 'Unknown error' 1467 | 1468 | if err: 1469 | state.add_comment(':bomb: Failed to start rebuilding: `{}`'.format(err)) # noqa 1470 | return False 1471 | 1472 | timeout = repo_cfg.get('timeout', DEFAULT_TEST_TIMEOUT) 1473 | state.start_testing(timeout) 1474 | 1475 | msg_1 = 'Previous build results' 1476 | msg_2 = ' for {}'.format(', '.join('[{}]({})'.format(builder, url) for builder, url in succ_builders)) # noqa 1477 | msg_3 = ' are reusable. Rebuilding' 1478 | msg_4 = ' only {}'.format(', '.join('[{}]({})'.format(builder, url) for builder, url in builders)) # noqa 1479 | 1480 | utils.github_create_status( 1481 | state.get_repo(), 1482 | state.head_sha, 1483 | 'pending', 1484 | '', 1485 | '{}{}...'.format(msg_1, msg_3), 1486 | context='homu') 1487 | 1488 | state.add_comment(':zap: {}{}{}{}...'.format(msg_1, msg_2, msg_3, msg_4)) 1489 | 1490 | return True 1491 | 1492 | 1493 | def start_build_or_rebuild(state, repo_cfgs, *args): 1494 | if start_rebuild(state, repo_cfgs): 1495 | return True 1496 | 1497 | return start_build(state, repo_cfgs, *args) 1498 | 1499 | 1500 | def process_queue(states, repos, repo_cfgs, logger, buildbot_slots, db, 1501 | git_cfg): 1502 | for repo_label, repo in repos.items(): 1503 | repo_states = sorted(states[repo_label].values()) 1504 | 1505 | for state in repo_states: 1506 | lazy_debug(logger, lambda: "process_queue: state={!r}, building {}" 1507 | .format(state, repo_label)) 1508 | if state.priority < repo.treeclosed: 1509 | continue 1510 | if state.status == 'pending' and not state.try_: 1511 | break 1512 | 1513 | elif state.status == 'success' and hasattr(state, 'fake_merge_sha'): # noqa 1514 | break 1515 | 1516 | elif state.status == '' and state.approved_by: 1517 | if start_build_or_rebuild(state, repo_cfgs, buildbot_slots, 1518 | logger, db, git_cfg): 1519 | return 1520 | 1521 | elif state.status == 'success' and state.try_ and state.approved_by: # noqa 1522 | state.try_ = False 1523 | 1524 | state.save() 1525 | 1526 | if start_build(state, repo_cfgs, buildbot_slots, logger, db, 1527 | git_cfg): 1528 | return 1529 | 1530 | for state in repo_states: 1531 | if state.status == '' and state.try_: 1532 | if start_build(state, repo_cfgs, buildbot_slots, logger, db, 1533 | git_cfg): 1534 | return 1535 | 1536 | 1537 | def fetch_mergeability(mergeable_que): 1538 | re_pull_num = re.compile('(?i)merge (?:of|pull request) #([0-9]+)') 1539 | 1540 | while True: 1541 | try: 1542 | state, cause = mergeable_que.get() 1543 | 1544 | if state.status == 'success': 1545 | continue 1546 | 1547 | pull_request = state.get_repo().pull_request(state.num) 1548 | if pull_request is None or pull_request.mergeable is None: 1549 | time.sleep(5) 1550 | pull_request = state.get_repo().pull_request(state.num) 1551 | mergeable = pull_request is not None and pull_request.mergeable 1552 | 1553 | if state.mergeable is True and mergeable is False: 1554 | if cause: 1555 | mat = re_pull_num.search(cause['title']) 1556 | 1557 | if mat: 1558 | issue_or_commit = '#' + mat.group(1) 1559 | else: 1560 | issue_or_commit = cause['sha'] 1561 | else: 1562 | issue_or_commit = '' 1563 | 1564 | _blame = '' 1565 | if issue_or_commit: 1566 | _blame = ' (presumably {})'.format(issue_or_commit) 1567 | state.add_comment( 1568 | ':umbrella: The latest upstream changes{} made this ' 1569 | 'pull request unmergeable. Please [resolve the merge conflicts]' # noqa 1570 | '(https://rustc-dev-guide.rust-lang.org/git.html#rebasing-and-conflicts).' # noqa 1571 | .format(_blame) 1572 | ) 1573 | state.change_labels(LabelEvent.CONFLICT) 1574 | 1575 | state.set_mergeable(mergeable, que=False) 1576 | 1577 | except Exception: 1578 | print('* Error while fetching mergeability') 1579 | traceback.print_exc() 1580 | 1581 | finally: 1582 | mergeable_que.task_done() 1583 | 1584 | 1585 | def synchronize(repo_label, repo_cfg, logger, gh, states, repos, db, mergeable_que, my_username, repo_labels): # noqa 1586 | logger.info('Synchronizing {}...'.format(repo_label)) 1587 | 1588 | repo = gh.repository(repo_cfg['owner'], repo_cfg['name']) 1589 | 1590 | db_query(db, 'DELETE FROM pull WHERE repo = ?', [repo_label]) 1591 | db_query(db, 'DELETE FROM build_res WHERE repo = ?', [repo_label]) 1592 | db_query(db, 'DELETE FROM mergeable WHERE repo = ?', [repo_label]) 1593 | 1594 | saved_states = {} 1595 | for num, state in states[repo_label].items(): 1596 | saved_states[num] = { 1597 | 'merge_sha': state.merge_sha, 1598 | 'build_res': state.build_res, 1599 | } 1600 | 1601 | states[repo_label] = {} 1602 | repos[repo_label] = Repository(repo, repo_label, db) 1603 | 1604 | for pull in repo.iter_pulls(state='open'): 1605 | db_query( 1606 | db, 1607 | 'SELECT status FROM pull WHERE repo = ? AND num = ?', 1608 | [repo_label, pull.number]) 1609 | row = db.fetchone() 1610 | if row: 1611 | status = row[0] 1612 | else: 1613 | status = '' 1614 | for info in utils.github_iter_statuses(repo, pull.head.sha): 1615 | if info.context == 'homu': 1616 | status = info.state 1617 | break 1618 | 1619 | state = PullReqState(pull.number, pull.head.sha, status, db, repo_label, mergeable_que, gh, repo_cfg['owner'], repo_cfg['name'], repo_cfg.get('labels', {}), repos, repo_cfg.get('test-on-fork')) # noqa 1620 | state.title = pull.title 1621 | state.body = suppress_pings(pull.body or "") 1622 | state.body = suppress_ignore_block(state.body) 1623 | state.head_ref = pull.head.repo[0] + ':' + pull.head.ref 1624 | state.base_ref = pull.base.ref 1625 | state.set_mergeable(None) 1626 | state.assignee = pull.assignee.login if pull.assignee else '' 1627 | 1628 | for comment in pull.iter_comments(): 1629 | if comment.original_commit_id == pull.head.sha: 1630 | parse_commands( 1631 | comment.body, 1632 | comment.user.login, 1633 | comment.user.id, 1634 | repo_label, 1635 | repo_cfg, 1636 | state, 1637 | my_username, 1638 | db, 1639 | states, 1640 | sha=comment.original_commit_id, 1641 | command_src=comment.to_json()['html_url'], 1642 | # FIXME switch to `comment.html_url` 1643 | # after updating github3 to 1.3.0+ 1644 | ) 1645 | 1646 | for comment in pull.iter_issue_comments(): 1647 | parse_commands( 1648 | comment.body, 1649 | comment.user.login, 1650 | comment.user.id, 1651 | repo_label, 1652 | repo_cfg, 1653 | state, 1654 | my_username, 1655 | db, 1656 | states, 1657 | command_src=comment.to_json()['html_url'], 1658 | # FIXME switch to `comment.html_url` 1659 | # after updating github3 to 1.3.0+ 1660 | ) 1661 | 1662 | saved_state = saved_states.get(pull.number) 1663 | if saved_state: 1664 | for key, val in saved_state.items(): 1665 | setattr(state, key, val) 1666 | 1667 | state.save() 1668 | 1669 | states[repo_label][pull.number] = state 1670 | 1671 | logger.info('Done synchronizing {}!'.format(repo_label)) 1672 | 1673 | 1674 | def process_config(config): 1675 | # Replace environment variables 1676 | if type(config) is str: 1677 | for var in VARIABLES_RE.findall(config): 1678 | try: 1679 | config = config.replace("${"+var+"}", os.environ[var]) 1680 | except KeyError: 1681 | raise RuntimeError( 1682 | f"missing environment variable ${var} " 1683 | f"(requested in the configuration file)" 1684 | ) from None 1685 | 1686 | return config 1687 | # Recursively apply the processing 1688 | elif type(config) is list: 1689 | return [process_config(item) for item in config] 1690 | elif type(config) is dict: 1691 | return {key: process_config(value) for key, value in config.items()} 1692 | # All other values should be returned as-is 1693 | else: 1694 | return config 1695 | 1696 | 1697 | def arguments(): 1698 | parser = argparse.ArgumentParser( 1699 | description='A bot that integrates with GitHub and your favorite ' 1700 | 'continuous integration service') 1701 | parser.add_argument( 1702 | '-v', 1703 | '--verbose', 1704 | action='store_true', 1705 | help='Enable more verbose logging') 1706 | parser.add_argument( 1707 | '-c', 1708 | '--config', 1709 | action='store', 1710 | help='Path to cfg.toml', 1711 | default='cfg.toml') 1712 | 1713 | return parser.parse_args() 1714 | 1715 | 1716 | def main(): 1717 | global global_cfg 1718 | args = arguments() 1719 | 1720 | logger = logging.getLogger('homu') 1721 | logger.setLevel(logging.DEBUG if args.verbose else logging.INFO) 1722 | logger.addHandler(logging.StreamHandler()) 1723 | 1724 | if sys.getfilesystemencoding() == 'ascii': 1725 | logger.info('You need to set a locale compatible with unicode or homu will choke on Unicode in PR descriptions/titles. See http://stackoverflow.com/a/27931669') # noqa 1726 | 1727 | try: 1728 | with open(args.config) as fp: 1729 | cfg = toml.loads(fp.read()) 1730 | except FileNotFoundError: 1731 | # Fall back to cfg.json only if we're using the defaults 1732 | if args.config == 'cfg.toml': 1733 | with open('cfg.json') as fp: 1734 | cfg = json.loads(fp.read()) 1735 | else: 1736 | raise 1737 | cfg = process_config(cfg) 1738 | global_cfg = cfg 1739 | 1740 | gh = github3.login(token=cfg['github']['access_token']) 1741 | user = gh.user() 1742 | cfg_git = cfg.get('git', {}) 1743 | user_email = cfg_git.get('email') 1744 | if user_email is None: 1745 | try: 1746 | user_email = [x for x in gh.iter_emails() if x['primary']][0]['email'] # noqa 1747 | except IndexError: 1748 | raise RuntimeError('Primary email not set, or "user" scope not granted') # noqa 1749 | user_name = cfg_git.get('name', user.name if user.name else user.login) 1750 | 1751 | states = {} 1752 | repos = {} 1753 | repo_cfgs = {} 1754 | buildbot_slots = [''] 1755 | my_username = user.login 1756 | repo_labels = {} 1757 | mergeable_que = Queue() 1758 | git_cfg = { 1759 | 'name': user_name, 1760 | 'email': user_email, 1761 | 'ssh_key': cfg_git.get('ssh_key', ''), 1762 | 'local_git': cfg_git.get('local_git', False), 1763 | 'cache_dir': cfg_git.get('cache_dir', 'cache') 1764 | } 1765 | 1766 | db_file = cfg.get('db', {}).get('file', 'main.db') 1767 | db_conn = sqlite3.connect(db_file, 1768 | check_same_thread=False, 1769 | isolation_level=None) 1770 | db = db_conn.cursor() 1771 | 1772 | db_query(db, '''CREATE TABLE IF NOT EXISTS pull ( 1773 | repo TEXT NOT NULL, 1774 | num INTEGER NOT NULL, 1775 | status TEXT NOT NULL, 1776 | merge_sha TEXT, 1777 | title TEXT, 1778 | body TEXT, 1779 | head_sha TEXT, 1780 | head_ref TEXT, 1781 | base_ref TEXT, 1782 | assignee TEXT, 1783 | approved_by TEXT, 1784 | priority INTEGER, 1785 | try_ INTEGER, 1786 | rollup INTEGER, 1787 | squash INTEGER, 1788 | delegate TEXT, 1789 | UNIQUE (repo, num) 1790 | )''') 1791 | 1792 | db_query(db, '''CREATE TABLE IF NOT EXISTS build_res ( 1793 | repo TEXT NOT NULL, 1794 | num INTEGER NOT NULL, 1795 | builder TEXT NOT NULL, 1796 | res INTEGER, 1797 | url TEXT NOT NULL, 1798 | merge_sha TEXT NOT NULL, 1799 | UNIQUE (repo, num, builder) 1800 | )''') 1801 | 1802 | db_query(db, '''CREATE TABLE IF NOT EXISTS mergeable ( 1803 | repo TEXT NOT NULL, 1804 | num INTEGER NOT NULL, 1805 | mergeable INTEGER NOT NULL, 1806 | UNIQUE (repo, num) 1807 | )''') 1808 | db_query(db, '''CREATE TABLE IF NOT EXISTS repos ( 1809 | repo TEXT NOT NULL, 1810 | treeclosed INTEGER NOT NULL, 1811 | treeclosed_src TEXT, 1812 | UNIQUE (repo) 1813 | )''') 1814 | 1815 | db_query(db, '''CREATE TABLE IF NOT EXISTS retry_log ( 1816 | repo TEXT NOT NULL, 1817 | num INTEGER NOT NULL, 1818 | time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 1819 | src TEXT NOT NULL, 1820 | msg TEXT NOT NULL 1821 | )''') 1822 | db_query(db, ''' 1823 | CREATE INDEX IF NOT EXISTS retry_log_time_index ON retry_log 1824 | (repo, time DESC) 1825 | ''') 1826 | 1827 | # manual DB migration :/ 1828 | try: 1829 | db_query(db, 'SELECT treeclosed_src FROM repos LIMIT 0') 1830 | except sqlite3.OperationalError: 1831 | db_query(db, 'ALTER TABLE repos ADD COLUMN treeclosed_src TEXT') 1832 | try: 1833 | db_query(db, 'SELECT squash FROM pull LIMIT 0') 1834 | except sqlite3.OperationalError: 1835 | db_query(db, 'ALTER TABLE pull ADD COLUMN squash INT') 1836 | 1837 | for repo_label, repo_cfg in cfg['repo'].items(): 1838 | repo_cfgs[repo_label] = repo_cfg 1839 | repo_labels[repo_cfg['owner'], repo_cfg['name']] = repo_label 1840 | 1841 | # If test-on-fork is enabled point both the main repo and the fork to 1842 | # the same homu "repository". This will allow events coming from both 1843 | # GitHub repositories to be processed the same way. 1844 | if 'test-on-fork' in repo_cfg: 1845 | tof = repo_cfg['test-on-fork'] 1846 | repo_labels[tof['owner'], tof['name']] = repo_label 1847 | 1848 | repo_states = {} 1849 | repos[repo_label] = Repository(None, repo_label, db) 1850 | 1851 | db_query( 1852 | db, 1853 | 'SELECT num, head_sha, status, title, body, head_ref, base_ref, assignee, approved_by, priority, try_, rollup, squash, delegate, merge_sha FROM pull WHERE repo = ?', # noqa 1854 | [repo_label]) 1855 | for num, head_sha, status, title, body, head_ref, base_ref, assignee, approved_by, priority, try_, rollup, squash, delegate, merge_sha in db.fetchall(): # noqa 1856 | state = PullReqState(num, head_sha, status, db, repo_label, mergeable_que, gh, repo_cfg['owner'], repo_cfg['name'], repo_cfg.get('labels', {}), repos, repo_cfg.get('test-on-fork')) # noqa 1857 | state.title = title 1858 | state.body = body 1859 | state.head_ref = head_ref 1860 | state.base_ref = base_ref 1861 | state.assignee = assignee 1862 | 1863 | state.approved_by = approved_by 1864 | state.priority = int(priority) 1865 | state.try_ = bool(try_) 1866 | state.rollup = rollup 1867 | state.squash = bool(squash) 1868 | state.delegate = delegate 1869 | builders = [] 1870 | if merge_sha: 1871 | if 'buildbot' in repo_cfg: 1872 | builders += repo_cfg['buildbot']['builders'] 1873 | if 'travis' in repo_cfg: 1874 | builders += ['travis'] 1875 | if 'status' in repo_cfg: 1876 | builders += ['status-' + key for key, value in repo_cfg['status'].items() if 'context' in value] # noqa 1877 | if 'checks' in repo_cfg: 1878 | builders += ['checks-' + key for key, value in repo_cfg['checks'].items() if 'name' in value] # noqa 1879 | if len(builders) == 0: 1880 | raise RuntimeError('Invalid configuration') 1881 | 1882 | state.init_build_res(builders, use_db=False) 1883 | state.merge_sha = merge_sha 1884 | 1885 | elif state.status == 'pending': 1886 | # FIXME: There might be a better solution 1887 | state.status = '' 1888 | 1889 | state.save() 1890 | 1891 | repo_states[num] = state 1892 | 1893 | states[repo_label] = repo_states 1894 | 1895 | db_query( 1896 | db, 1897 | 'SELECT repo, num, builder, res, url, merge_sha FROM build_res') 1898 | for repo_label, num, builder, res, url, merge_sha in db.fetchall(): 1899 | try: 1900 | state = states[repo_label][num] 1901 | if builder not in state.build_res: 1902 | raise KeyError 1903 | if state.merge_sha != merge_sha: 1904 | raise KeyError 1905 | except KeyError: 1906 | db_query( 1907 | db, 1908 | 'DELETE FROM build_res WHERE repo = ? AND num = ? AND builder = ?', # noqa 1909 | [repo_label, num, builder]) 1910 | continue 1911 | 1912 | state.build_res[builder] = { 1913 | 'res': bool(res) if res is not None else None, 1914 | 'url': url, 1915 | } 1916 | 1917 | db_query(db, 'SELECT repo, num, mergeable FROM mergeable') 1918 | for repo_label, num, mergeable in db.fetchall(): 1919 | try: 1920 | state = states[repo_label][num] 1921 | except KeyError: 1922 | db_query( 1923 | db, 1924 | 'DELETE FROM mergeable WHERE repo = ? AND num = ?', 1925 | [repo_label, num]) 1926 | continue 1927 | 1928 | state.mergeable = bool(mergeable) if mergeable is not None else None 1929 | 1930 | db_query(db, 'SELECT repo FROM pull GROUP BY repo') 1931 | for repo_label, in db.fetchall(): 1932 | if repo_label not in repos: 1933 | db_query(db, 'DELETE FROM pull WHERE repo = ?', [repo_label]) 1934 | 1935 | queue_handler_lock = Lock() 1936 | 1937 | def queue_handler(): 1938 | with queue_handler_lock: 1939 | return process_queue(states, repos, repo_cfgs, logger, buildbot_slots, db, git_cfg) # noqa 1940 | 1941 | os.environ['GIT_SSH'] = os.path.join(os.path.dirname(__file__), 'git_helper.py') # noqa 1942 | os.environ['GIT_EDITOR'] = 'cat' 1943 | 1944 | from . import server 1945 | Thread( 1946 | target=server.start, 1947 | args=[ 1948 | cfg, 1949 | states, 1950 | queue_handler, 1951 | repo_cfgs, 1952 | repos, 1953 | logger, 1954 | buildbot_slots, 1955 | my_username, 1956 | db, 1957 | repo_labels, 1958 | mergeable_que, 1959 | gh, 1960 | ]).start() 1961 | 1962 | Thread(target=fetch_mergeability, args=[mergeable_que]).start() 1963 | 1964 | queue_handler() 1965 | 1966 | 1967 | if __name__ == '__main__': 1968 | main() 1969 | -------------------------------------------------------------------------------- /homu/parse_issue_comment.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | import re 3 | 4 | WORDS_TO_ROLLUP = { 5 | 'rollup-': 0, 6 | 'rollup': 1, 7 | 'rollup=maybe': 0, 8 | 'rollup=never': -2, 9 | 'rollup=iffy': -1, 10 | 'rollup=always': 1, 11 | } 12 | 13 | 14 | class IssueCommentCommand: 15 | """ 16 | A command that has been parsed out of a GitHub issue comment. 17 | 18 | E.g., `@bors r+` => an issue command with action == 'approve' 19 | """ 20 | 21 | def __init__(self, action): 22 | self.action = action 23 | 24 | @classmethod 25 | def approve(cls, approver, commit): 26 | command = cls('approve') 27 | command.commit = commit 28 | command.actor = approver.lstrip('@') 29 | return command 30 | 31 | @classmethod 32 | def unapprove(cls): 33 | return cls('unapprove') 34 | 35 | @classmethod 36 | def prioritize(cls, priority): 37 | command = cls('prioritize') 38 | command.priority = priority 39 | return command 40 | 41 | @classmethod 42 | def delegate_author(cls): 43 | return cls('delegate-author') 44 | 45 | @classmethod 46 | def delegate(cls, delegate_to): 47 | command = cls('delegate') 48 | command.delegate_to = delegate_to.lstrip('@') 49 | return command 50 | 51 | @classmethod 52 | def undelegate(cls): 53 | return cls('undelegate') 54 | 55 | @classmethod 56 | def retry(cls): 57 | return cls('retry') 58 | 59 | @classmethod 60 | def try_(cls): 61 | return cls('try') 62 | 63 | @classmethod 64 | def untry(cls): 65 | return cls('untry') 66 | 67 | @classmethod 68 | def rollup(cls, rollup_value): 69 | command = cls('rollup') 70 | command.rollup_value = rollup_value 71 | return command 72 | 73 | @classmethod 74 | def squash(cls): 75 | return cls('squash') 76 | 77 | @classmethod 78 | def unsquash(cls): 79 | return cls('unsquash') 80 | 81 | @classmethod 82 | def force(cls): 83 | return cls('force') 84 | 85 | @classmethod 86 | def clean(cls): 87 | return cls('clean') 88 | 89 | @classmethod 90 | def ping(cls, ping_type='standard'): 91 | command = cls('ping') 92 | command.ping_type = ping_type 93 | return command 94 | 95 | @classmethod 96 | def treeclosed(cls, treeclosed_value): 97 | command = cls('treeclosed') 98 | command.treeclosed_value = treeclosed_value 99 | return command 100 | 101 | @classmethod 102 | def untreeclosed(cls): 103 | return cls('untreeclosed') 104 | 105 | @classmethod 106 | def hook(cls, hook_name, hook_extra=None): 107 | command = cls('hook') 108 | command.hook_name = hook_name 109 | command.hook_extra = hook_extra 110 | return command 111 | 112 | 113 | def is_sha(sha): 114 | """ 115 | Try to determine if the input is a git sha 116 | """ 117 | return re.match(r'^[0-9a-f]{4,}$', sha) 118 | 119 | 120 | def hook_with_extra_is_in_hooks(word, hooks): 121 | """ 122 | Determine if the word given is the name of a valid hook, with extra data 123 | hanging off of it (e.g., `validhookname=extradata`). 124 | 125 | hook_with_extra_is_in_hooks( 126 | 'validhookname=stuff', 127 | ['validhookname', 'other']) 128 | #=> True 129 | 130 | hook_with_extra_is_in_hooks( 131 | 'invalidhookname=stuff', 132 | ['validhookname', 'other']) 133 | #=> False 134 | 135 | hook_with_extra_is_in_hooks( 136 | 'validhookname', 137 | ['validhookname', 'other']) 138 | #=> False 139 | """ 140 | for hook in hooks: 141 | if word.startswith('{}='.format(hook)): 142 | return True 143 | 144 | return False 145 | 146 | 147 | def parse_issue_comment(username, body, sha, botname, hooks=[]): 148 | """ 149 | Parse an issue comment looking for commands that Homu should handle 150 | 151 | Parameters: 152 | username: the username of the user that created the issue comment. 153 | This is without the leading @ 154 | body: the full body of the comment (markdown) 155 | sha: the commit that the comment applies to 156 | botname: the name of bot. This is without the leading @. 157 | So if we should respond to `@bors {command}`, botname will be `bors` 158 | hooks: a list of strings that are valid hook names. 159 | E.g. `['hook1', 'hook2', 'hook3']` 160 | """ 161 | 162 | botname_regex = re.compile(r'^.*(?=@' + botname + ')') 163 | 164 | # All of the 'words' after and including the botname 165 | words = list(chain.from_iterable( 166 | re.findall(r'\S+', re.sub(botname_regex, '', x)) 167 | for x 168 | in body.splitlines() 169 | if '@' + botname in x and not x.lstrip().startswith('>'))) # noqa 170 | 171 | commands = [] 172 | 173 | if words[1:] == ["are", "you", "still", "there?"]: 174 | commands.append(IssueCommentCommand.ping('portal')) 175 | 176 | for i, word in enumerate(words): 177 | if word is None: 178 | # We already parsed the next word, and we set it to an empty string 179 | # to signify that we did. 180 | continue 181 | 182 | if word == '@' + botname: 183 | continue 184 | 185 | if word == '@' + botname + ':': 186 | continue 187 | 188 | if word == 'r+' or word.startswith('r='): 189 | approved_sha = sha 190 | 191 | if i + 1 < len(words) and is_sha(words[i + 1]): 192 | approved_sha = words[i + 1] 193 | words[i + 1] = None 194 | 195 | approver = word[len('r='):] if word.startswith('r=') else username 196 | 197 | # Ignore "r=me" 198 | if approver == 'me': 199 | continue 200 | 201 | commands.append( 202 | IssueCommentCommand.approve(approver, approved_sha)) 203 | 204 | elif word == 'r-': 205 | commands.append(IssueCommentCommand.unapprove()) 206 | 207 | elif word.startswith('p='): 208 | try: 209 | pvalue = int(word[len('p='):]) 210 | except ValueError: 211 | continue 212 | 213 | commands.append(IssueCommentCommand.prioritize(pvalue)) 214 | 215 | elif word.startswith('delegate='): 216 | delegate = word[len('delegate='):] 217 | commands.append(IssueCommentCommand.delegate(delegate)) 218 | 219 | elif word == 'delegate-': 220 | commands.append(IssueCommentCommand.undelegate()) 221 | 222 | elif word == 'delegate+': 223 | commands.append(IssueCommentCommand.delegate_author()) 224 | 225 | elif word == 'retry': 226 | commands.append(IssueCommentCommand.retry()) 227 | 228 | elif word == 'try': 229 | commands.append(IssueCommentCommand.try_()) 230 | 231 | elif word == 'try-': 232 | # Try- is broken, prevent its usage. 233 | # commands.append(IssueCommentCommand.untry()) 234 | pass 235 | 236 | elif word in WORDS_TO_ROLLUP: 237 | rollup_value = WORDS_TO_ROLLUP[word] 238 | commands.append(IssueCommentCommand.rollup(rollup_value)) 239 | 240 | elif word == 'squash': 241 | # Squash is broken, prevent its usage. 242 | # commands.append(IssueCommentCommand.squash()) 243 | pass 244 | 245 | elif word == 'squash-': 246 | # Squash is broken, prevent its usage. 247 | # commands.append(IssueCommentCommand.unsquash()) 248 | pass 249 | 250 | elif word == 'force': 251 | commands.append(IssueCommentCommand.force()) 252 | 253 | elif word == 'clean': 254 | commands.append(IssueCommentCommand.clean()) 255 | 256 | elif (word == 'hello?' or word == 'ping'): 257 | commands.append(IssueCommentCommand.ping()) 258 | 259 | elif word.startswith('treeclosed='): 260 | try: 261 | treeclosed = int(word[len('treeclosed='):]) 262 | commands.append(IssueCommentCommand.treeclosed(treeclosed)) 263 | except ValueError: 264 | pass 265 | 266 | elif word == 'treeclosed-': 267 | commands.append(IssueCommentCommand.untreeclosed()) 268 | 269 | elif word in hooks: 270 | commands.append(IssueCommentCommand.hook(word)) 271 | 272 | elif hook_with_extra_is_in_hooks(word, hooks): 273 | # word is like `somehook=data` and `somehook` is in our list of 274 | # potential hooks 275 | (hook_name, hook_extra) = word.split('=', 2) 276 | commands.append(IssueCommentCommand.hook(hook_name, hook_extra)) 277 | 278 | else: 279 | # First time we reach an unknown word, stop parsing. 280 | break 281 | 282 | return commands 283 | -------------------------------------------------------------------------------- /homu/server.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import json 3 | import urllib.parse 4 | from .main import ( 5 | PullReqState, 6 | parse_commands, 7 | db_query, 8 | IGNORE_BLOCK_END, 9 | IGNORE_BLOCK_START, 10 | INTERRUPTED_BY_HOMU_RE, 11 | suppress_ignore_block, 12 | suppress_pings, 13 | synchronize, 14 | LabelEvent, 15 | ) 16 | from . import comments 17 | from . import utils 18 | from .utils import lazy_debug 19 | import github3 20 | import jinja2 21 | import requests 22 | import pkg_resources 23 | from bottle import ( 24 | get, 25 | post, 26 | run, 27 | request, 28 | redirect, 29 | abort, 30 | response, 31 | error, 32 | ) 33 | from threading import Thread 34 | import sys 35 | import os 36 | import traceback 37 | from retrying import retry 38 | import random 39 | import string 40 | import time 41 | 42 | import bottle 43 | bottle.BaseRequest.MEMFILE_MAX = 1024 * 1024 * 10 44 | 45 | 46 | class G: 47 | pass 48 | 49 | 50 | g = G() 51 | 52 | 53 | ROLLUP_STR = { 54 | -2: 'never', 55 | -1: 'iffy', 56 | 0: '', 57 | 1: 'always', 58 | } 59 | 60 | 61 | def find_state(sha): 62 | for repo_label, repo_states in g.states.items(): 63 | for state in repo_states.values(): 64 | if state.merge_sha == sha: 65 | return state, repo_label 66 | 67 | raise ValueError('Invalid SHA') 68 | 69 | 70 | def get_repo(repo_label, repo_cfg): 71 | repo = g.repos[repo_label].gh 72 | if not repo: 73 | repo = g.gh.repository(repo_cfg['owner'], repo_cfg['name']) 74 | g.repos[repo_label].gh = repo 75 | assert repo.owner.login == repo_cfg['owner'] 76 | assert repo.name == repo_cfg['name'] 77 | return repo 78 | 79 | 80 | @get('/') 81 | def index(): 82 | return g.tpls['index'].render(repos=[g.repos[label] 83 | for label in sorted(g.repos)]) 84 | 85 | 86 | @get('/results//') 87 | def result(repo_label, pull): 88 | if repo_label not in g.states: 89 | abort(404, 'No such repository: {}'.format(repo_label)) 90 | states = [state for state in g.states[repo_label].values() 91 | if state.num == pull] 92 | if len(states) == 0: 93 | abort(404, 'No build results for pull request {}'.format(pull)) 94 | 95 | state = states[0] 96 | builders = [] 97 | repo_url = 'https://github.com/{}/{}'.format( 98 | g.cfg['repo'][repo_label]['owner'], 99 | g.cfg['repo'][repo_label]['name']) 100 | for (builder, data) in state.build_res.items(): 101 | result = "pending" 102 | if data['res'] is not None: 103 | result = "success" if data['res'] else "failed" 104 | 105 | builder_details = { 106 | 'result': result, 107 | 'name': builder, 108 | } 109 | 110 | if data['url']: 111 | builder_details['url'] = data['url'] 112 | 113 | builders.append(builder_details) 114 | 115 | return g.tpls['build_res'].render(repo_label=repo_label, repo_url=repo_url, 116 | builders=builders, pull=pull) 117 | 118 | 119 | @get('/queue/') 120 | def queue(repo_label): 121 | if repo_label not in g.cfg['repo'] and repo_label != 'all': 122 | abort(404) 123 | 124 | logger = g.logger.getChild('queue') 125 | 126 | lazy_debug(logger, lambda: 'repo_label: {}'.format(repo_label)) 127 | 128 | single_repo_closed = None 129 | treeclosed_src = None 130 | if repo_label == 'all': 131 | labels = g.repos.keys() 132 | multiple = True 133 | repo_url = None 134 | else: 135 | labels = repo_label.split('+') 136 | multiple = len(labels) > 1 137 | if repo_label in g.repos and g.repos[repo_label].treeclosed >= 0: 138 | single_repo_closed = g.repos[repo_label].treeclosed 139 | treeclosed_src = g.repos[repo_label].treeclosed_src 140 | repo_url = 'https://github.com/{}/{}'.format( 141 | g.cfg['repo'][repo_label]['owner'], 142 | g.cfg['repo'][repo_label]['name']) 143 | 144 | states = [] 145 | for label in labels: 146 | try: 147 | states += g.states[label].values() 148 | except KeyError: 149 | abort(404, 'No such repository: {}'.format(label)) 150 | 151 | prechecked_prs = set() 152 | if request.query.get('prs'): 153 | prechecked_prs = set(request.query.get('prs').split(',')) 154 | 155 | pull_states = sorted(states) 156 | rows = [] 157 | for state in pull_states: 158 | treeclosed = (single_repo_closed and 159 | state.priority < g.repos[state.repo_label].treeclosed) 160 | status_ext = '' 161 | 162 | if state.try_: 163 | status_ext += ' (try)' 164 | 165 | rows.append({ 166 | 'status': state.get_status(), 167 | 'status_ext': status_ext, 168 | 'priority': state.priority, 169 | 'rollup': ROLLUP_STR.get(state.rollup, ''), 170 | 'prechecked': str(state.num) in prechecked_prs, 171 | 'url': 'https://github.com/{}/{}/pull/{}'.format(state.owner, 172 | state.name, 173 | state.num), 174 | 'num': state.num, 175 | 'approved_by': state.approved_by, 176 | 'title': state.title, 177 | 'head_ref': state.head_ref, 178 | 'mergeable': ('yes' if state.mergeable is True else 179 | 'no' if state.mergeable is False else ''), 180 | 'assignee': state.assignee, 181 | 'repo_label': state.repo_label, 182 | 'repo_url': 'https://github.com/{}/{}'.format(state.owner, 183 | state.name), 184 | 'greyed': "treeclosed" if treeclosed else "", 185 | }) 186 | 187 | return g.tpls['queue'].render( 188 | repo_url=repo_url, 189 | repo_label=repo_label, 190 | treeclosed=single_repo_closed, 191 | treeclosed_src=treeclosed_src, 192 | states=rows, 193 | oauth_client_id=g.cfg['github']['app_client_id'], 194 | total=len(pull_states), 195 | approved=len([x for x in pull_states if x.approved_by]), 196 | rolled_up=len([x for x in pull_states if x.rollup > 0]), 197 | failed=len([x for x in pull_states if x.status == 'failure' or 198 | x.status == 'error']), 199 | multiple=multiple, 200 | ) 201 | 202 | 203 | @get('/retry_log/') 204 | def retry_log(repo_label): 205 | if repo_label not in g.cfg['repo']: 206 | abort(404) 207 | 208 | logger = g.logger.getChild('retry_log') 209 | 210 | lazy_debug(logger, lambda: 'repo_label: {}'.format(repo_label)) 211 | 212 | repo_url = 'https://github.com/{}/{}'.format( 213 | g.cfg['repo'][repo_label]['owner'], 214 | g.cfg['repo'][repo_label]['name'], 215 | ) 216 | 217 | db_query( 218 | g.db, 219 | ''' 220 | SELECT num, time, src, msg FROM retry_log 221 | WHERE repo = ? ORDER BY time DESC 222 | ''', 223 | [repo_label], 224 | ) 225 | logs = [ 226 | {'num': num, 'time': time, 'src': src, 'msg': msg} 227 | for num, time, src, msg in g.db.fetchall() 228 | ] 229 | 230 | return g.tpls['retry_log'].render( 231 | repo_url=repo_url, 232 | repo_label=repo_label, 233 | logs=logs, 234 | ) 235 | 236 | 237 | @get('/callback') 238 | def callback(): 239 | logger = g.logger.getChild('callback') 240 | 241 | response.content_type = 'text/plain' 242 | 243 | code = request.query.code 244 | state = json.loads(request.query.state) 245 | 246 | lazy_debug(logger, lambda: 'state: {}'.format(state)) 247 | oauth_url = 'https://github.com/login/oauth/access_token' 248 | 249 | try: 250 | res = requests.post(oauth_url, data={ 251 | 'client_id': g.cfg['github']['app_client_id'], 252 | 'client_secret': g.cfg['github']['app_client_secret'], 253 | 'code': code, 254 | }) 255 | except Exception as ex: 256 | logger.warn('/callback encountered an error ' 257 | 'during github oauth callback') 258 | lazy_debug( 259 | logger, 260 | lambda ex=ex: 'github oauth callback err: {}'.format(ex), 261 | ) 262 | abort(502, 'Bad Gateway') 263 | 264 | args = urllib.parse.parse_qs(res.text) 265 | token = args['access_token'][0] 266 | 267 | repo_label = state['repo_label'] 268 | repo_cfg = g.repo_cfgs[repo_label] 269 | repo = get_repo(repo_label, repo_cfg) 270 | 271 | user_gh = github3.login(token=token) 272 | 273 | if state['cmd'] == 'rollup': 274 | return rollup(user_gh, state, repo_label, repo_cfg, repo) 275 | elif state['cmd'] == 'synch': 276 | return synch(user_gh, state, repo_label, repo_cfg, repo) 277 | else: 278 | abort(400, 'Invalid command') 279 | 280 | 281 | def rollup(user_gh, state, repo_label, repo_cfg, repo): 282 | user_repo = user_gh.repository(user_gh.user().login, repo.name) 283 | if user_repo is None: 284 | return 'You must have a fork of rust-lang/rust named rust under your user account.' # noqa 285 | base_repo = user_gh.repository(repo.owner.login, repo.name) 286 | 287 | nums = state.get('nums', []) 288 | if nums: 289 | try: 290 | rollup_states = [g.states[repo_label][num] for num in nums] 291 | except KeyError as e: 292 | return 'Invalid PR number: {}'.format(e.args[0]) 293 | else: 294 | rollup_states = [x for x in g.states[repo_label].values() if x.rollup] 295 | rollup_states = [x for x in rollup_states if x.approved_by] 296 | rollup_states.sort(key=lambda x: x.num) 297 | 298 | if not rollup_states: 299 | return 'No pull requests are marked as rollup' 300 | 301 | base_ref = rollup_states[0].base_ref 302 | 303 | base_sha = repo.ref('heads/' + base_ref).object.sha 304 | branch_name = 'rollup-' + ''.join( 305 | random.choice(string.digits + string.ascii_lowercase) for _ in range(7) 306 | ) 307 | utils.github_set_ref( 308 | user_repo, 309 | 'heads/' + branch_name, 310 | base_sha, 311 | force=True, 312 | ) 313 | 314 | successes = [] 315 | failures = [] 316 | 317 | for state in rollup_states: 318 | if base_ref != state.base_ref: 319 | failures.append(state) 320 | continue 321 | 322 | state.body = suppress_pings(state.body or "") 323 | state.body = suppress_ignore_block(state.body) 324 | 325 | merge_msg = 'Rollup merge of #{} - {}, r={}\n\n{}\n\n{}'.format( 326 | state.num, 327 | state.head_ref, 328 | state.approved_by, 329 | state.title, 330 | state.body, 331 | ) 332 | 333 | try: 334 | user_repo.merge(branch_name, state.head_sha, merge_msg) 335 | except github3.models.GitHubError as e: 336 | if e.code != 409: 337 | raise 338 | 339 | failures.append(state) 340 | else: 341 | successes.append(state) 342 | 343 | title = 'Rollup of {} pull requests'.format(len(successes)) 344 | 345 | body = 'Successful merges:\n\n' 346 | for x in successes: 347 | body += ' - #{} ({})\n'.format(x.num, x.title) 348 | 349 | if len(failures) != 0: 350 | body += '\nFailed merges:\n\n' 351 | for x in failures: 352 | body += ' - #{} ({})\n'.format(x.num, x.title) 353 | body += '\nr? @ghost\n@rustbot modify labels: rollup' 354 | 355 | # Set web.base_url in cfg to enable 356 | base_url = g.cfg['web'].get('base_url') 357 | if not base_url: 358 | # If web.base_url is not present, fall back to using web.canonical_url 359 | base_url = g.cfg['web'].get('canonical_url') 360 | 361 | if base_url: 362 | pr_list = ','.join(str(x.num) for x in successes) 363 | link = '{}/queue/{}?prs={}'.format(base_url, repo_label, pr_list) 364 | body += '\n' 365 | body += IGNORE_BLOCK_START 366 | body += '\n[Create a similar rollup]({})\n'.format(link) 367 | body += IGNORE_BLOCK_END 368 | 369 | try: 370 | pull = base_repo.create_pull( 371 | title, 372 | state.base_ref, 373 | user_repo.owner.login + ':' + branch_name, 374 | body, 375 | ) 376 | except github3.models.GitHubError as e: 377 | return e.response.text 378 | else: 379 | redirect(pull.html_url) 380 | 381 | 382 | @post('/github') 383 | def github(): 384 | logger = g.logger.getChild('github') 385 | 386 | response.content_type = 'text/plain' 387 | 388 | payload = request.body.read() 389 | info = request.json 390 | 391 | lazy_debug(logger, lambda: 'info: {}'.format(utils.remove_url_keys_from_json(info))) # noqa 392 | 393 | owner_info = info['repository']['owner'] 394 | owner = owner_info.get('login') or owner_info['name'] 395 | repo_label = g.repo_labels[owner, info['repository']['name']] 396 | repo_cfg = g.repo_cfgs[repo_label] 397 | 398 | hmac_method, hmac_sig = request.headers['X-Hub-Signature'].split('=') 399 | if hmac_sig != hmac.new( 400 | repo_cfg['github']['secret'].encode('utf-8'), 401 | payload, 402 | hmac_method, 403 | ).hexdigest(): 404 | abort(400, 'Invalid signature') 405 | 406 | event_type = request.headers['X-Github-Event'] 407 | 408 | if event_type == 'pull_request_review_comment': 409 | action = info['action'] 410 | original_commit_id = info['comment']['original_commit_id'] 411 | head_sha = info['pull_request']['head']['sha'] 412 | 413 | if action == 'created' and original_commit_id == head_sha: 414 | pull_num = info['pull_request']['number'] 415 | body = info['comment']['body'] 416 | username = info['sender']['login'] 417 | user_id = info['sender']['id'] 418 | 419 | state = g.states[repo_label].get(pull_num) 420 | if state: 421 | state.title = info['pull_request']['title'] 422 | state.body = info['pull_request']['body'] 423 | 424 | if parse_commands( 425 | body, 426 | username, 427 | user_id, 428 | repo_label, 429 | repo_cfg, 430 | state, 431 | g.my_username, 432 | g.db, 433 | g.states, 434 | realtime=True, 435 | sha=original_commit_id, 436 | command_src=info['comment']['html_url'], 437 | ): 438 | state.save() 439 | 440 | g.queue_handler() 441 | 442 | elif event_type == 'pull_request': 443 | action = info['action'] 444 | pull_num = info['number'] 445 | head_sha = info['pull_request']['head']['sha'] 446 | 447 | if action == 'synchronize': 448 | state = g.states[repo_label][pull_num] 449 | state.head_advanced(head_sha) 450 | 451 | state.save() 452 | 453 | elif action in ['opened', 'reopened']: 454 | state = PullReqState(pull_num, head_sha, '', g.db, repo_label, 455 | g.mergeable_que, g.gh, 456 | info['repository']['owner']['login'], 457 | info['repository']['name'], 458 | repo_cfg.get('labels', {}), 459 | g.repos, 460 | repo_cfg.get('test-on-fork')) 461 | state.title = info['pull_request']['title'] 462 | state.body = info['pull_request']['body'] 463 | state.head_ref = info['pull_request']['head']['repo']['owner']['login'] + ':' + info['pull_request']['head']['ref'] # noqa 464 | state.base_ref = info['pull_request']['base']['ref'] 465 | state.set_mergeable(info['pull_request']['mergeable']) 466 | state.assignee = (info['pull_request']['assignee']['login'] if 467 | info['pull_request']['assignee'] else '') 468 | 469 | found = False 470 | 471 | if action == 'reopened': 472 | # FIXME: Review comments are ignored here 473 | for c in state.get_repo().issue(pull_num).iter_comments(): 474 | found = parse_commands( 475 | c.body, 476 | c.user.login, 477 | c.user.id, 478 | repo_label, 479 | repo_cfg, 480 | state, 481 | g.my_username, 482 | g.db, 483 | g.states, 484 | command_src=c.to_json()['html_url'], 485 | # FIXME switch to `c.html_url` 486 | # after updating github3 to 1.3.0+ 487 | ) or found 488 | 489 | status = '' 490 | for info in utils.github_iter_statuses(state.get_repo(), 491 | state.head_sha): 492 | if info.context == 'homu': 493 | status = info.state 494 | break 495 | 496 | state.set_status(status) 497 | 498 | state.save() 499 | 500 | g.states[repo_label][pull_num] = state 501 | 502 | if found: 503 | g.queue_handler() 504 | 505 | elif action == 'closed': 506 | state = g.states[repo_label][pull_num] 507 | if hasattr(state, 'fake_merge_sha'): 508 | def inner(): 509 | utils.github_set_ref( 510 | state.get_repo(), 511 | 'heads/' + state.base_ref, 512 | state.merge_sha, 513 | force=True, 514 | ) 515 | 516 | def fail(err): 517 | state.add_comment(':boom: Failed to recover from the ' 518 | 'artificial commit. See {} for details.' 519 | ' ({})'.format(state.fake_merge_sha, 520 | err)) 521 | 522 | utils.retry_until(inner, fail, state) 523 | 524 | del g.states[repo_label][pull_num] 525 | 526 | db_query(g.db, 'DELETE FROM pull WHERE repo = ? AND num = ?', 527 | [repo_label, pull_num]) 528 | db_query(g.db, 'DELETE FROM build_res WHERE repo = ? AND num = ?', 529 | [repo_label, pull_num]) 530 | db_query(g.db, 'DELETE FROM mergeable WHERE repo = ? AND num = ?', 531 | [repo_label, pull_num]) 532 | 533 | g.queue_handler() 534 | 535 | elif action in ['assigned', 'unassigned']: 536 | state = g.states[repo_label][pull_num] 537 | state.assignee = (info['pull_request']['assignee']['login'] if 538 | info['pull_request']['assignee'] else '') 539 | 540 | state.save() 541 | 542 | elif action == 'edited': 543 | state = g.states[repo_label][pull_num] 544 | 545 | base_ref = info['pull_request']['base']['ref'] 546 | if state.base_ref != base_ref: 547 | state.base_ref = base_ref 548 | state.set_mergeable(None) 549 | # Remove PR approval when the branch changes, to prevent the PR 550 | # authors to merge the changes on other branches 551 | if state.get_status() != '': 552 | state.approved_by = '' 553 | state.set_status('') 554 | state.change_labels(LabelEvent.PUSHED) 555 | state.add_comment( 556 | ':warning: The base branch changed to `{}`, and the ' 557 | 'PR will need to be re-approved.\n\n' 558 | ''.format(base_ref, g.my_username) 559 | ) 560 | 561 | state.title = info['pull_request']['title'] 562 | state.body = info['pull_request']['body'] 563 | 564 | state.save() 565 | 566 | else: 567 | lazy_debug(logger, lambda: 'Invalid pull_request action: {}'.format(action)) # noqa 568 | 569 | elif event_type == 'push': 570 | ref = info['ref'][len('refs/heads/'):] 571 | 572 | for state in list(g.states[repo_label].values()): 573 | if state.base_ref == ref: 574 | state.set_mergeable(None, cause={ 575 | 'sha': info['head_commit']['id'], 576 | 'title': info['head_commit']['message'].splitlines()[0], 577 | }) 578 | 579 | if state.head_sha == info['before']: 580 | if state.status: 581 | state.change_labels(LabelEvent.PUSHED) 582 | state.head_advanced(info['after']) 583 | 584 | state.save() 585 | 586 | elif event_type == 'issue_comment': 587 | action = info['action'] 588 | body = info['comment']['body'] 589 | username = info['comment']['user']['login'] 590 | user_id = info['comment']['user']['id'] 591 | pull_num = info['issue']['number'] 592 | 593 | state = g.states[repo_label].get(pull_num) 594 | 595 | if action == 'created' and 'pull_request' in info['issue'] and state: 596 | state.title = info['issue']['title'] 597 | state.body = info['issue']['body'] 598 | 599 | if parse_commands( 600 | body, 601 | username, 602 | user_id, 603 | repo_label, 604 | repo_cfg, 605 | state, 606 | g.my_username, 607 | g.db, 608 | g.states, 609 | realtime=True, 610 | command_src=info['comment']['html_url'], 611 | ): 612 | state.save() 613 | 614 | g.queue_handler() 615 | 616 | elif event_type == 'status': 617 | try: 618 | state, repo_label = find_state(info['sha']) 619 | except ValueError: 620 | return 'OK' 621 | 622 | status_name = "" 623 | if 'status' in repo_cfg: 624 | for name, value in repo_cfg['status'].items(): 625 | if 'context' in value and value['context'] == info['context']: 626 | status_name = name 627 | if status_name == "": 628 | return 'OK' 629 | 630 | if info['state'] == 'pending': 631 | return 'OK' 632 | 633 | for row in info['branches']: 634 | if row['name'] == state.base_ref: 635 | return 'OK' 636 | 637 | report_build_res(info['state'] == 'success', info['target_url'], 638 | 'status-' + status_name, state, logger, repo_cfg) 639 | 640 | elif event_type == 'check_run': 641 | try: 642 | state, repo_label = find_state(info['check_run']['head_sha']) 643 | except ValueError: 644 | return 'OK' 645 | 646 | current_run_name = info['check_run']['name'] 647 | checks_name = None 648 | if 'checks' in repo_cfg: 649 | for name, value in repo_cfg['checks'].items(): 650 | if state.try_ and 'try_name' in value: 651 | if value['try_name'] == current_run_name: 652 | checks_name = name 653 | elif 'name' in value and value['name'] == current_run_name: 654 | checks_name = name 655 | if checks_name is None: 656 | return 'OK' 657 | 658 | if info['check_run']['status'] != 'completed': 659 | return 'OK' 660 | if info['check_run']['conclusion'] is None: 661 | return 'OK' 662 | # GHA marks jobs as skipped, if they are not run due to the job 663 | # condition. This prevents bors from failing because of these jobs. 664 | if info['check_run']['conclusion'] == 'skipped': 665 | return 'OK' 666 | 667 | report_build_res( 668 | info['check_run']['conclusion'] == 'success', 669 | info['check_run']['details_url'], 670 | 'checks-' + checks_name, 671 | state, logger, repo_cfg, 672 | ) 673 | 674 | return 'OK' 675 | 676 | 677 | def report_build_res(succ, url, builder, state, logger, repo_cfg): 678 | lazy_debug(logger, 679 | lambda: 'build result {}: builder = {}, succ = {}, current build_res = {}' # noqa 680 | .format(state, builder, succ, 681 | state.build_res_summary())) 682 | 683 | state.set_build_res(builder, succ, url) 684 | 685 | if succ: 686 | if all(x['res'] for x in state.build_res.values()): 687 | state.set_status('success') 688 | utils.github_create_status( 689 | state.get_repo(), state.head_sha, 690 | 'success', url, "Test successful", context='homu' 691 | ) 692 | 693 | if state.approved_by and not state.try_: 694 | # The set_ref call below sometimes fails with 422 failed to 695 | # fast forward. We believe this is a spurious error on GitHub's 696 | # side, though it's not entirely clear why. We sleep for 1 697 | # minute before trying it after setting the status to try to 698 | # increase the likelihood it will work, and also retry the 699 | # set_ref a few times. 700 | time.sleep(60) 701 | state.add_comment(comments.BuildCompleted( 702 | approved_by=state.approved_by, 703 | base_ref=state.base_ref, 704 | builders={k: v["url"] for k, v in state.build_res.items()}, 705 | merge_sha=state.merge_sha, 706 | )) 707 | state.change_labels(LabelEvent.SUCCEED) 708 | 709 | def set_ref_inner(): 710 | utils.github_set_ref(state.get_repo(), 'heads/' + 711 | state.base_ref, state.merge_sha) 712 | if state.test_on_fork is not None: 713 | utils.github_set_ref(state.get_test_on_fork_repo(), 714 | 'heads/' + state.base_ref, 715 | state.merge_sha, force=True) 716 | 717 | def set_ref(): 718 | try: 719 | set_ref_inner() 720 | except github3.models.GitHubError: 721 | utils.github_create_status( 722 | state.get_repo(), 723 | state.merge_sha, 724 | 'success', '', 725 | 'Branch protection bypassed', 726 | context='homu') 727 | set_ref_inner() 728 | 729 | error = None 730 | for i in range(0, 5): 731 | try: 732 | set_ref() 733 | state.fake_merge(repo_cfg) 734 | error = None 735 | except github3.models.GitHubError as e: 736 | error = e 737 | pass 738 | if error is None: 739 | break 740 | else: 741 | time.sleep(10) 742 | 743 | if error is not None: 744 | state.set_status('error') 745 | desc = ('Test was successful, but fast-forwarding failed:' 746 | ' {}'.format(error)) 747 | utils.github_create_status(state.get_repo(), 748 | state.head_sha, 'error', url, 749 | desc, context='homu') 750 | 751 | state.add_comment(':eyes: ' + desc) 752 | else: 753 | state.add_comment(comments.TryBuildCompleted( 754 | builders={k: v["url"] for k, v in state.build_res.items()}, 755 | merge_sha=state.merge_sha, 756 | )) 757 | state.change_labels(LabelEvent.TRY_SUCCEED) 758 | 759 | else: 760 | if state.status == 'pending': 761 | state.set_status('failure') 762 | utils.github_create_status( 763 | state.get_repo(), state.head_sha, 764 | 'failure', url, "Test failed", context='homu' 765 | ) 766 | 767 | if state.try_: 768 | state.add_comment(comments.TryBuildFailed( 769 | builder_url=url, 770 | builder_name=builder, 771 | )) 772 | state.change_labels(LabelEvent.TRY_FAILED) 773 | else: 774 | state.add_comment(comments.BuildFailed( 775 | builder_url=url, 776 | builder_name=builder, 777 | )) 778 | state.change_labels(LabelEvent.FAILED) 779 | 780 | g.queue_handler() 781 | 782 | 783 | @post('/buildbot') 784 | def buildbot(): 785 | logger = g.logger.getChild('buildbot') 786 | 787 | response.content_type = 'text/plain' 788 | 789 | for row in json.loads(request.forms.packets): 790 | if row['event'] == 'buildFinished': 791 | info = row['payload']['build'] 792 | lazy_debug(logger, lambda: 'info: {}'.format(info)) 793 | props = dict(x[:2] for x in info['properties']) 794 | 795 | if 'retry' in info['text']: 796 | continue 797 | 798 | if not props['revision']: 799 | continue 800 | 801 | try: 802 | state, repo_label = find_state(props['revision']) 803 | except ValueError: 804 | lazy_debug(logger, 805 | lambda: 'Invalid commit ID from Buildbot: {}'.format(props['revision'])) # noqa 806 | continue 807 | 808 | lazy_debug(logger, lambda: 'state: {}, {}'.format(state, state.build_res_summary())) # noqa 809 | 810 | if info['builderName'] not in state.build_res: 811 | lazy_debug(logger, 812 | lambda: 'Invalid builder from Buildbot: {}'.format(info['builderName'])) # noqa 813 | continue 814 | 815 | repo_cfg = g.repo_cfgs[repo_label] 816 | 817 | if request.forms.secret != repo_cfg['buildbot']['secret']: 818 | abort(400, 'Invalid secret') 819 | 820 | build_succ = 'successful' in info['text'] or info['results'] == 0 821 | 822 | url = '{}/builders/{}/builds/{}'.format( 823 | repo_cfg['buildbot']['url'], 824 | info['builderName'], 825 | props['buildnumber'], 826 | ) 827 | 828 | if 'interrupted' in info['text']: 829 | step_name = '' 830 | for step in reversed(info['steps']): 831 | if 'interrupted' in step.get('text', []): 832 | step_name = step['name'] 833 | break 834 | 835 | if step_name: 836 | try: 837 | url = ('{}/builders/{}/builds/{}/steps/{}/logs/interrupt' # noqa 838 | ).format(repo_cfg['buildbot']['url'], 839 | info['builderName'], 840 | props['buildnumber'], 841 | step_name,) 842 | res = requests.get(url) 843 | except Exception as ex: 844 | logger.warn('/buildbot encountered an error during ' 845 | 'github logs request') 846 | lazy_debug( 847 | logger, 848 | lambda ex=ex: 'buildbot logs err: {}'.format(ex), 849 | ) 850 | abort(502, 'Bad Gateway') 851 | 852 | mat = INTERRUPTED_BY_HOMU_RE.search(res.text) 853 | if mat: 854 | interrupt_token = mat.group(1) 855 | if getattr(state, 'interrupt_token', 856 | '') != interrupt_token: 857 | state.interrupt_token = interrupt_token 858 | 859 | if state.status == 'pending': 860 | state.set_status('') 861 | 862 | desc = (':snowman: The build was interrupted ' 863 | 'to prioritize another pull request.') 864 | state.add_comment(desc) 865 | state.change_labels(LabelEvent.INTERRUPTED) 866 | utils.github_create_status(state.get_repo(), 867 | state.head_sha, 868 | 'error', url, 869 | desc, 870 | context='homu') 871 | 872 | g.queue_handler() 873 | 874 | continue 875 | 876 | else: 877 | logger.error('Corrupt payload from Buildbot') 878 | 879 | report_build_res(build_succ, url, info['builderName'], 880 | state, logger, repo_cfg) 881 | 882 | elif row['event'] == 'buildStarted': 883 | info = row['payload']['build'] 884 | lazy_debug(logger, lambda: 'info: {}'.format(info)) 885 | props = dict(x[:2] for x in info['properties']) 886 | 887 | if not props['revision']: 888 | continue 889 | 890 | try: 891 | state, repo_label = find_state(props['revision']) 892 | except ValueError: 893 | pass 894 | else: 895 | if info['builderName'] in state.build_res: 896 | repo_cfg = g.repo_cfgs[repo_label] 897 | 898 | if request.forms.secret != repo_cfg['buildbot']['secret']: 899 | abort(400, 'Invalid secret') 900 | 901 | url = '{}/builders/{}/builds/{}'.format( 902 | repo_cfg['buildbot']['url'], 903 | info['builderName'], 904 | props['buildnumber'], 905 | ) 906 | 907 | state.set_build_res(info['builderName'], None, url) 908 | 909 | if g.buildbot_slots[0] == props['revision']: 910 | g.buildbot_slots[0] = '' 911 | 912 | g.queue_handler() 913 | 914 | return 'OK' 915 | 916 | 917 | @get('/assets/') 918 | def server_static(file): 919 | current_path = os.path.dirname(__file__) 920 | return bottle.static_file(file, root=os.path.join(current_path, 'assets')) 921 | 922 | 923 | def synch(user_gh, state, repo_label, repo_cfg, repo): 924 | try: 925 | if not repo.is_collaborator(user_gh.user().login): 926 | abort(400, 'You are not a collaborator') 927 | except github3.GitHubError as e: 928 | if e.code == 403: 929 | abort(400, 'Homu does not have write access on the repository') 930 | raise e 931 | 932 | Thread(target=synchronize, args=[repo_label, repo_cfg, g.logger, 933 | g.gh, g.states, g.repos, g.db, 934 | g.mergeable_que, g.my_username, 935 | g.repo_labels]).start() 936 | 937 | return 'Synchronizing {}...'.format(repo_label) 938 | 939 | 940 | def synch_all(): 941 | @retry(wait_exponential_multiplier=1000, wait_exponential_max=600000) 942 | def sync_repo(repo_label, g): 943 | try: 944 | synchronize(repo_label, g.repo_cfgs[repo_label], g.logger, g.gh, 945 | g.states, g.repos, g.db, g.mergeable_que, 946 | g.my_username, g.repo_labels) 947 | except Exception: 948 | print('* Error while synchronizing {}'.format(repo_label)) 949 | traceback.print_exc() 950 | raise 951 | 952 | for repo_label in g.repos: 953 | sync_repo(repo_label, g) 954 | print('* Done synchronizing all') 955 | 956 | 957 | @post('/admin') 958 | def admin(): 959 | if request.json['secret'] != g.cfg['web']['secret']: 960 | return 'Authentication failure' 961 | 962 | if request.json['cmd'] == 'repo_new': 963 | repo_label = request.json['repo_label'] 964 | repo_cfg = request.json['repo_cfg'] 965 | 966 | g.states[repo_label] = {} 967 | g.repos[repo_label] = None 968 | g.repo_cfgs[repo_label] = repo_cfg 969 | g.repo_labels[repo_cfg['owner'], repo_cfg['name']] = repo_label 970 | 971 | Thread(target=synchronize, args=[repo_label, repo_cfg, g.logger, 972 | g.gh, g.states, g.repos, g.db, 973 | g.mergeable_que, g.my_username, 974 | g.repo_labels]).start() 975 | return 'OK' 976 | 977 | elif request.json['cmd'] == 'repo_del': 978 | repo_label = request.json['repo_label'] 979 | repo_cfg = g.repo_cfgs[repo_label] 980 | 981 | db_query(g.db, 'DELETE FROM pull WHERE repo = ?', [repo_label]) 982 | db_query(g.db, 'DELETE FROM build_res WHERE repo = ?', [repo_label]) 983 | db_query(g.db, 'DELETE FROM mergeable WHERE repo = ?', [repo_label]) 984 | 985 | del g.states[repo_label] 986 | del g.repos[repo_label] 987 | del g.repo_cfgs[repo_label] 988 | del g.repo_labels[repo_cfg['owner'], repo_cfg['name']] 989 | 990 | return 'OK' 991 | 992 | elif request.json['cmd'] == 'repo_edit': 993 | repo_label = request.json['repo_label'] 994 | repo_cfg = request.json['repo_cfg'] 995 | 996 | assert repo_cfg['owner'] == g.repo_cfgs[repo_label]['owner'] 997 | assert repo_cfg['name'] == g.repo_cfgs[repo_label]['name'] 998 | 999 | g.repo_cfgs[repo_label] = repo_cfg 1000 | 1001 | return 'OK' 1002 | 1003 | elif request.json['cmd'] == 'sync_all': 1004 | Thread(target=synch_all).start() 1005 | 1006 | return 'OK' 1007 | 1008 | return 'Unrecognized command' 1009 | 1010 | 1011 | @get('/health') 1012 | def health(): 1013 | return 'OK' 1014 | 1015 | 1016 | @error(404) 1017 | def not_found(error): 1018 | return g.tpls['404'].render() 1019 | 1020 | 1021 | def redirect_to_canonical_host(): 1022 | request_url = urllib.parse.urlparse(request.url) 1023 | redirect_url = request_url 1024 | 1025 | # Assume that we're always deployed behind something that hides https:// 1026 | # from us. In production TLS is terminated at ELB, so the actual bors app 1027 | # sees only http:// requests. 1028 | request_url = redirect_url._replace( 1029 | scheme="https" 1030 | ) 1031 | 1032 | # Disable redirects on the health check endpoint. 1033 | if request_url.path == "/health": 1034 | return 1035 | 1036 | # Handle hostname changes 1037 | if "canonical_url" in g.cfg["web"]: 1038 | canonical_url = urllib.parse.urlparse(g.cfg["web"]["canonical_url"]) 1039 | redirect_url = redirect_url._replace( 1040 | scheme=canonical_url.scheme, 1041 | netloc=canonical_url.netloc, 1042 | ) 1043 | 1044 | # Handle path changes 1045 | for prefix in g.cfg["web"].get("remove_path_prefixes", []): 1046 | if redirect_url.path.startswith("/" + prefix + "/"): 1047 | new_path = redirect_url.path[len(prefix)+1:] 1048 | redirect_url = redirect_url._replace(path=new_path) 1049 | elif redirect_url.path == "/" + prefix: 1050 | redirect_url = redirect_url._replace(path="/") 1051 | 1052 | if request_url != redirect_url: 1053 | print("redirecting original=" + request_url.geturl() + " to new=" + redirect_url.geturl()) # noqa 1054 | redirect(urllib.parse.urlunparse(redirect_url), 301) 1055 | 1056 | 1057 | def start(cfg, states, queue_handler, repo_cfgs, repos, logger, 1058 | buildbot_slots, my_username, db, repo_labels, mergeable_que, gh): 1059 | env = jinja2.Environment( 1060 | loader=jinja2.FileSystemLoader(pkg_resources.resource_filename(__name__, 'html')), # noqa 1061 | autoescape=True, 1062 | ) 1063 | env.globals["announcement"] = cfg["web"].get("announcement") 1064 | tpls = {} 1065 | tpls['index'] = env.get_template('index.html') 1066 | tpls['queue'] = env.get_template('queue.html') 1067 | tpls['build_res'] = env.get_template('build_res.html') 1068 | tpls['retry_log'] = env.get_template('retry_log.html') 1069 | tpls['404'] = env.get_template('404.html') 1070 | 1071 | g.cfg = cfg 1072 | g.states = states 1073 | g.queue_handler = queue_handler 1074 | g.repo_cfgs = repo_cfgs 1075 | g.repos = repos 1076 | g.logger = logger.getChild('server') 1077 | g.buildbot_slots = buildbot_slots 1078 | g.tpls = tpls 1079 | g.my_username = my_username 1080 | g.db = db 1081 | g.repo_labels = repo_labels 1082 | g.mergeable_que = mergeable_que 1083 | g.gh = gh 1084 | 1085 | bottle.app().add_hook("before_request", redirect_to_canonical_host) 1086 | 1087 | # Synchronize all PR data on startup 1088 | if cfg['web'].get('sync_on_start', False): 1089 | Thread(target=synch_all).start() 1090 | 1091 | try: 1092 | run(host=cfg['web'].get('host', '0.0.0.0'), 1093 | port=cfg['web']['port'], 1094 | server='waitress') 1095 | except OSError as e: 1096 | print(e, file=sys.stderr) 1097 | os._exit(1) 1098 | -------------------------------------------------------------------------------- /homu/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-lang/homu/e3fbfe2d063707772c5dffac8c0a3e21c2da2977/homu/tests/__init__.py -------------------------------------------------------------------------------- /homu/tests/test_parse_issue_comment.py: -------------------------------------------------------------------------------- 1 | from homu.parse_issue_comment import parse_issue_comment 2 | 3 | # Random commit number. Just so that we don't need to come up with a new one 4 | # for every test. 5 | commit = "5ffafdb1e94fa87334d4851a57564425e11a569e" 6 | other_commit = "4e4c9ddd781729173df2720d83e0f4d1b0102a94" 7 | 8 | 9 | def test_r_plus(): 10 | """ 11 | @bors r+ 12 | """ 13 | 14 | author = "jack" 15 | body = "@bors r+" 16 | commands = parse_issue_comment(author, body, commit, "bors") 17 | 18 | assert len(commands) == 1 19 | command = commands[0] 20 | assert command.action == 'approve' 21 | assert command.actor == 'jack' 22 | 23 | 24 | def test_r_plus_with_colon(): 25 | """ 26 | @bors: r+ 27 | """ 28 | 29 | author = "jack" 30 | body = "@bors: r+" 31 | commands = parse_issue_comment(author, body, commit, "bors") 32 | 33 | assert len(commands) == 1 34 | command = commands[0] 35 | assert command.action == 'approve' 36 | assert command.actor == 'jack' 37 | assert command.commit == commit 38 | 39 | 40 | def test_r_plus_with_sha(): 41 | """ 42 | @bors r+ {sha} 43 | """ 44 | 45 | author = "jack" 46 | body = "@bors r+ {}".format(other_commit) 47 | commands = parse_issue_comment(author, body, commit, "bors") 48 | 49 | assert len(commands) == 1 50 | command = commands[0] 51 | assert command.action == 'approve' 52 | assert command.actor == 'jack' 53 | assert command.commit == other_commit 54 | 55 | 56 | def test_r_equals(): 57 | """ 58 | @bors r=jill 59 | """ 60 | 61 | author = "jack" 62 | body = "@bors r=jill" 63 | commands = parse_issue_comment(author, body, commit, "bors") 64 | 65 | assert len(commands) == 1 66 | command = commands[0] 67 | assert command.action == 'approve' 68 | assert command.actor == 'jill' 69 | 70 | 71 | def test_r_equals_at_user(): 72 | """ 73 | @bors r=@jill 74 | """ 75 | 76 | author = "jack" 77 | body = "@bors r=@jill" 78 | commands = parse_issue_comment(author, body, commit, "bors") 79 | 80 | assert len(commands) == 1 81 | command = commands[0] 82 | assert command.action == 'approve' 83 | assert command.actor == 'jill' 84 | 85 | 86 | def test_hidden_r_equals(): 87 | author = "bors" 88 | body = """ 89 | :pushpin: Commit {0} has been approved by `jack` 90 | It is now in the [queue]({1}) for this repository.\n\n 91 | 92 | """.format(commit, "rust") 93 | 94 | commands = parse_issue_comment(author, body, commit, "bors") 95 | 96 | assert len(commands) == 1 97 | command = commands[0] 98 | assert command.action == 'approve' 99 | assert command.actor == 'jack' 100 | assert command.commit == commit 101 | 102 | 103 | def test_r_me(): 104 | """ 105 | Ignore r=me 106 | """ 107 | 108 | author = "jack" 109 | body = "@bors r=me" 110 | commands = parse_issue_comment(author, body, commit, "bors") 111 | 112 | # r=me is not a valid command, so no valid commands. 113 | assert len(commands) == 0 114 | 115 | 116 | def test_r_minus(): 117 | """ 118 | @bors r- 119 | """ 120 | 121 | author = "jack" 122 | body = "@bors r-" 123 | commands = parse_issue_comment(author, body, commit, "bors") 124 | 125 | assert len(commands) == 1 126 | command = commands[0] 127 | assert command.action == 'unapprove' 128 | 129 | 130 | def test_priority(): 131 | """ 132 | @bors p=5 133 | """ 134 | 135 | author = "jack" 136 | body = "@bors p=5" 137 | commands = parse_issue_comment(author, body, commit, "bors") 138 | 139 | assert len(commands) == 1 140 | command = commands[0] 141 | assert command.action == 'prioritize' 142 | assert command.priority == 5 143 | 144 | 145 | def test_approve_and_priority(): 146 | """ 147 | @bors r+ p=5 148 | """ 149 | 150 | author = "jack" 151 | body = "@bors r+ p=5" 152 | commands = parse_issue_comment(author, body, commit, "bors") 153 | 154 | assert len(commands) == 2 155 | approve_commands = [command for command in commands 156 | if command.action == 'approve'] 157 | prioritize_commands = [command for command in commands 158 | if command.action == 'prioritize'] 159 | assert len(approve_commands) == 1 160 | assert len(prioritize_commands) == 1 161 | 162 | assert approve_commands[0].actor == 'jack' 163 | assert prioritize_commands[0].priority == 5 164 | 165 | 166 | def test_approve_specific_and_priority(): 167 | """ 168 | @bors r+ {sha} p=5 169 | """ 170 | 171 | author = "jack" 172 | body = "@bors r+ {} p=5".format(other_commit) 173 | commands = parse_issue_comment(author, body, commit, "bors") 174 | 175 | assert len(commands) == 2 176 | approve_commands = [command for command in commands 177 | if command.action == 'approve'] 178 | prioritize_commands = [command for command in commands 179 | if command.action == 'prioritize'] 180 | assert len(approve_commands) == 1 181 | assert len(prioritize_commands) == 1 182 | 183 | assert approve_commands[0].actor == 'jack' 184 | assert approve_commands[0].commit == other_commit 185 | assert prioritize_commands[0].priority == 5 186 | 187 | 188 | def test_delegate_plus(): 189 | """ 190 | @bors delegate+ 191 | """ 192 | 193 | author = "jack" 194 | body = "@bors delegate+" 195 | commands = parse_issue_comment(author, body, commit, "bors") 196 | 197 | assert len(commands) == 1 198 | command = commands[0] 199 | assert command.action == 'delegate-author' 200 | 201 | 202 | def test_delegate_equals(): 203 | """ 204 | @bors delegate={username} 205 | """ 206 | 207 | author = "jack" 208 | body = "@bors delegate=jill" 209 | commands = parse_issue_comment(author, body, commit, "bors") 210 | 211 | assert len(commands) == 1 212 | command = commands[0] 213 | assert command.action == 'delegate' 214 | assert command.delegate_to == 'jill' 215 | 216 | 217 | def test_delegate_equals_at_user(): 218 | """ 219 | @bors delegate=@{username} 220 | """ 221 | 222 | author = "jack" 223 | body = "@bors delegate=@jill" 224 | commands = parse_issue_comment(author, body, commit, "bors") 225 | 226 | assert len(commands) == 1 227 | command = commands[0] 228 | assert command.action == 'delegate' 229 | assert command.delegate_to == 'jill' 230 | 231 | 232 | def test_delegate_minus(): 233 | """ 234 | @bors delegate- 235 | """ 236 | 237 | author = "jack" 238 | body = "@bors delegate-" 239 | commands = parse_issue_comment(author, body, commit, "bors") 240 | 241 | assert len(commands) == 1 242 | command = commands[0] 243 | assert command.action == 'undelegate' 244 | 245 | 246 | def test_retry(): 247 | """ 248 | @bors retry 249 | """ 250 | 251 | author = "jack" 252 | body = "@bors retry" 253 | commands = parse_issue_comment(author, body, commit, "bors") 254 | 255 | assert len(commands) == 1 256 | command = commands[0] 257 | assert command.action == 'retry' 258 | 259 | 260 | def test_try(): 261 | """ 262 | @bors try 263 | """ 264 | 265 | author = "jack" 266 | body = "@bors try" 267 | commands = parse_issue_comment(author, body, commit, "bors") 268 | 269 | assert len(commands) == 1 270 | command = commands[0] 271 | assert command.action == 'try' 272 | 273 | 274 | def test_try_minus(): 275 | """ 276 | @bors try- 277 | """ 278 | 279 | author = "jack" 280 | body = "@bors try-" 281 | commands = parse_issue_comment(author, body, commit, "bors") 282 | 283 | assert len(commands) == 0 284 | 285 | 286 | def test_rollup(): 287 | """ 288 | @bors rollup 289 | """ 290 | 291 | author = "jack" 292 | body = "@bors rollup" 293 | commands = parse_issue_comment(author, body, commit, "bors") 294 | 295 | assert len(commands) == 1 296 | command = commands[0] 297 | assert command.action == 'rollup' 298 | assert command.rollup_value == 1 299 | 300 | 301 | def test_rollup_minus(): 302 | """ 303 | @bors rollup- 304 | """ 305 | 306 | author = "jack" 307 | body = "@bors rollup-" 308 | commands = parse_issue_comment(author, body, commit, "bors") 309 | 310 | assert len(commands) == 1 311 | command = commands[0] 312 | assert command.action == 'rollup' 313 | assert command.rollup_value == 0 314 | 315 | 316 | def test_rollup_iffy(): 317 | """ 318 | @bors rollup=iffy 319 | """ 320 | 321 | author = "manishearth" 322 | body = "@bors rollup=iffy" 323 | commands = parse_issue_comment(author, body, commit, "bors") 324 | 325 | assert len(commands) == 1 326 | command = commands[0] 327 | assert command.action == 'rollup' 328 | assert command.rollup_value == -1 329 | 330 | 331 | def test_rollup_never(): 332 | """ 333 | @bors rollup=never 334 | """ 335 | 336 | author = "jack" 337 | body = "@bors rollup=never" 338 | commands = parse_issue_comment(author, body, commit, "bors") 339 | 340 | assert len(commands) == 1 341 | command = commands[0] 342 | assert command.action == 'rollup' 343 | assert command.rollup_value == -2 344 | 345 | 346 | def test_rollup_maybe(): 347 | """ 348 | @bors rollup=maybe 349 | """ 350 | 351 | author = "jack" 352 | body = "@bors rollup=maybe" 353 | commands = parse_issue_comment(author, body, commit, "bors") 354 | 355 | assert len(commands) == 1 356 | command = commands[0] 357 | assert command.action == 'rollup' 358 | assert command.rollup_value == 0 359 | 360 | 361 | def test_rollup_always(): 362 | """ 363 | @bors rollup=always 364 | """ 365 | 366 | author = "jack" 367 | body = "@bors rollup=always" 368 | commands = parse_issue_comment(author, body, commit, "bors") 369 | 370 | assert len(commands) == 1 371 | command = commands[0] 372 | assert command.action == 'rollup' 373 | assert command.rollup_value == 1 374 | 375 | 376 | def test_force(): 377 | """ 378 | @bors force 379 | """ 380 | 381 | author = "jack" 382 | body = "@bors force" 383 | commands = parse_issue_comment(author, body, commit, "bors") 384 | 385 | assert len(commands) == 1 386 | command = commands[0] 387 | assert command.action == 'force' 388 | 389 | 390 | def test_clean(): 391 | """ 392 | @bors clean 393 | """ 394 | 395 | author = "jack" 396 | body = "@bors clean" 397 | commands = parse_issue_comment(author, body, commit, "bors") 398 | 399 | assert len(commands) == 1 400 | command = commands[0] 401 | assert command.action == 'clean' 402 | 403 | 404 | def test_ping(): 405 | """ 406 | @bors ping 407 | """ 408 | 409 | author = "jack" 410 | body = "@bors ping" 411 | commands = parse_issue_comment(author, body, commit, "bors") 412 | 413 | assert len(commands) == 1 414 | command = commands[0] 415 | assert command.action == 'ping' 416 | assert command.ping_type == 'standard' 417 | 418 | 419 | def test_hello(): 420 | """ 421 | @bors hello? 422 | """ 423 | 424 | author = "jack" 425 | body = "@bors hello?" 426 | commands = parse_issue_comment(author, body, commit, "bors") 427 | 428 | assert len(commands) == 1 429 | command = commands[0] 430 | assert command.action == 'ping' 431 | assert command.ping_type == 'standard' 432 | 433 | 434 | def test_portal_ping(): 435 | """ 436 | @bors are you still there? 437 | """ 438 | 439 | author = "jack" 440 | body = "@bors are you still there?" 441 | commands = parse_issue_comment(author, body, commit, "bors") 442 | 443 | assert len(commands) == 1 444 | command = commands[0] 445 | assert command.action == 'ping' 446 | assert command.ping_type == 'portal' 447 | 448 | 449 | def test_treeclosed(): 450 | """ 451 | @bors treeclosed=50 452 | """ 453 | 454 | author = "jack" 455 | body = "@bors treeclosed=50" 456 | commands = parse_issue_comment(author, body, commit, "bors") 457 | 458 | assert len(commands) == 1 459 | command = commands[0] 460 | assert command.action == 'treeclosed' 461 | assert command.treeclosed_value == 50 462 | 463 | 464 | def test_treeclosed_minus(): 465 | """ 466 | @bors treeclosed- 467 | """ 468 | 469 | author = "jack" 470 | body = "@bors treeclosed-" 471 | commands = parse_issue_comment(author, body, commit, "bors") 472 | 473 | assert len(commands) == 1 474 | command = commands[0] 475 | assert command.action == 'untreeclosed' 476 | 477 | 478 | def test_hook(): 479 | """ 480 | Test hooks that are defined in the configuration 481 | 482 | @bors secondhook 483 | """ 484 | 485 | author = "jack" 486 | body = "@bors secondhook" 487 | commands = parse_issue_comment( 488 | author, body, commit, "bors", 489 | ['firsthook', 'secondhook', 'thirdhook']) 490 | 491 | assert len(commands) == 1 492 | command = commands[0] 493 | assert command.action == 'hook' 494 | assert command.hook_name == 'secondhook' 495 | assert command.hook_extra is None 496 | 497 | 498 | def test_hook_equals(): 499 | """ 500 | Test hooks that are defined in the configuration 501 | 502 | @bors secondhook=extra 503 | """ 504 | 505 | author = "jack" 506 | body = "@bors secondhook=extra" 507 | commands = parse_issue_comment( 508 | author, body, commit, "bors", 509 | ['firsthook', 'secondhook', 'thirdhook']) 510 | 511 | assert len(commands) == 1 512 | command = commands[0] 513 | assert command.action == 'hook' 514 | assert command.hook_name == 'secondhook' 515 | assert command.hook_extra == 'extra' 516 | 517 | 518 | def test_multiple_hooks(): 519 | """ 520 | Test hooks that are defined in the configuration 521 | 522 | @bors thirdhook secondhook=extra 523 | """ 524 | 525 | author = "jack" 526 | body = "@bors thirdhook secondhook=extra" 527 | commands = parse_issue_comment( 528 | author, body, commit, "bors", 529 | ['firsthook', 'secondhook', 'thirdhook']) 530 | 531 | assert len(commands) == 2 532 | secondhook_commands = [command for command in commands 533 | if command.action == 'hook' 534 | and command.hook_name == 'secondhook'] 535 | thirdhook_commands = [command for command in commands 536 | if command.action == 'hook' 537 | and command.hook_name == 'thirdhook'] 538 | assert len(secondhook_commands) == 1 539 | assert len(thirdhook_commands) == 1 540 | assert secondhook_commands[0].hook_extra == 'extra' 541 | assert thirdhook_commands[0].hook_extra is None 542 | 543 | 544 | def test_similar_name(): 545 | """ 546 | Test that a username that starts with 'bors' doesn't trigger. 547 | """ 548 | 549 | author = "jack" 550 | body = """ 551 | @bors-servo r+ 552 | """ 553 | commands = parse_issue_comment(author, body, commit, "bors") 554 | 555 | assert len(commands) == 0 556 | 557 | 558 | def test_parse_up_to_first_unknown_word(): 559 | """ 560 | Test that when parsing, once we arrive at an unknown word, we stop parsing 561 | """ 562 | 563 | author = "jack" 564 | body = """ 565 | @bors retry -- yielding priority to the rollup 566 | """ 567 | commands = parse_issue_comment(author, body, commit, "bors") 568 | 569 | assert len(commands) == 1 570 | command = commands[0] 571 | assert command.action == 'retry' 572 | 573 | body = """ 574 | @bors retry (yielding priority to the rollup) 575 | """ 576 | commands = parse_issue_comment(author, body, commit, "bors") 577 | 578 | assert len(commands) == 1 579 | command = commands[0] 580 | assert command.action == 'retry' 581 | 582 | 583 | def test_ignore_commands_before_bors_line(): 584 | """ 585 | Test that when command-like statements appear before the @bors part, 586 | they don't get parsed 587 | """ 588 | 589 | author = "jack" 590 | body = """ 591 | A sentence that includes command-like statements, like r- or ping or delegate+ or the like. 592 | 593 | @bors r+ 594 | """ # noqa 595 | commands = parse_issue_comment(author, body, commit, "bors") 596 | 597 | assert len(commands) == 1 598 | command = commands[0] 599 | assert command.action == 'approve' 600 | assert command.actor == 'jack' 601 | 602 | 603 | def test_ignore_commands_after_bors_line(): 604 | """ 605 | Test that when command-like statements appear after the @bors part, 606 | they don't get parsed 607 | """ 608 | 609 | author = "jack" 610 | body = """ 611 | @bors r+ 612 | 613 | A sentence that includes command-like statements, like r- or ping or delegate+ or the like. 614 | """ # noqa 615 | commands = parse_issue_comment(author, body, commit, "bors") 616 | 617 | assert len(commands) == 1 618 | command = commands[0] 619 | assert command.action == 'approve' 620 | assert command.actor == 'jack' 621 | 622 | 623 | def test_in_quote(): 624 | """ 625 | Test that a command in a quote (e.g. when replying by e-mail) doesn't 626 | trigger. 627 | """ 628 | 629 | author = "jack" 630 | body = """ 631 | > @bors r+ 632 | """ 633 | commands = parse_issue_comment(author, body, commit, "bors") 634 | 635 | assert len(commands) == 0 636 | -------------------------------------------------------------------------------- /homu/tests/test_pr_body.py: -------------------------------------------------------------------------------- 1 | from homu.main import ( 2 | suppress_ignore_block, 3 | suppress_pings, 4 | IGNORE_BLOCK_START, 5 | IGNORE_BLOCK_END, 6 | ) 7 | 8 | 9 | def test_suppress_pings_in_PR_body(): 10 | body = ( 11 | "r? @matklad\n" # should escape 12 | "@bors r+\n" # shouldn't 13 | "mail@example.com" # shouldn't 14 | ) 15 | 16 | expect = ( 17 | "r? `@matklad`\n" 18 | "`@bors` r+\n" 19 | "mail@example.com" 20 | ) 21 | 22 | assert suppress_pings(body) == expect 23 | 24 | 25 | def test_suppress_ignore_block_in_PR_body(): 26 | body = ( 27 | "Rollup merge\n" 28 | "{}\n" 29 | "[Create a similar rollup](https://fake.xyz/?prs=1,2,3)\n" 30 | "{}" 31 | ) 32 | 33 | body = body.format(IGNORE_BLOCK_START, IGNORE_BLOCK_END) 34 | 35 | expect = "Rollup merge\n" 36 | 37 | assert suppress_ignore_block(body) == expect 38 | -------------------------------------------------------------------------------- /homu/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import github3 3 | import logging 4 | import subprocess 5 | import sys 6 | import traceback 7 | import requests 8 | import time 9 | 10 | 11 | def github_set_ref(repo, ref, sha, *, force=False, auto_create=True, retry=1): 12 | url = repo._build_url('git', 'refs', ref, base_url=repo._api) 13 | data = {'sha': sha, 'force': force} 14 | 15 | try: 16 | js = repo._json(repo._patch(url, data=json.dumps(data)), 200) 17 | except github3.models.GitHubError as e: 18 | if e.code == 422 and auto_create: 19 | try: 20 | return repo.create_ref('refs/' + ref, sha) 21 | except github3.models.GitHubError: 22 | raise e 23 | elif e.code == 422 and retry > 0: 24 | time.sleep(5) 25 | return github_set_ref(repo, 26 | ref, 27 | sha, 28 | force=force, 29 | auto_create=auto_create, 30 | retry=retry - 1) 31 | else: 32 | raise 33 | 34 | return github3.git.Reference(js, repo) if js else None 35 | 36 | 37 | class Status(github3.repos.status.Status): 38 | def __init__(self, info): 39 | super(Status, self).__init__(info) 40 | 41 | self.context = info.get('context') 42 | 43 | 44 | def github_iter_statuses(repo, sha): 45 | url = repo._build_url('statuses', sha, base_url=repo._api) 46 | return repo._iter(-1, url, Status) 47 | 48 | 49 | def github_create_status(repo, sha, state, target_url='', description='', *, 50 | context=''): 51 | data = {'state': state, 'target_url': target_url, 52 | 'description': description, 'context': context} 53 | url = repo._build_url('statuses', sha, base_url=repo._api) 54 | js = repo._json(repo._post(url, data=data), 201) 55 | return Status(js) if js else None 56 | 57 | 58 | def remove_url_keys_from_json(json): 59 | if isinstance(json, dict): 60 | return {key: remove_url_keys_from_json(value) 61 | for key, value in json.items() 62 | if not key.endswith('url')} 63 | elif isinstance(json, list): 64 | return [remove_url_keys_from_json(value) for value in json] 65 | else: 66 | return json 67 | 68 | 69 | def lazy_debug(logger, f): 70 | if logger.isEnabledFor(logging.DEBUG): 71 | logger.debug(f()) 72 | 73 | 74 | def logged_call(args): 75 | try: 76 | subprocess.check_call(args, stdout=subprocess.DEVNULL, stderr=None) 77 | except subprocess.CalledProcessError: 78 | print('* Failed to execute command: {}'.format(args)) 79 | raise 80 | 81 | 82 | def silent_call(args): 83 | return subprocess.call( 84 | args, 85 | stdout=subprocess.DEVNULL, 86 | stderr=subprocess.DEVNULL, 87 | ) 88 | 89 | 90 | def retry_until(inner, fail, state): 91 | err = None 92 | exc_info = None 93 | 94 | for i in range(3, 0, -1): 95 | try: 96 | inner() 97 | except (github3.models.GitHubError, requests.exceptions.RequestException) as e: # noqa 98 | print('* Intermittent GitHub error: {}'.format(e), file=sys.stderr) 99 | 100 | err = e 101 | exc_info = sys.exc_info() 102 | 103 | if i != 1: 104 | time.sleep(1) 105 | else: 106 | err = None 107 | break 108 | 109 | if err: 110 | print('* GitHub failure in {}'.format(state), file=sys.stderr) 111 | traceback.print_exception(*exc_info) 112 | 113 | fail(err) 114 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bottle==0.12.25 2 | certifi==2024.2.2 3 | charset-normalizer==3.3.2 4 | github3.py==0.9.6 5 | idna==3.6 6 | Jinja2==3.1.3 7 | MarkupSafe==2.1.5 8 | pip==20.0.2 9 | pluggy==1.5.0 10 | requests==2.31.0 11 | retrying==1.3.4 12 | setuptools==45.2.0 13 | six==1.16.0 14 | toml==0.10.2 15 | uritemplate==4.1.1 16 | uritemplate.py==3.0.2 17 | urllib3==2.2.1 18 | waitress==3.0.0 19 | wheel==0.34.2 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='homu', 5 | version='0.3.0', 6 | author='Barosl Lee', 7 | url='https://github.com/barosl/homu', 8 | test_suite='homu.tests', 9 | description=('A bot that integrates with GitHub ' 10 | 'and your favorite continuous integration service'), 11 | 12 | packages=['homu'], 13 | install_requires=[ 14 | 'github3.py==0.9.6', 15 | 'toml', 16 | 'Jinja2', 17 | 'requests', 18 | 'bottle', 19 | 'waitress', 20 | 'retrying', 21 | ], 22 | setup_requires=[ 23 | 'pytest-runner<8', 24 | ], 25 | tests_require=[ 26 | 'pytest<8', 27 | ], 28 | package_data={ 29 | 'homu': [ 30 | 'html/*.html', 31 | 'assets/*', 32 | ], 33 | }, 34 | entry_points={ 35 | 'console_scripts': [ 36 | 'homu=homu.main:main', 37 | ], 38 | }, 39 | zip_safe=False, 40 | ) 41 | --------------------------------------------------------------------------------