├── .github └── workflows │ └── main.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── collaborators.ini ├── config.sample ├── eventhandler.py ├── handlers ├── easy_info │ ├── __init__.py │ └── tests │ │ ├── assign_already_assigned.json │ │ ├── assign_new.json │ │ ├── label_easy.json │ │ └── label_not_easy.json ├── empty_title_element │ ├── __init__.py │ └── tests │ │ └── new_pr.json ├── homu_status │ ├── __init__.py │ └── tests │ │ ├── json │ │ └── builders │ │ │ ├── test_builder_result.json │ │ │ └── test_stdio │ │ │ └── text │ │ ├── merge_approved.json │ │ ├── merge_conflict.json │ │ ├── post_retry.json │ │ └── tests_failed.json ├── label_watchers │ ├── __init__.py │ ├── tests │ │ ├── dont_annoy.json │ │ ├── dont_label_sender.json │ │ ├── dont_notify_creator.json │ │ ├── new_pr.json │ │ └── not_watched.json │ └── watchers.ini ├── missing_test │ ├── __init__.py │ └── tests │ │ ├── new_pr.json │ │ └── test_binding.json ├── no_modify_css_tests │ ├── __init__.py │ └── tests │ │ └── new_pr.json ├── nonini_wpt_meta │ ├── __init__.py │ └── tests │ │ ├── new_issue.json │ │ └── new_pr.json ├── status_update │ ├── __init__.py │ ├── labels.ini │ └── tests │ │ ├── custom_labels.json │ │ ├── dequeued.json │ │ ├── dequeued_unmergeable.json │ │ ├── enqueued.json │ │ ├── merge.json │ │ ├── new_pr.json │ │ ├── new_pr_draft.json │ │ ├── new_pr_unmergeable.json │ │ ├── pr_ready_for_review.json │ │ ├── review_approved.json │ │ ├── review_changes.json │ │ ├── synchronize.json │ │ ├── synchronize_changes.json │ │ ├── synchronize_draft.json │ │ ├── synchronize_mergeable.json │ │ ├── synchronize_not_mergeable.json │ │ └── unmergeable_pr_ready_for_review.json ├── unsafe │ ├── __init__.py │ └── tests │ │ └── new_pr.json └── watchers │ ├── __init__.py │ ├── tests │ ├── new_pr_author_is_not_watcher.json │ ├── new_pr_author_is_watcher.json │ ├── new_pr_wildcards_author_is_not_watcher.json │ └── new_pr_wildcards_author_is_watcher.json │ └── watchers.ini ├── helpers.py ├── json_cleanup.py ├── newpr.py ├── requirements.txt └── test.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | types: ['opened', 'synchronize'] 8 | branches: ["**"] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v5 16 | - name: Setup Python 17 | uses: actions/setup-python@v6 18 | - name: Install requirements 19 | run: pip install flake8 legacy-cgi 20 | - name: flake8 21 | run: flake8 . 22 | - name: tests 23 | run: python test.py 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | script: 5 | - flake8 . 6 | - python test.py 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Highfive 2 | ======== 3 | 4 | GitHub hooks to provide an encouraging atmosphere for new contributors. 5 | 6 | Docs for the highfive instance for servo/servo repository live [on the Servo 7 | wiki](https://github.com/servo/servo/wiki/Highfive). 8 | 9 | ## Design 10 | 11 | Highfive is built as a modular, loosely-coupled set of handlers for Github 12 | API events. Each time an API event is processed, each handler is given the 13 | opportunity to respond to it, either by making direct API calls (such as 14 | manipulating PR labels) or using cross-handler features such as logging a 15 | warning (which are aggregated at the end and posted as a single comment). 16 | 17 | ## Testing 18 | 19 | Per-handler tests can be run using `python test.py`. These consist of 20 | a set of JSON documents collected from the `tests/` subdirectory of 21 | each handler, using the following format: 22 | 23 | ```js 24 | { 25 | "initial": { 26 | // Initial state of the PR before any handlers process the payload. 27 | "labels": [], 28 | "diff": "", 29 | "new_contributor": false, 30 | "assignee": null 31 | }, 32 | "expected": { 33 | // Expected state of the PR after all the handlers process the following payload. 34 | // Only fields in this object will be checked. Example fields are shown below. 35 | "comments": 5, 36 | "labels": ["S-awaiting-review"], 37 | "assignee": "jdm" 38 | }, 39 | "payload": { 40 | // Github API event payload in JSON format. 41 | } 42 | } 43 | ``` 44 | 45 | Each test runs with a mock Github API provider, so no account information 46 | or network connection is required to run the test suite. 47 | 48 | ## Enabling a repo 49 | 50 | Visit the repo's webhook settings page at 51 | `https://github.com/org/repo/settings/hooks`. 52 | 53 | Create a new webhook, pointing at your highfive instance's location: 54 | 55 | Payload URL: `http://99.88.777.666/highfive/newpr.py` 56 | Content type: `application/x-www-form-urlencoded` 57 | Leave the 'secret' field blank. 58 | Let me select individual events: Issue Comment, Pull Request, Status 59 | Check the box by 'Active' 60 | 61 | Add the bot's github account as a Collaborator to the repo with Write access. 62 | If automatically assigning a reviewer is desired for the repo, add a new section 63 | to the highfive instance's `collaborators.ini` with the list of desired reviewers: 64 | 65 | ``` 66 | [user/repo] 67 | reviewer_name = 68 | another_reviewer_name = 69 | ``` 70 | 71 | ## Configuring a Highfive 72 | 73 | Copy `config.sample` to `config`. Add the username of the account that will be 74 | commenting as highfive. When logged into that account, visit 75 | `https://github.com/settings/tokens` and create a token with the `public_repo` 76 | permission. 77 | 78 | Add that access token's value to the `token` field of the config. 79 | -------------------------------------------------------------------------------- /collaborators.ini: -------------------------------------------------------------------------------- 1 | [servo/servo-ignored] 2 | asajeffrey = 3 | jdm = 4 | Manishearth = 5 | nox = 6 | ferjm = 7 | SimonSapin = 8 | 9 | [servo/ipc-channel] 10 | emilio = 11 | jdm = 12 | 13 | [servo/highfive] 14 | jdm = 15 | Wafflespeanut = 16 | 17 | [servo/test-repo] 18 | highfive = 19 | 20 | [servo/test-repo2] 21 | highfive = 22 | highfive2 = 23 | -------------------------------------------------------------------------------- /config.sample: -------------------------------------------------------------------------------- 1 | [github] 2 | user=highfive 3 | token=abc123xyz789 4 | -------------------------------------------------------------------------------- /eventhandler.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from helpers import linear_search 3 | 4 | import importlib.util 5 | import os 6 | import sys 7 | 8 | _warnings = [] 9 | _payload_actions = { 10 | 'opened': 'on_pr_opened', 11 | 'synchronize': 'on_pr_updated', 12 | 'created': 'on_new_comment', 13 | 'closed': 'on_pr_closed', 14 | 'labeled': 'on_issue_labeled', 15 | 'enqueued': 'on_pr_enqueued', 16 | 'dequeued': 'on_pr_dequeued', 17 | 'ready_for_review': 'on_pr_ready_for_review', 18 | 'submitted': 'on_review_submitted', 19 | } 20 | 21 | 22 | class EventHandler: 23 | def on_pr_opened(self, api, payload): 24 | pass 25 | 26 | def on_pr_updated(self, api, payload): 27 | pass 28 | 29 | def on_new_comment(self, api, payload): 30 | pass 31 | 32 | def on_pr_closed(self, api, payload): 33 | pass 34 | 35 | def on_pr_enqueued(self, api, payload): 36 | pass 37 | 38 | def on_pr_dequeued(self, api, payload): 39 | pass 40 | 41 | def on_pr_ready_for_review(self, api, payload): 42 | pass 43 | 44 | def on_issue_labeled(self, api, payload): 45 | pass 46 | 47 | def on_review_submitted(self, api, payload): 48 | pass 49 | 50 | def handle_payload(self, api, payload): 51 | def callback(action): 52 | if "pull_request" in payload and payload["pull_request"]["draft"] == True: # noqa 53 | return 54 | getattr(self, _payload_actions[action])(api, payload) 55 | payload_action = payload['action'] 56 | linear_search(_payload_actions, payload_action, callback) 57 | 58 | def warn(self, msg): 59 | global _warnings 60 | _warnings += [msg] 61 | 62 | def is_open_pr(self, payload): 63 | return (payload['issue']['state'] == 'open' and 64 | 'pull_request' in payload['issue']) 65 | 66 | 67 | def reset_test_state(): 68 | global _warnings 69 | _warnings = [] 70 | 71 | 72 | def get_warnings(): 73 | return _warnings 74 | 75 | 76 | def get_handlers(): 77 | modules = [] 78 | handlers = [] 79 | possible_handlers = os.listdir('handlers') 80 | for i in possible_handlers: 81 | location = os.path.join('handlers', i, "__init__.py") 82 | abs_location = os.path.join(os.path.dirname(__file__), location) 83 | spec = importlib.util.spec_from_file_location(i, abs_location) 84 | if spec is None: 85 | raise ImportError( 86 | f"Could not load spec for module '{i}' at: {abs_location}" 87 | ) 88 | module = importlib.util.module_from_spec(spec) 89 | sys.modules[i] = module 90 | spec.loader.exec_module(module) 91 | handlers.append(module.handler_interface()) 92 | modules.append((module, location)) 93 | return (modules, handlers) 94 | -------------------------------------------------------------------------------- /handlers/easy_info/__init__.py: -------------------------------------------------------------------------------- 1 | from eventhandler import EventHandler 2 | 3 | import re 4 | 5 | ASSIGN_MSG = 'assign me' 6 | 7 | MSG = ('Hi! If you have any questions regarding this issue, feel free to make' 8 | ' a comment here, or ask it in ' 9 | '[Zulip](https://servo.zulipchat.com/).\n\n' 10 | 'If you intend to work on this issue, then add `@%s: %s`' 11 | ' to your comment, and I\'ll assign this to you. :smile:') 12 | 13 | RESPONSE_FAIL = ('It looks like this has already been assigned to someone.' 14 | ' I\'ll leave the decision to a core contributor.') 15 | 16 | RESPONSE_OK = ('Hey @%s! Thanks for your interest in working on this issue.' 17 | ' It\'s now assigned to you!') 18 | 19 | 20 | class EasyInfoHandler(EventHandler): 21 | def on_issue_labeled(self, api, payload): 22 | if payload['label']['name'].lower() == 'e-easy': 23 | api.post_comment(MSG % (api.user, ASSIGN_MSG)) 24 | 25 | def on_new_comment(self, api, payload): 26 | if payload['issue']['state'] != 'open': 27 | return 28 | 29 | user = payload['comment']['user']['login'] 30 | if user == api.user: # ignore comments from self 31 | return # (since `MSG` already has `ASSIGN_MSG`) 32 | 33 | msg = payload['comment']['body'] 34 | 35 | if re.search(r'@%s[: ]*%s' % (api.user, ASSIGN_MSG), str(msg)): 36 | labels = payload['issue']['labels'] 37 | if any(label['name'] == 'C-assigned' for label in labels): 38 | api.post_comment(RESPONSE_FAIL) 39 | return 40 | 41 | api.add_label('C-assigned') 42 | api.set_assignee(user) 43 | api.post_comment(RESPONSE_OK % user) 44 | 45 | 46 | handler_interface = EasyInfoHandler 47 | -------------------------------------------------------------------------------- /handlers/easy_info/tests/assign_already_assigned.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 1, 4 | "labels": [ 5 | "C-assigned" 6 | ] 7 | }, 8 | "initial": { 9 | "labels": [ 10 | "C-assigned" 11 | ] 12 | }, 13 | "payload": { 14 | "comment": { 15 | "body": "@highfive: assign me", 16 | "user": { 17 | "login": "someone" 18 | } 19 | }, 20 | "repository": { 21 | "owner": { 22 | "login": "servo" 23 | }, 24 | "name": "servo" 25 | }, 26 | "action": "created", 27 | "issue": { 28 | "number": 7075, 29 | "state": "open", 30 | "labels": [ 31 | { 32 | "name": "C-assigned" 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /handlers/easy_info/tests/assign_new.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 1, 4 | "labels": [ 5 | "C-assigned" 6 | ] 7 | }, 8 | "initial": {}, 9 | "payload": { 10 | "comment": { 11 | "body": "@highfive: assign me", 12 | "user": { 13 | "login": "someone" 14 | } 15 | }, 16 | "repository": { 17 | "owner": { 18 | "login": "servo" 19 | }, 20 | "name": "servo" 21 | }, 22 | "action": "created", 23 | "issue": { 24 | "number": 7075, 25 | "state": "open", 26 | "labels": [] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /handlers/easy_info/tests/label_easy.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial": {}, 3 | "expected": { 4 | "comments": 1 5 | }, 6 | "payload": { 7 | "repository": { 8 | "owner": { 9 | "login": "servo" 10 | }, 11 | "name": "highfive" 12 | }, 13 | "label": { 14 | "name": "E-Easy" 15 | }, 16 | "action": "labeled", 17 | "issue": { 18 | "number": 130 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /handlers/easy_info/tests/label_not_easy.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial": {}, 3 | "expected": { 4 | "comments": 0 5 | }, 6 | "payload": { 7 | "repository": { 8 | "owner": { 9 | "login": "servo" 10 | }, 11 | "name": "highfive" 12 | }, 13 | "label": { 14 | "name": "unrelated-label" 15 | }, 16 | "action": "labeled", 17 | "issue": { 18 | "number": 130 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /handlers/empty_title_element/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from eventhandler import EventHandler 3 | 4 | WARNING = ("These commits include an empty title element (``). " 5 | "Consider adding appropriate metadata.") 6 | 7 | 8 | class EmptyTitleElementHandler(EventHandler): 9 | def on_pr_opened(self, api, payload): 10 | for line in api.get_added_lines(): 11 | if line.find("") > -1: 12 | # This test doesn't consider case and whitespace the same way 13 | # that a HTML parser does, so empty title elements might still 14 | # go unnoticed. It will catch the low-hanging fruit, though. 15 | self.warn(WARNING) 16 | return 17 | 18 | 19 | handler_interface = EmptyTitleElementHandler 20 | -------------------------------------------------------------------------------- /handlers/empty_title_element/tests/new_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 1 4 | }, 5 | "initial": { 6 | "diff": "+ " 7 | }, 8 | "payload": { 9 | "number": 7076, 10 | "pull_request": { 11 | "draft": false, 12 | "base": { 13 | "repo": { 14 | "owner": { 15 | "login": "servo" 16 | }, 17 | "name": "servo" 18 | } 19 | } 20 | }, 21 | "action": "opened" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /handlers/homu_status/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import json 4 | import re 5 | 6 | from eventhandler import EventHandler 7 | 8 | 9 | def check_failure_log(api, bors_comment): 10 | # bors_comment would be something like, 11 | # ":broken_heart: Test failed - [linux2](https://build.servo.org/builders/linux2/builds/2627)" # noqa 12 | # ... from which we get the relevant build result url 13 | url = next(iter(re.findall(r'.*\((.*)\)', str(bors_comment)))) 14 | if not url: 15 | return 16 | 17 | # Substitute and get the new url 18 | # (e.g. https://build.servo.org/json/builders/linux2/builds/2627) 19 | json_url = re.sub(r'(.*)(builders/.*)', r'\1json/\2', url) 20 | json_stuff = api.get_page_content(json_url) 21 | if not json_stuff: 22 | return 23 | 24 | build_stats = json.loads(json_stuff) 25 | 26 | build_log = [] 27 | for step in build_stats['steps']: 28 | if 'failed' in step['text']: 29 | build_log = step['logs'] 30 | break 31 | 32 | failed_tests_url = None 33 | failed_summary_url = None 34 | for (name, log_url) in build_log: 35 | if name == 'stdio': 36 | failed_tests_url = log_url + '/text' 37 | elif 'errorsummary.log' in name: 38 | failed_summary_url = log_url + '/text' 39 | 40 | if not failed_summary_url and not failed_tests_url: 41 | return 42 | 43 | failures = None 44 | if failed_tests_url: 45 | stdio = api.get_page_content(failed_tests_url) 46 | failure_regex = r'.*Tests with unexpected results:\n(.*)$' 47 | failures = next(iter(re.findall(failure_regex, stdio, re.DOTALL))) 48 | 49 | if not failures and failed_summary_url: 50 | failures = api.get_page_content(failed_summary_url) 51 | 52 | if failures: 53 | comments = ["Test failures:", "
"] 54 | comments += [' ' * 4 + line for line in failures.split('\n')] 55 | comments += ["
"] 56 | api.post_comment('\n'.join(comments)) 57 | 58 | 59 | class HomuStatusHandler(EventHandler): 60 | def on_new_comment(self, api, payload): 61 | if not self.is_open_pr(payload): 62 | return 63 | 64 | if payload['comment']['user']['login'] != 'bors-servo': 65 | return 66 | 67 | labels = api.get_labels() 68 | msg = payload["comment"]["body"] 69 | 70 | def remove_if_exists(label): 71 | if label in labels: 72 | api.remove_label(label) 73 | 74 | if 'has been approved by' in msg or 'Testing commit' in msg: 75 | for label in ["S-awaiting-review", "S-needs-rebase", 76 | "S-tests-failed", "S-needs-code-changes", 77 | "S-needs-squash", "S-awaiting-answer"]: 78 | remove_if_exists(label) 79 | if "S-awaiting-merge" not in labels: 80 | api.add_label("S-awaiting-merge") 81 | 82 | elif 'Test failed' in msg: 83 | remove_if_exists("S-awaiting-merge") 84 | api.add_label("S-tests-failed") 85 | # Get the homu build stats url, 86 | # extract the failed tests and post them! 87 | check_failure_log(api, msg) 88 | 89 | elif 'Please resolve the merge conflicts' in msg: 90 | remove_if_exists("S-awaiting-merge") 91 | api.add_label("S-needs-rebase") 92 | 93 | 94 | handler_interface = HomuStatusHandler 95 | -------------------------------------------------------------------------------- /handlers/homu_status/tests/json/builders/test_builder_result.json: -------------------------------------------------------------------------------- 1 | { 2 | "steps": [{ 3 | "logs": [ 4 | ["stdio", "handlers/homu_status/tests/json/builders/test_stdio"] 5 | ], 6 | "text": ["test", "failed"] 7 | }] 8 | } 9 | -------------------------------------------------------------------------------- /handlers/homu_status/tests/json/builders/test_stdio/text: -------------------------------------------------------------------------------- 1 | // fooooooooooooo barrrrrrrrrrrrrrrrrrr 2 | 3 | Tests with unexpected results: 4 | ▶ OK [expected CRASH] /something/foo/something.html 5 | 6 | ▶ Unexpected subtest result in /something/blah/something.html 7 | │ FAIL [expected PASS] totally-something-else 8 | │ 9 | │ reporting... 10 | └ I won't report anymore... 11 | -------------------------------------------------------------------------------- /handlers/homu_status/tests/merge_approved.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-merge" 5 | ] 6 | }, 7 | "initial": { 8 | "labels": [ 9 | "S-needs-code-changes", 10 | "S-needs-rebase", 11 | "S-tests-failed", 12 | "S-needs-squash", 13 | "S-awaiting-review" 14 | ] 15 | }, 16 | "payload": { 17 | "comment": { 18 | "body": ":pushpin: Commit 90d7d11 has been approved by `larsbergstrom+dzbarsky`\n\n", 19 | "user": { 20 | "login": "bors-servo" 21 | } 22 | }, 23 | "repository": { 24 | "owner": { 25 | "login": "servo" 26 | }, 27 | "name": "servo" 28 | }, 29 | "action": "created", 30 | "issue": { 31 | "number": 7075, 32 | "state": "open", 33 | "pull_request": {} 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /handlers/homu_status/tests/merge_conflict.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-needs-rebase" 5 | ] 6 | }, 7 | "initial": { 8 | "labels": [ 9 | "S-awaiting-merge" 10 | ] 11 | }, 12 | "payload": { 13 | "comment": { 14 | "body": ":umbrella: The latest upstream changes (presumably #6741) made this pull request unmergeable. Please resolve the merge conflicts.", 15 | "user": { 16 | "login": "bors-servo" 17 | } 18 | }, 19 | "repository": { 20 | "owner": { 21 | "login": "servo" 22 | }, 23 | "name": "servo" 24 | }, 25 | "action": "created", 26 | "issue": { 27 | "number": 7075, 28 | "state": "open", 29 | "pull_request": {} 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /handlers/homu_status/tests/post_retry.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "labels": [ 5 | "S-awaiting-merge" 6 | ] 7 | }, 8 | { 9 | "labels": [ 10 | "S-awaiting-merge" 11 | ] 12 | } 13 | ], 14 | "initial": [ 15 | { 16 | "labels": [ 17 | "S-tests-failed" 18 | ] 19 | }, 20 | { 21 | "labels": [ 22 | "S-awaiting-merge" 23 | ] 24 | } 25 | ], 26 | "payload": { 27 | "comment": { 28 | "body": ":hourglass: Testing commit 926cb90 with merge abf7399...", 29 | "user": { 30 | "login": "bors-servo" 31 | } 32 | }, 33 | "repository": { 34 | "owner": { 35 | "login": "servo" 36 | }, 37 | "name": "servo" 38 | }, 39 | "action": "created", 40 | "issue": { 41 | "number": 7105, 42 | "state": "open", 43 | "pull_request": {} 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /handlers/homu_status/tests/tests_failed.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-tests-failed" 5 | ], 6 | "comments": 1 7 | }, 8 | "initial": { 9 | "labels": [ 10 | "S-awaiting-merge" 11 | ] 12 | }, 13 | "payload": { 14 | "comment": { 15 | "body": ":broken_heart: Test failed - [linux2](handlers/homu_status/tests/builders/test_builder_result.json)", 16 | "user": { 17 | "login": "bors-servo" 18 | } 19 | }, 20 | "repository": { 21 | "owner": { 22 | "login": "servo" 23 | }, 24 | "name": "servo" 25 | }, 26 | "action": "created", 27 | "issue": { 28 | "number": 7075, 29 | "state": "open", 30 | "pull_request": {} 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /handlers/label_watchers/__init__.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from eventhandler import EventHandler 3 | from helpers import get_people_from_config 4 | 5 | import os 6 | 7 | LABEL_WATCHERS_CONFIG_FILE = os.path.join(os.path.dirname(__file__), 8 | 'watchers.ini') 9 | 10 | 11 | def build_label_message(mentions): 12 | message = ['cc'] 13 | for watcher in mentions: 14 | message.append("@{}".format(watcher)) 15 | 16 | return ' '.join(message) 17 | 18 | 19 | class LabelWatchersHandler(EventHandler): 20 | def on_issue_labeled(self, api, payload): 21 | label_list = get_people_from_config(api, LABEL_WATCHERS_CONFIG_FILE) 22 | if not label_list: 23 | return 24 | 25 | new_label = payload['label']['name'] 26 | existing_labels = [] 27 | if 'issue' in payload: 28 | for label in payload['issue']['labels']: 29 | if new_label != label['name']: 30 | existing_labels.append(label['name']) 31 | 32 | creator = None 33 | sender = payload['sender']['login'].lower() 34 | 35 | if 'issue' in payload: 36 | creator = payload['issue']['user']['login'].lower() 37 | elif 'pull_request' in payload: 38 | creator = payload['pull_request']['user']['login'].lower() 39 | 40 | label_map = dict() 41 | for watcher, watched_labels in label_list: # reverse map 42 | if watcher == sender or watcher == creator: 43 | continue 44 | 45 | for label in watched_labels.split(' '): 46 | label_map.setdefault(label, set()) 47 | label_map[label].add(watcher) 48 | 49 | mentions = deepcopy(label_map.get(new_label, set())) 50 | for label in existing_labels: 51 | for watcher in label_map.get(label, set()): 52 | if watcher in mentions: # avoid cc'ing again 53 | mentions.remove(watcher) 54 | 55 | if not mentions: 56 | return 57 | 58 | message = build_label_message(mentions) 59 | api.post_comment(message) 60 | 61 | 62 | handler_interface = LabelWatchersHandler 63 | -------------------------------------------------------------------------------- /handlers/label_watchers/tests/dont_annoy.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 0 4 | }, 5 | "initial": {}, 6 | "payload": { 7 | "action": "labeled", 8 | "issue": { 9 | "labels": [ 10 | { 11 | "name": "foo" 12 | }, 13 | { 14 | "name": "bar" 15 | } 16 | ], 17 | "number": 16179, 18 | "user": { 19 | "login": "jdm" 20 | } 21 | }, 22 | "sender": { 23 | "login": "jdm" 24 | }, 25 | "repository": { 26 | "owner": { 27 | "login": "servo" 28 | }, 29 | "name": "highfive-test" 30 | }, 31 | "label": { 32 | "name": "foo" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /handlers/label_watchers/tests/dont_label_sender.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 0 4 | }, 5 | "initial": {}, 6 | "payload": { 7 | "sender": { 8 | "login": "jdm" 9 | }, 10 | "repository": { 11 | "owner": { 12 | "login": "servo" 13 | }, 14 | "name": "highfive" 15 | }, 16 | "label": { 17 | "name": "enhancement" 18 | }, 19 | "action": "labeled", 20 | "issue": { 21 | "labels": [{ 22 | "name": "enhancement" 23 | }], 24 | "number": 84, 25 | "user": { 26 | "login": "nox" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /handlers/label_watchers/tests/dont_notify_creator.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 0 4 | }, 5 | "initial": {}, 6 | "payload": { 7 | "issue": { 8 | "number": 123, 9 | "labels": [{ 10 | "name": "L-python" 11 | }], 12 | "user": { 13 | "login": "Wafflespeanut" 14 | } 15 | }, 16 | "sender": { 17 | "login": "jdm" 18 | }, 19 | "repository": { 20 | "owner": { 21 | "login": "servo" 22 | }, 23 | "name": "servo" 24 | }, 25 | "label": { 26 | "name": "L-python" 27 | }, 28 | "action": "labeled" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /handlers/label_watchers/tests/new_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 1 4 | }, 5 | "initial": {}, 6 | "payload": { 7 | "sender": { 8 | "login": "test" 9 | }, 10 | "repository": { 11 | "owner": { 12 | "login": "servo" 13 | }, 14 | "name": "highfive" 15 | }, 16 | "label": { 17 | "name": "enhancement" 18 | }, 19 | "action": "labeled", 20 | "issue": { 21 | "number": 84, 22 | "labels": [{ 23 | "name": "enhancement" 24 | }], 25 | "user": { 26 | "login": "highfive" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /handlers/label_watchers/tests/not_watched.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 0 4 | }, 5 | "initial": {}, 6 | "payload": { 7 | "sender": { 8 | "login": "jdm" 9 | }, 10 | "repository": { 11 | "owner": { 12 | "login": "servo" 13 | }, 14 | "name": "highfive" 15 | }, 16 | "label": { 17 | "name": "not-watched-label" 18 | }, 19 | "action": "labeled", 20 | "issue": { 21 | "number": 84, 22 | "labels": [{ 23 | "name": "not-watched-label" 24 | }], 25 | "user": { 26 | "login": "Wafflespeanut" 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /handlers/label_watchers/watchers.ini: -------------------------------------------------------------------------------- 1 | [servo/servo] 2 | emilio = A-stylo 3 | 4 | [servo/highfive] 5 | jdm = enhancement 6 | 7 | [servo/highfive-test] 8 | test_user = foo bar 9 | 10 | [mozillareality/firefoxreality] 11 | thenadj = Design 12 | -------------------------------------------------------------------------------- /handlers/missing_test/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from eventhandler import EventHandler 4 | 5 | TEST_REQUIRED_MSG = ('These commits modify {} code, but no tests are modified.' 6 | ' Please consider adding a test!') 7 | 8 | 9 | class MissingTestHandler(EventHandler): 10 | COMPONENT_DIRS_TO_CHECK = ('layout', 'script', 'gfx', 'style', 'net') 11 | TEST_DIRS_TO_CHECK = ('ref', 'wpt', 'unit', 12 | 'compiletest/plugin/compile-fail') 13 | TEST_FILES_TO_CHECK = [ 14 | '{0}/{1}'.format('components/script/dom', test_file) 15 | for test_file in ['testbinding.rs', 16 | 'webidls/TestBinding.webidl', 17 | 'testbindingproxy.rs', 18 | 'webidls/TestBindingProxy.webidl', 19 | 'testbindingiterable.rs', 20 | 'webidls/TestBindingIterable.webidl', 21 | 'testbindingpairiterable.rs', 22 | 'webidls/TestBindingPairIterable.webidl'] 23 | ] 24 | 25 | def on_pr_opened(self, api, payload): 26 | return 27 | components_changed = set() 28 | 29 | for filepath in api.get_changed_files(): 30 | for component in self.COMPONENT_DIRS_TO_CHECK: 31 | if 'components/{0}/'.format(component) in filepath: 32 | components_changed.add(component) 33 | 34 | if '/tests/' in filepath: 35 | return 36 | 37 | for directory in self.TEST_DIRS_TO_CHECK: 38 | if 'tests/{0}'.format(directory) in filepath: 39 | return 40 | 41 | for test_file in self.TEST_FILES_TO_CHECK: 42 | if test_file in filepath: 43 | return 44 | 45 | if components_changed: 46 | # Build a readable list of changed components 47 | if len(components_changed) == 1: 48 | components_msg = components_changed.pop() 49 | elif len(components_changed) == 2: 50 | components_msg = '{} and {}'.format(*components_changed) 51 | else: 52 | components_msg = ', '.join(components_changed) 53 | components_msg = ", and ".join(components_msg.rsplit(", ", 1)) 54 | 55 | self.warn(TEST_REQUIRED_MSG.format(components_msg)) 56 | 57 | 58 | handler_interface = MissingTestHandler 59 | -------------------------------------------------------------------------------- /handlers/missing_test/tests/new_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "comments": 1 5 | }, 6 | { 7 | "comments": 1 8 | }, 9 | { 10 | "comments": 1 11 | }, 12 | { 13 | "comments": 1 14 | }, 15 | { 16 | "comments": 1 17 | }, 18 | { 19 | "comments": 0 20 | }, 21 | { 22 | "comments": 0 23 | }, 24 | { 25 | "comments": 0 26 | }, 27 | { 28 | "comments": 0 29 | }, 30 | { 31 | "comments": 0 32 | }, 33 | { 34 | "comments": 0 35 | }, 36 | { 37 | "comments": 0 38 | } 39 | ], 40 | "initial": [ 41 | { 42 | "diff": "diff --git components/layout/" 43 | }, 44 | { 45 | "diff": "diff --git components/script/" 46 | }, 47 | { 48 | "diff": "diff --git components/gfx/" 49 | }, 50 | { 51 | "diff": "diff --git components/style/" 52 | }, 53 | { 54 | "diff": "diff --git components/net/" 55 | }, 56 | { 57 | "diff": "diff --git components/layout/\ndiff --git tests/wpt" 58 | }, 59 | { 60 | "diff": "diff --git components/gfx/\ndiff --git tests/unit" 61 | }, 62 | { 63 | "diff": "diff --git components/style/\ndiff --git tests/unit" 64 | }, 65 | { 66 | "diff": "diff --git components/net/\ndiff --git tests/unit" 67 | }, 68 | { 69 | "diff": "diff --git components/script/\ndiff --git tests/unit" 70 | }, 71 | { 72 | "diff": "diff --git components/script/\ndiff --git tests/compiletest/plugin/compile-fail" 73 | }, 74 | { 75 | "diff": "diff --git components/net/blah.rs\ndiff --git components/net/tests/blah.rs" 76 | } 77 | ], 78 | "payload": { 79 | "number": 7076, 80 | "pull_request": { 81 | "draft": false, 82 | "base": { 83 | "repo": { 84 | "owner": { 85 | "login": "servo" 86 | }, 87 | "name": "servo" 88 | } 89 | } 90 | }, 91 | "action": "opened" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /handlers/missing_test/tests/test_binding.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial": [ 3 | { 4 | "diff": "diff --git components/script/dom/testbinding.rs" 5 | }, 6 | { 7 | "diff": "diff --git components/script/dom/webidls/TestBinding.webidl" 8 | }, 9 | { 10 | "diff": "diff --git components/script/dom/testbindingproxy.rs" 11 | }, 12 | { 13 | "diff": "diff --git components/script/dom/webidls/TestBindingProxy.webidl" 14 | }, 15 | { 16 | "diff": "diff --git components/script/dom/testbindingiterable.rs" 17 | }, 18 | { 19 | "diff": "diff --git components/script/dom/webidls/TestBindingIterable.webidl" 20 | }, 21 | { 22 | "diff": "diff --git components/script/dom/testbindingpairiterable.rs" 23 | }, 24 | { 25 | "diff": "diff --git components/script/dom/webidls/TestBindingPairIterable.webidl" 26 | } 27 | ], 28 | "expected": [ 29 | { 30 | "comments": 0 31 | }, 32 | { 33 | "comments": 0 34 | }, 35 | { 36 | "comments": 0 37 | }, 38 | { 39 | "comments": 0 40 | }, 41 | { 42 | "comments": 0 43 | }, 44 | { 45 | "comments": 0 46 | }, 47 | { 48 | "comments": 0 49 | }, 50 | { 51 | "comments": 0 52 | } 53 | ], 54 | "payload": { 55 | "number": 10355, 56 | "pull_request": { 57 | "draft": false, 58 | "base": { 59 | "repo": { 60 | "owner": { 61 | "login": "servo" 62 | }, 63 | "name": "servo" 64 | } 65 | } 66 | }, 67 | "action": "opened" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /handlers/no_modify_css_tests/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from eventhandler import EventHandler 4 | 5 | NO_MODIFY_CSS_TESTS_MSG = '''This pull request modifies the contents of 6 | `tests/wpt/css-tests/`, which are overwriten occasionally whenever the 7 | directory is synced from upstream.''' 8 | 9 | 10 | class NoModifyCSSTestsHandler(EventHandler): 11 | DIR_TO_CHECK = "tests/wpt/css-tests" 12 | 13 | def on_pr_opened(self, api, payload): 14 | for filepath in api.get_changed_files(): 15 | if self.DIR_TO_CHECK in filepath: 16 | self.warn(NO_MODIFY_CSS_TESTS_MSG) 17 | break 18 | 19 | 20 | handler_interface = NoModifyCSSTestsHandler 21 | -------------------------------------------------------------------------------- /handlers/no_modify_css_tests/tests/new_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "comments": 1 5 | }, 6 | { 7 | "comments": 0 8 | }, 9 | { 10 | "comments": 0 11 | }, 12 | { 13 | "comments": 1 14 | }, 15 | { 16 | "comments": 0 17 | } 18 | ], 19 | "initial": [ 20 | { 21 | "diff": "diff --git tests/wpt/css-tests" 22 | }, 23 | { 24 | "diff": "diff --git tests/" 25 | }, 26 | { 27 | "diff": "diff --git ports/" 28 | }, 29 | { 30 | "diff": "diff --git tests/wpt/css-tests/LICENSE" 31 | }, 32 | { 33 | "diff": "not diff --git tests/wpt/css-tests/manifest" 34 | } 35 | ], 36 | "payload": { 37 | "number": 7076, 38 | "pull_request": { 39 | "draft": false, 40 | "base": { 41 | "repo": { 42 | "owner": { 43 | "login": "servo" 44 | }, 45 | "name": "servo" 46 | } 47 | } 48 | }, 49 | "action": "opened" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /handlers/nonini_wpt_meta/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from eventhandler import EventHandler 4 | 5 | NON_INI_MSG = 'This pull request adds {0} without the .ini \ 6 | file extension to {1}. Please consider removing {2}!' 7 | 8 | 9 | class NonINIWPTMetaFileHandler(EventHandler): 10 | DIRS_TO_CHECK = ( 11 | 'tests/wpt/meta', 12 | 'tests/wpt/mozilla/meta', 13 | ) 14 | 15 | FALSE_POSITIVE_SUBSTRINGS = ( 16 | '.ini', 17 | 'MANIFEST.json', 18 | 'mozilla-sync', 19 | ) 20 | 21 | def _wpt_ini_dirs(self, line): 22 | if '.' in line and not any(fp in line 23 | for fp in self.FALSE_POSITIVE_SUBSTRINGS): 24 | return set(directory for directory in self.DIRS_TO_CHECK 25 | if directory in line) 26 | else: 27 | return set() 28 | 29 | def on_pr_opened(self, api, payload): 30 | test_dirs_with_offending_files = set() 31 | 32 | for filepath in api.get_changed_files(): 33 | test_dirs_with_offending_files |= self._wpt_ini_dirs(filepath) 34 | 35 | if test_dirs_with_offending_files: 36 | if len(test_dirs_with_offending_files) == 1: 37 | files = "a file" 38 | test_dirs = test_dirs_with_offending_files.pop() 39 | remove = "it" 40 | else: 41 | files = "files" 42 | test_dirs = '{} and {}'.format(*test_dirs_with_offending_files) 43 | remove = "them" 44 | 45 | self.warn(NON_INI_MSG.format(files, test_dirs, remove)) 46 | 47 | 48 | handler_interface = NonINIWPTMetaFileHandler 49 | -------------------------------------------------------------------------------- /handlers/nonini_wpt_meta/tests/new_issue.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "comments": 0 5 | } 6 | ], 7 | "initial": [ 8 | {} 9 | ], 10 | "payload": { 11 | "issue": { 12 | "number": 7076 13 | }, 14 | "repository": { 15 | "owner": { 16 | "login": "" 17 | }, 18 | "name": "" 19 | }, 20 | "action": "opened" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /handlers/nonini_wpt_meta/tests/new_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "comments": 0 5 | }, 6 | { 7 | "comments": 1 8 | }, 9 | { 10 | "comments": 1 11 | }, 12 | { 13 | "comments": 0 14 | }, 15 | { 16 | "comments": 0 17 | }, 18 | { 19 | "comments": 0 20 | }, 21 | { 22 | "comments": 0 23 | } 24 | ], 25 | "initial": [ 26 | { 27 | "diff": "diff --git tests/wpt/meta/foo.ini" 28 | }, 29 | { 30 | "diff": "diff --git tests/wpt/mozilla/meta/bar.tgz" 31 | }, 32 | { 33 | "diff": "diff --git tests/wpt/mozilla/meta/bar.tgz tests/wpt/metadata/spam.xz" 34 | }, 35 | { 36 | "diff": "diff --git tests/wpt/meta" 37 | }, 38 | { 39 | "diff": "not diff tests/wpt/meta/eggs.delicious" 40 | }, 41 | { 42 | "diff": "diff --git tests/wpt/meta/MANIFEST.json" 43 | }, 44 | { 45 | "diff": "diff --git tests/wpt/meta/mozilla-sync" 46 | } 47 | ], 48 | "payload": { 49 | "number": 7076, 50 | "pull_request": { 51 | "draft": false, 52 | "base": { 53 | "repo": { 54 | "owner": { 55 | "login": "servo" 56 | }, 57 | "name": "servo" 58 | } 59 | } 60 | }, 61 | "action": "opened" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /handlers/status_update/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from configparser import ConfigParser 4 | import os.path 5 | import time 6 | 7 | from eventhandler import EventHandler 8 | 9 | AWAITING_MERGE = "S-awaiting-merge" 10 | AWAITING_REVIEW = "S-awaiting-review" 11 | NEED_CODE_CHANGES = "S-needs-code-changes" 12 | NEED_REBASE = "S-needs-rebase" 13 | TESTS_FAILED = "S-tests-failed" 14 | 15 | config = ConfigParser() 16 | config.optionxform = str # Be case sensitive 17 | config.read(os.path.join(os.path.dirname(__file__), 'labels.ini')) 18 | config = { 19 | repo: { 20 | event: set(labels.split(' ')) for event, labels in config.items(repo) 21 | } 22 | for repo in config.sections() 23 | } 24 | # TODO(aneeshusa): Add checking of config option validity 25 | 26 | 27 | def clear_pr_labels(api): 28 | labels = api.get_labels() 29 | 30 | for label in [ 31 | AWAITING_MERGE, 32 | TESTS_FAILED, 33 | NEED_CODE_CHANGES, 34 | NEED_REBASE, 35 | ]: 36 | if label in labels: 37 | api.remove_label(label) 38 | 39 | 40 | def handle_custom_labels(api, event): 41 | repo_config = config.get('{}/{}'.format(api.owner, api.repo), None) 42 | if not repo_config: 43 | return 44 | labels = api.get_labels() 45 | for label in repo_config.get('remove_on_pr_{}'.format(event), []): 46 | if label in labels: 47 | api.remove_label(label) 48 | for label in repo_config.get('add_on_pr_{}'.format(event), []): 49 | if label not in labels: 50 | api.add_label(label) 51 | 52 | 53 | def update_rebase_status(api, payload): 54 | if "pull_request" not in payload: 55 | return 56 | 57 | mergeable = payload['pull_request'].get('mergeable', None) 58 | 59 | # If mergeable is null, the data wasn't available yet. 60 | # Once it is, mergeable will be either true or false. 61 | while mergeable == None: # noqa 62 | time.sleep(1) # wait for GitHub to finish determine mergeability 63 | pull_request = api.get_pull() 64 | mergeable = pull_request['mergeable'] 65 | 66 | if mergeable == False: # noqa 67 | api.add_label(NEED_REBASE) 68 | 69 | 70 | def is_draft_pr(payload): 71 | return "pull_request" in payload and \ 72 | payload["pull_request"]["draft"] == True # noqa 73 | 74 | 75 | class StatusUpdateHandler(EventHandler): 76 | def on_pr_opened(self, api, payload): 77 | if "pull_request" not in payload or is_draft_pr(payload): 78 | return 79 | 80 | labels = api.get_labels() 81 | if AWAITING_REVIEW not in labels: 82 | api.add_label(AWAITING_REVIEW) 83 | update_rebase_status(api, payload) 84 | handle_custom_labels(api, 'opened') 85 | 86 | def on_pr_updated(self, api, payload): 87 | if is_draft_pr(payload): 88 | return 89 | 90 | clear_pr_labels(api) 91 | api.add_label(AWAITING_REVIEW) 92 | update_rebase_status(api, payload) 93 | handle_custom_labels(api, 'updated') 94 | 95 | def on_pr_ready_for_review(self, api, payload): 96 | clear_pr_labels(api) 97 | api.add_label(AWAITING_REVIEW) 98 | update_rebase_status(api, payload) 99 | handle_custom_labels(api, 'ready') 100 | 101 | def on_pr_closed(self, api, payload): 102 | handle_custom_labels(api, 'closed') 103 | if "pull_request" in payload and \ 104 | payload['pull_request']['merged'] == True: # noqa 105 | api.remove_label(AWAITING_MERGE) 106 | handle_custom_labels(api, 'merged') 107 | 108 | def on_pr_enqueued(self, api, payload): 109 | clear_pr_labels(api) 110 | api.add_label(AWAITING_MERGE) 111 | handle_custom_labels(api, 'enqueued') 112 | 113 | def on_pr_dequeued(self, api, payload): 114 | labels = api.get_labels() 115 | update_rebase_status(api, payload) 116 | if payload["reason"] != "MERGE": 117 | if AWAITING_MERGE in labels: 118 | api.remove_label(AWAITING_MERGE) 119 | if payload["reason"] == "CI_FAILURE": 120 | api.add_label(TESTS_FAILED) 121 | handle_custom_labels(api, 'dequeued') 122 | 123 | def on_review_submitted(self, api, payload): 124 | update_rebase_status(api, payload) 125 | labels = api.get_labels() 126 | state = payload["review"]["state"] 127 | if state == "changes_requested": 128 | if AWAITING_REVIEW in labels: 129 | api.remove_label(AWAITING_REVIEW) 130 | if NEED_CODE_CHANGES not in labels: 131 | api.add_label(NEED_CODE_CHANGES) 132 | elif state == "approved": 133 | if AWAITING_REVIEW in labels: 134 | api.remove_label(AWAITING_REVIEW) 135 | 136 | 137 | handler_interface = StatusUpdateHandler 138 | -------------------------------------------------------------------------------- /handlers/status_update/labels.ini: -------------------------------------------------------------------------------- 1 | [servo/saltfs] 2 | add_on_pr_merged = S-needs-deploy 3 | -------------------------------------------------------------------------------- /handlers/status_update/tests/custom_labels.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-needs-deploy" 5 | ] 6 | }, 7 | "initial": { 8 | "labels": [ 9 | "S-awaiting-merge" 10 | ] 11 | }, 12 | "payload": { 13 | "number": 659, 14 | "pull_request": { 15 | "draft": false, 16 | "base": { 17 | "repo": { 18 | "owner": { 19 | "login": "servo" 20 | }, 21 | "name": "saltfs" 22 | } 23 | }, 24 | "merged": true 25 | }, 26 | "action": "closed" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /handlers/status_update/tests/dequeued.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": ["S-tests-failed"] 4 | }, 5 | "initial": { 6 | "labels": [ 7 | "S-awaiting-merge" 8 | ] 9 | }, 10 | "payload": { 11 | "number": 7062, 12 | "reason": "CI_FAILURE", 13 | "pull_request": { 14 | "draft": false, 15 | "mergeable": true, 16 | "base": { 17 | "repo": { 18 | "owner": { 19 | "login": "servo" 20 | }, 21 | "name": "servo" 22 | } 23 | } 24 | }, 25 | "action": "dequeued" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /handlers/status_update/tests/dequeued_unmergeable.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": ["S-needs-rebase"] 4 | }, 5 | "initial": { 6 | "labels": [ 7 | "S-awaiting-merge" 8 | ] 9 | }, 10 | "payload": { 11 | "number": 7062, 12 | "reason": "MERGE_CONFLICT", 13 | "pull_request": { 14 | "draft": false, 15 | "mergeable": false, 16 | "base": { 17 | "repo": { 18 | "owner": { 19 | "login": "servo" 20 | }, 21 | "name": "servo" 22 | } 23 | } 24 | }, 25 | "action": "dequeued" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /handlers/status_update/tests/enqueued.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-merge" 5 | ] 6 | }, 7 | "initial": { 8 | "labels": [ 9 | "S-needs-code-changes", 10 | "S-tests-failed", 11 | "S-awaiting-merge", 12 | "S-needs-rebase" 13 | ] 14 | }, 15 | "payload": { 16 | "number": 7062, 17 | "pull_request": { 18 | "draft": false, 19 | "base": { 20 | "repo": { 21 | "owner": { 22 | "login": "servo" 23 | }, 24 | "name": "servo" 25 | } 26 | } 27 | }, 28 | "action": "enqueued" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /handlers/status_update/tests/merge.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [] 4 | }, 5 | "initial": { 6 | "labels": [ 7 | "S-awaiting-merge" 8 | ] 9 | }, 10 | "payload": { 11 | "number": 10315, 12 | "pull_request": { 13 | "draft": false, 14 | "base": { 15 | "repo": { 16 | "owner": { 17 | "login": "servo" 18 | }, 19 | "name": "servo" 20 | } 21 | }, 22 | "merged": true 23 | }, 24 | "action": "closed" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /handlers/status_update/tests/new_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-review" 5 | ] 6 | }, 7 | "initial": {}, 8 | "payload": { 9 | "number": 7076, 10 | "pull_request": { 11 | "draft": false, 12 | "mergeable": true, 13 | "base": { 14 | "repo": { 15 | "owner": { 16 | "login": "servo" 17 | }, 18 | "name": "servo" 19 | } 20 | } 21 | }, 22 | "action": "opened" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /handlers/status_update/tests/new_pr_draft.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | ] 5 | }, 6 | "initial": {}, 7 | "payload": { 8 | "number": 7076, 9 | "pull_request": { 10 | "draft": true, 11 | "base": { 12 | "repo": { 13 | "owner": { 14 | "login": "servo" 15 | }, 16 | "name": "servo" 17 | } 18 | } 19 | }, 20 | "action": "opened" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /handlers/status_update/tests/new_pr_unmergeable.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-review", 5 | "S-needs-rebase" 6 | ] 7 | }, 8 | "initial": {}, 9 | "payload": { 10 | "number": 7076, 11 | "pull_request": { 12 | "draft": false, 13 | "mergeable": false, 14 | "base": { 15 | "repo": { 16 | "owner": { 17 | "login": "servo" 18 | }, 19 | "name": "servo" 20 | } 21 | } 22 | }, 23 | "action": "opened" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /handlers/status_update/tests/pr_ready_for_review.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-review" 5 | ] 6 | }, 7 | "initial": {}, 8 | "payload": { 9 | "number": 7076, 10 | "pull_request": { 11 | "draft": false, 12 | "mergeable": true, 13 | "base": { 14 | "repo": { 15 | "owner": { 16 | "login": "servo" 17 | }, 18 | "name": "servo" 19 | } 20 | } 21 | }, 22 | "action": "ready_for_review" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /handlers/status_update/tests/review_approved.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | ] 5 | }, 6 | "initial": { 7 | "labels": [ 8 | "S-awaiting-review" 9 | ] 10 | }, 11 | "payload": { 12 | "number": 7076, 13 | "review": { 14 | "state": "approved" 15 | }, 16 | "pull_request": { 17 | "draft": false, 18 | "mergeable": true, 19 | "base": { 20 | "repo": { 21 | "owner": { 22 | "login": "servo" 23 | }, 24 | "name": "servo" 25 | } 26 | } 27 | }, 28 | "action": "submitted" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /handlers/status_update/tests/review_changes.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-needs-code-changes" 5 | ] 6 | }, 7 | "initial": { 8 | "labels": [ 9 | "S-awaiting-review" 10 | ] 11 | }, 12 | "payload": { 13 | "number": 7076, 14 | "review": { 15 | "state": "changes_requested" 16 | }, 17 | "pull_request": { 18 | "draft": false, 19 | "mergeable": true, 20 | "base": { 21 | "repo": { 22 | "owner": { 23 | "login": "servo" 24 | }, 25 | "name": "servo" 26 | } 27 | } 28 | }, 29 | "action": "submitted" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /handlers/status_update/tests/synchronize.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-review" 5 | ] 6 | }, 7 | "initial": { 8 | "labels": [ 9 | "S-needs-code-changes", 10 | "S-tests-failed", 11 | "S-awaiting-merge" 12 | ] 13 | }, 14 | "payload": { 15 | "number": 7062, 16 | "pull_request": { 17 | "draft": false, 18 | "mergeable": true, 19 | "base": { 20 | "repo": { 21 | "owner": { 22 | "login": "servo" 23 | }, 24 | "name": "servo" 25 | } 26 | } 27 | }, 28 | "action": "synchronize" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /handlers/status_update/tests/synchronize_changes.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 0 4 | }, 5 | "initial": {}, 6 | "payload": { 7 | "number": 7062, 8 | "pull_request": { 9 | "draft": false, 10 | "mergeable": true, 11 | "base": { 12 | "repo": { 13 | "owner": { 14 | "login": "servo" 15 | }, 16 | "name": "servo" 17 | } 18 | } 19 | }, 20 | "action": "synchronize" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /handlers/status_update/tests/synchronize_draft.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-needs-code-changes", 5 | "S-tests-failed", 6 | "S-awaiting-merge" 7 | ] 8 | }, 9 | "initial": { 10 | "labels": [ 11 | "S-needs-code-changes", 12 | "S-tests-failed", 13 | "S-awaiting-merge" 14 | ] 15 | }, 16 | "payload": { 17 | "number": 7062, 18 | "pull_request": { 19 | "draft": true, 20 | "base": { 21 | "repo": { 22 | "owner": { 23 | "login": "servo" 24 | }, 25 | "name": "servo" 26 | } 27 | } 28 | }, 29 | "action": "synchronize" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /handlers/status_update/tests/synchronize_mergeable.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-review" 5 | ] 6 | }, 7 | "initial": { 8 | "labels": [ 9 | "S-needs-rebase" 10 | ], 11 | "pull_request": { 12 | "body": "This excludes:\r\n\r\n* tenacious, as it has not yet been updated to the current rustc\r\n (Manishearth/rust-tenacious#6);\r\n* ipc-channel, as it doesn't build on linux upstream.\n\n\n[\"Review](https://reviewable.io/reviews/servo/servo/7062)\n\n", 13 | "merge_commit_sha": "30f3785911255fde11ab91ef5c0a9fe741174aff", 14 | "number": 7062, 15 | "assignee": null, 16 | "mergeable": true, 17 | "closed_at": null, 18 | "additions": 483, 19 | "statuses_url": "https://api.github.com/repos/servo/servo/statuses/289b421490113278d323ef482d77a1cacb560ac7", 20 | "id": 41866663, 21 | "title": "Update most dependencies.", 22 | "comments": 3, 23 | "merged_at": null, 24 | "state": "open", 25 | "_links": { 26 | "review_comment": { 27 | "href": "https://api.github.com/repos/servo/servo/pulls/comments{/number}" 28 | }, 29 | "commits": { 30 | "href": "https://api.github.com/repos/servo/servo/pulls/7062/commits" 31 | }, 32 | "self": { 33 | "href": "https://api.github.com/repos/servo/servo/pulls/7062" 34 | }, 35 | "comments": { 36 | "href": "https://api.github.com/repos/servo/servo/issues/7062/comments" 37 | }, 38 | "html": { 39 | "href": "https://github.com/servo/servo/pull/7062" 40 | }, 41 | "review_comments": { 42 | "href": "https://api.github.com/repos/servo/servo/pulls/7062/comments" 43 | }, 44 | "issue": { 45 | "href": "https://api.github.com/repos/servo/servo/issues/7062" 46 | }, 47 | "statuses": { 48 | "href": "https://api.github.com/repos/servo/servo/statuses/289b421490113278d323ef482d77a1cacb560ac7" 49 | } 50 | }, 51 | "commits": 1, 52 | "diff_url": "https://github.com/servo/servo/pull/7062.diff", 53 | "issue_url": "https://api.github.com/repos/servo/servo/issues/7062", 54 | "patch_url": "https://github.com/servo/servo/pull/7062.patch", 55 | "deletions": 438, 56 | "head": { 57 | "repo": { 58 | "issues_url": "https://api.github.com/repos/servo/servo/issues{/number}", 59 | "has_wiki": true, 60 | "forks_url": "https://api.github.com/repos/servo/servo/forks", 61 | "mirror_url": null, 62 | "subscription_url": "https://api.github.com/repos/servo/servo/subscription", 63 | "merges_url": "https://api.github.com/repos/servo/servo/merges", 64 | "collaborators_url": "https://api.github.com/repos/servo/servo/collaborators{/collaborator}", 65 | "updated_at": "2015-08-07T09:37:49Z", 66 | "private": false, 67 | "pulls_url": "https://api.github.com/repos/servo/servo/pulls{/number}", 68 | "issue_comment_url": "https://api.github.com/repos/servo/servo/issues/comments{/number}", 69 | "full_name": "servo/servo", 70 | "owner": { 71 | "following_url": "https://api.github.com/users/servo/following{/other_user}", 72 | "gists_url": "https://api.github.com/users/servo/gists{/gist_id}", 73 | "organizations_url": "https://api.github.com/users/servo/orgs", 74 | "url": "https://api.github.com/users/servo", 75 | "events_url": "https://api.github.com/users/servo/events{/privacy}", 76 | "html_url": "https://github.com/servo", 77 | "subscriptions_url": "https://api.github.com/users/servo/subscriptions", 78 | "avatar_url": "https://avatars.githubusercontent.com/u/2566135?v=3", 79 | "repos_url": "https://api.github.com/users/servo/repos", 80 | "received_events_url": "https://api.github.com/users/servo/received_events", 81 | "gravatar_id": "", 82 | "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}", 83 | "site_admin": false, 84 | "login": "servo", 85 | "type": "Organization", 86 | "id": 2566135, 87 | "followers_url": "https://api.github.com/users/servo/followers" 88 | }, 89 | "statuses_url": "https://api.github.com/repos/servo/servo/statuses/{sha}", 90 | "id": 3390243, 91 | "keys_url": "https://api.github.com/repos/servo/servo/keys{/key_id}", 92 | "size": 1903023, 93 | "tags_url": "https://api.github.com/repos/servo/servo/tags", 94 | "issue_events_url": "https://api.github.com/repos/servo/servo/issues/events{/number}", 95 | "contributors_url": "https://api.github.com/repos/servo/servo/contributors", 96 | "downloads_url": "https://api.github.com/repos/servo/servo/downloads", 97 | "has_downloads": true, 98 | "assignees_url": "https://api.github.com/repos/servo/servo/assignees{/user}", 99 | "contents_url": "https://api.github.com/repos/servo/servo/contents/{+path}", 100 | "has_pages": false, 101 | "git_refs_url": "https://api.github.com/repos/servo/servo/git/refs{/sha}", 102 | "clone_url": "https://github.com/servo/servo.git", 103 | "watchers_count": 4571, 104 | "git_tags_url": "https://api.github.com/repos/servo/servo/git/tags{/sha}", 105 | "milestones_url": "https://api.github.com/repos/servo/servo/milestones{/number}", 106 | "stargazers_count": 4571, 107 | "homepage": "", 108 | "branches_url": "https://api.github.com/repos/servo/servo/branches{/branch}", 109 | "fork": false, 110 | "commits_url": "https://api.github.com/repos/servo/servo/commits{/sha}", 111 | "releases_url": "https://api.github.com/repos/servo/servo/releases{/id}", 112 | "description": "The Servo Browser Engine", 113 | "archive_url": "https://api.github.com/repos/servo/servo/{archive_format}{/ref}", 114 | "labels_url": "https://api.github.com/repos/servo/servo/labels{/name}", 115 | "events_url": "https://api.github.com/repos/servo/servo/events", 116 | "comments_url": "https://api.github.com/repos/servo/servo/comments{/number}", 117 | "html_url": "https://github.com/servo/servo", 118 | "forks": 717, 119 | "compare_url": "https://api.github.com/repos/servo/servo/compare/{base}...{head}", 120 | "trees_url": "https://api.github.com/repos/servo/servo/git/trees{/sha}", 121 | "git_url": "git://github.com/servo/servo.git", 122 | "svn_url": "https://github.com/servo/servo", 123 | "notifications_url": "https://api.github.com/repos/servo/servo/notifications{?since,all,participating}", 124 | "has_issues": true, 125 | "ssh_url": "git@github.com:servo/servo.git", 126 | "blobs_url": "https://api.github.com/repos/servo/servo/git/blobs{/sha}", 127 | "languages_url": "https://api.github.com/repos/servo/servo/languages", 128 | "hooks_url": "https://api.github.com/repos/servo/servo/hooks", 129 | "open_issues_count": 1057, 130 | "watchers": 4571, 131 | "name": "servo", 132 | "language": "Rust", 133 | "url": "https://api.github.com/repos/servo/servo", 134 | "created_at": "2012-02-08T19:07:25Z", 135 | "pushed_at": "2015-08-07T16:09:10Z", 136 | "forks_count": 717, 137 | "default_branch": "master", 138 | "teams_url": "https://api.github.com/repos/servo/servo/teams", 139 | "open_issues": 1057, 140 | "git_commits_url": "https://api.github.com/repos/servo/servo/git/commits{/sha}", 141 | "subscribers_url": "https://api.github.com/repos/servo/servo/subscribers", 142 | "stargazers_url": "https://api.github.com/repos/servo/servo/stargazers" 143 | }, 144 | "sha": "289b421490113278d323ef482d77a1cacb560ac7", 145 | "ref": "update-some", 146 | "user": { 147 | "following_url": "https://api.github.com/users/servo/following{/other_user}", 148 | "gists_url": "https://api.github.com/users/servo/gists{/gist_id}", 149 | "organizations_url": "https://api.github.com/users/servo/orgs", 150 | "url": "https://api.github.com/users/servo", 151 | "events_url": "https://api.github.com/users/servo/events{/privacy}", 152 | "html_url": "https://github.com/servo", 153 | "subscriptions_url": "https://api.github.com/users/servo/subscriptions", 154 | "avatar_url": "https://avatars.githubusercontent.com/u/2566135?v=3", 155 | "repos_url": "https://api.github.com/users/servo/repos", 156 | "received_events_url": "https://api.github.com/users/servo/received_events", 157 | "gravatar_id": "", 158 | "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}", 159 | "site_admin": false, 160 | "login": "servo", 161 | "type": "Organization", 162 | "id": 2566135, 163 | "followers_url": "https://api.github.com/users/servo/followers" 164 | }, 165 | "label": "servo:update-some" 166 | }, 167 | "commits_url": "https://api.github.com/repos/servo/servo/pulls/7062/commits", 168 | "changed_files": 3, 169 | "comments_url": "https://api.github.com/repos/servo/servo/issues/7062/comments", 170 | "html_url": "https://github.com/servo/servo/pull/7062", 171 | "updated_at": "2015-08-07T16:09:10Z", 172 | "base": { 173 | "repo": { 174 | "issues_url": "https://api.github.com/repos/servo/servo/issues{/number}", 175 | "has_wiki": true, 176 | "forks_url": "https://api.github.com/repos/servo/servo/forks", 177 | "mirror_url": null, 178 | "subscription_url": "https://api.github.com/repos/servo/servo/subscription", 179 | "merges_url": "https://api.github.com/repos/servo/servo/merges", 180 | "collaborators_url": "https://api.github.com/repos/servo/servo/collaborators{/collaborator}", 181 | "updated_at": "2015-08-07T09:37:49Z", 182 | "private": false, 183 | "pulls_url": "https://api.github.com/repos/servo/servo/pulls{/number}", 184 | "issue_comment_url": "https://api.github.com/repos/servo/servo/issues/comments{/number}", 185 | "full_name": "servo/servo", 186 | "owner": { 187 | "following_url": "https://api.github.com/users/servo/following{/other_user}", 188 | "gists_url": "https://api.github.com/users/servo/gists{/gist_id}", 189 | "organizations_url": "https://api.github.com/users/servo/orgs", 190 | "url": "https://api.github.com/users/servo", 191 | "events_url": "https://api.github.com/users/servo/events{/privacy}", 192 | "html_url": "https://github.com/servo", 193 | "subscriptions_url": "https://api.github.com/users/servo/subscriptions", 194 | "avatar_url": "https://avatars.githubusercontent.com/u/2566135?v=3", 195 | "repos_url": "https://api.github.com/users/servo/repos", 196 | "received_events_url": "https://api.github.com/users/servo/received_events", 197 | "gravatar_id": "", 198 | "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}", 199 | "site_admin": false, 200 | "login": "servo", 201 | "type": "Organization", 202 | "id": 2566135, 203 | "followers_url": "https://api.github.com/users/servo/followers" 204 | }, 205 | "statuses_url": "https://api.github.com/repos/servo/servo/statuses/{sha}", 206 | "id": 3390243, 207 | "keys_url": "https://api.github.com/repos/servo/servo/keys{/key_id}", 208 | "size": 1903023, 209 | "tags_url": "https://api.github.com/repos/servo/servo/tags", 210 | "issue_events_url": "https://api.github.com/repos/servo/servo/issues/events{/number}", 211 | "contributors_url": "https://api.github.com/repos/servo/servo/contributors", 212 | "downloads_url": "https://api.github.com/repos/servo/servo/downloads", 213 | "has_downloads": true, 214 | "assignees_url": "https://api.github.com/repos/servo/servo/assignees{/user}", 215 | "contents_url": "https://api.github.com/repos/servo/servo/contents/{+path}", 216 | "has_pages": false, 217 | "git_refs_url": "https://api.github.com/repos/servo/servo/git/refs{/sha}", 218 | "clone_url": "https://github.com/servo/servo.git", 219 | "watchers_count": 4571, 220 | "git_tags_url": "https://api.github.com/repos/servo/servo/git/tags{/sha}", 221 | "milestones_url": "https://api.github.com/repos/servo/servo/milestones{/number}", 222 | "stargazers_count": 4571, 223 | "homepage": "", 224 | "branches_url": "https://api.github.com/repos/servo/servo/branches{/branch}", 225 | "fork": false, 226 | "commits_url": "https://api.github.com/repos/servo/servo/commits{/sha}", 227 | "releases_url": "https://api.github.com/repos/servo/servo/releases{/id}", 228 | "description": "The Servo Browser Engine", 229 | "archive_url": "https://api.github.com/repos/servo/servo/{archive_format}{/ref}", 230 | "labels_url": "https://api.github.com/repos/servo/servo/labels{/name}", 231 | "events_url": "https://api.github.com/repos/servo/servo/events", 232 | "comments_url": "https://api.github.com/repos/servo/servo/comments{/number}", 233 | "html_url": "https://github.com/servo/servo", 234 | "forks": 717, 235 | "compare_url": "https://api.github.com/repos/servo/servo/compare/{base}...{head}", 236 | "trees_url": "https://api.github.com/repos/servo/servo/git/trees{/sha}", 237 | "git_url": "git://github.com/servo/servo.git", 238 | "svn_url": "https://github.com/servo/servo", 239 | "notifications_url": "https://api.github.com/repos/servo/servo/notifications{?since,all,participating}", 240 | "has_issues": true, 241 | "ssh_url": "git@github.com:servo/servo.git", 242 | "blobs_url": "https://api.github.com/repos/servo/servo/git/blobs{/sha}", 243 | "languages_url": "https://api.github.com/repos/servo/servo/languages", 244 | "hooks_url": "https://api.github.com/repos/servo/servo/hooks", 245 | "open_issues_count": 1057, 246 | "watchers": 4571, 247 | "name": "servo", 248 | "language": "Rust", 249 | "url": "https://api.github.com/repos/servo/servo", 250 | "created_at": "2012-02-08T19:07:25Z", 251 | "pushed_at": "2015-08-07T16:09:10Z", 252 | "forks_count": 717, 253 | "default_branch": "master", 254 | "teams_url": "https://api.github.com/repos/servo/servo/teams", 255 | "open_issues": 1057, 256 | "git_commits_url": "https://api.github.com/repos/servo/servo/git/commits{/sha}", 257 | "subscribers_url": "https://api.github.com/repos/servo/servo/subscribers", 258 | "stargazers_url": "https://api.github.com/repos/servo/servo/stargazers" 259 | }, 260 | "sha": "568bd92236e6eee0a3dfc077dccf74e4fdc79582", 261 | "ref": "master", 262 | "user": { 263 | "following_url": "https://api.github.com/users/servo/following{/other_user}", 264 | "gists_url": "https://api.github.com/users/servo/gists{/gist_id}", 265 | "organizations_url": "https://api.github.com/users/servo/orgs", 266 | "url": "https://api.github.com/users/servo", 267 | "events_url": "https://api.github.com/users/servo/events{/privacy}", 268 | "html_url": "https://github.com/servo", 269 | "subscriptions_url": "https://api.github.com/users/servo/subscriptions", 270 | "avatar_url": "https://avatars.githubusercontent.com/u/2566135?v=3", 271 | "repos_url": "https://api.github.com/users/servo/repos", 272 | "received_events_url": "https://api.github.com/users/servo/received_events", 273 | "gravatar_id": "", 274 | "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}", 275 | "site_admin": false, 276 | "login": "servo", 277 | "type": "Organization", 278 | "id": 2566135, 279 | "followers_url": "https://api.github.com/users/servo/followers" 280 | }, 281 | "label": "servo:master" 282 | }, 283 | "user": { 284 | "following_url": "https://api.github.com/users/Ms2ger/following{/other_user}", 285 | "gists_url": "https://api.github.com/users/Ms2ger/gists{/gist_id}", 286 | "organizations_url": "https://api.github.com/users/Ms2ger/orgs", 287 | "url": "https://api.github.com/users/Ms2ger", 288 | "events_url": "https://api.github.com/users/Ms2ger/events{/privacy}", 289 | "html_url": "https://github.com/Ms2ger", 290 | "subscriptions_url": "https://api.github.com/users/Ms2ger/subscriptions", 291 | "avatar_url": "https://avatars.githubusercontent.com/u/111161?v=3", 292 | "repos_url": "https://api.github.com/users/Ms2ger/repos", 293 | "received_events_url": "https://api.github.com/users/Ms2ger/received_events", 294 | "gravatar_id": "", 295 | "starred_url": "https://api.github.com/users/Ms2ger/starred{/owner}{/repo}", 296 | "site_admin": false, 297 | "login": "Ms2ger", 298 | "type": "User", 299 | "id": 111161, 300 | "followers_url": "https://api.github.com/users/Ms2ger/followers" 301 | }, 302 | "milestone": null, 303 | "locked": false, 304 | "merged_by": null, 305 | "url": "https://api.github.com/repos/servo/servo/pulls/7062", 306 | "mergeable_state": "stable", 307 | "created_at": "2015-08-07T07:37:38Z", 308 | "review_comments_url": "https://api.github.com/repos/servo/servo/pulls/7062/comments", 309 | "review_comments": 0, 310 | "review_comment_url": "https://api.github.com/repos/servo/servo/pulls/comments{/number}", 311 | "merged": false 312 | } 313 | }, 314 | "payload": { 315 | "number": 7062, 316 | "pull_request": { 317 | "draft": false, 318 | "mergeable": null, 319 | "base": { 320 | "repo": { 321 | "owner": { 322 | "login": "servo" 323 | }, 324 | "name": "servo" 325 | } 326 | } 327 | }, 328 | "action": "synchronize" 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /handlers/status_update/tests/synchronize_not_mergeable.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-review", 5 | "S-needs-rebase" 6 | ] 7 | }, 8 | "initial": { 9 | "labels": [ 10 | "S-needs-rebase" 11 | ], 12 | "pull_request": { 13 | "body": "This excludes:\r\n\r\n* tenacious, as it has not yet been updated to the current rustc\r\n (Manishearth/rust-tenacious#6);\r\n* ipc-channel, as it doesn't build on linux upstream.\n\n\n[\"Review](https://reviewable.io/reviews/servo/servo/7062)\n\n", 14 | "merge_commit_sha": "30f3785911255fde11ab91ef5c0a9fe741174aff", 15 | "number": 7062, 16 | "assignee": null, 17 | "mergeable": false, 18 | "closed_at": null, 19 | "additions": 483, 20 | "statuses_url": "https://api.github.com/repos/servo/servo/statuses/289b421490113278d323ef482d77a1cacb560ac7", 21 | "id": 41866663, 22 | "title": "Update most dependencies.", 23 | "comments": 3, 24 | "merged_at": null, 25 | "state": "open", 26 | "_links": { 27 | "review_comment": { 28 | "href": "https://api.github.com/repos/servo/servo/pulls/comments{/number}" 29 | }, 30 | "commits": { 31 | "href": "https://api.github.com/repos/servo/servo/pulls/7062/commits" 32 | }, 33 | "self": { 34 | "href": "https://api.github.com/repos/servo/servo/pulls/7062" 35 | }, 36 | "comments": { 37 | "href": "https://api.github.com/repos/servo/servo/issues/7062/comments" 38 | }, 39 | "html": { 40 | "href": "https://github.com/servo/servo/pull/7062" 41 | }, 42 | "review_comments": { 43 | "href": "https://api.github.com/repos/servo/servo/pulls/7062/comments" 44 | }, 45 | "issue": { 46 | "href": "https://api.github.com/repos/servo/servo/issues/7062" 47 | }, 48 | "statuses": { 49 | "href": "https://api.github.com/repos/servo/servo/statuses/289b421490113278d323ef482d77a1cacb560ac7" 50 | } 51 | }, 52 | "commits": 1, 53 | "diff_url": "https://github.com/servo/servo/pull/7062.diff", 54 | "issue_url": "https://api.github.com/repos/servo/servo/issues/7062", 55 | "patch_url": "https://github.com/servo/servo/pull/7062.patch", 56 | "deletions": 438, 57 | "head": { 58 | "repo": { 59 | "issues_url": "https://api.github.com/repos/servo/servo/issues{/number}", 60 | "has_wiki": true, 61 | "forks_url": "https://api.github.com/repos/servo/servo/forks", 62 | "mirror_url": null, 63 | "subscription_url": "https://api.github.com/repos/servo/servo/subscription", 64 | "merges_url": "https://api.github.com/repos/servo/servo/merges", 65 | "collaborators_url": "https://api.github.com/repos/servo/servo/collaborators{/collaborator}", 66 | "updated_at": "2015-08-07T09:37:49Z", 67 | "private": false, 68 | "pulls_url": "https://api.github.com/repos/servo/servo/pulls{/number}", 69 | "issue_comment_url": "https://api.github.com/repos/servo/servo/issues/comments{/number}", 70 | "full_name": "servo/servo", 71 | "owner": { 72 | "following_url": "https://api.github.com/users/servo/following{/other_user}", 73 | "gists_url": "https://api.github.com/users/servo/gists{/gist_id}", 74 | "organizations_url": "https://api.github.com/users/servo/orgs", 75 | "url": "https://api.github.com/users/servo", 76 | "events_url": "https://api.github.com/users/servo/events{/privacy}", 77 | "html_url": "https://github.com/servo", 78 | "subscriptions_url": "https://api.github.com/users/servo/subscriptions", 79 | "avatar_url": "https://avatars.githubusercontent.com/u/2566135?v=3", 80 | "repos_url": "https://api.github.com/users/servo/repos", 81 | "received_events_url": "https://api.github.com/users/servo/received_events", 82 | "gravatar_id": "", 83 | "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}", 84 | "site_admin": false, 85 | "login": "servo", 86 | "type": "Organization", 87 | "id": 2566135, 88 | "followers_url": "https://api.github.com/users/servo/followers" 89 | }, 90 | "statuses_url": "https://api.github.com/repos/servo/servo/statuses/{sha}", 91 | "id": 3390243, 92 | "keys_url": "https://api.github.com/repos/servo/servo/keys{/key_id}", 93 | "size": 1903023, 94 | "tags_url": "https://api.github.com/repos/servo/servo/tags", 95 | "issue_events_url": "https://api.github.com/repos/servo/servo/issues/events{/number}", 96 | "contributors_url": "https://api.github.com/repos/servo/servo/contributors", 97 | "downloads_url": "https://api.github.com/repos/servo/servo/downloads", 98 | "has_downloads": true, 99 | "assignees_url": "https://api.github.com/repos/servo/servo/assignees{/user}", 100 | "contents_url": "https://api.github.com/repos/servo/servo/contents/{+path}", 101 | "has_pages": false, 102 | "git_refs_url": "https://api.github.com/repos/servo/servo/git/refs{/sha}", 103 | "clone_url": "https://github.com/servo/servo.git", 104 | "watchers_count": 4571, 105 | "git_tags_url": "https://api.github.com/repos/servo/servo/git/tags{/sha}", 106 | "milestones_url": "https://api.github.com/repos/servo/servo/milestones{/number}", 107 | "stargazers_count": 4571, 108 | "homepage": "", 109 | "branches_url": "https://api.github.com/repos/servo/servo/branches{/branch}", 110 | "fork": false, 111 | "commits_url": "https://api.github.com/repos/servo/servo/commits{/sha}", 112 | "releases_url": "https://api.github.com/repos/servo/servo/releases{/id}", 113 | "description": "The Servo Browser Engine", 114 | "archive_url": "https://api.github.com/repos/servo/servo/{archive_format}{/ref}", 115 | "labels_url": "https://api.github.com/repos/servo/servo/labels{/name}", 116 | "events_url": "https://api.github.com/repos/servo/servo/events", 117 | "comments_url": "https://api.github.com/repos/servo/servo/comments{/number}", 118 | "html_url": "https://github.com/servo/servo", 119 | "forks": 717, 120 | "compare_url": "https://api.github.com/repos/servo/servo/compare/{base}...{head}", 121 | "trees_url": "https://api.github.com/repos/servo/servo/git/trees{/sha}", 122 | "git_url": "git://github.com/servo/servo.git", 123 | "svn_url": "https://github.com/servo/servo", 124 | "notifications_url": "https://api.github.com/repos/servo/servo/notifications{?since,all,participating}", 125 | "has_issues": true, 126 | "ssh_url": "git@github.com:servo/servo.git", 127 | "blobs_url": "https://api.github.com/repos/servo/servo/git/blobs{/sha}", 128 | "languages_url": "https://api.github.com/repos/servo/servo/languages", 129 | "hooks_url": "https://api.github.com/repos/servo/servo/hooks", 130 | "open_issues_count": 1057, 131 | "watchers": 4571, 132 | "name": "servo", 133 | "language": "Rust", 134 | "url": "https://api.github.com/repos/servo/servo", 135 | "created_at": "2012-02-08T19:07:25Z", 136 | "pushed_at": "2015-08-07T16:09:10Z", 137 | "forks_count": 717, 138 | "default_branch": "master", 139 | "teams_url": "https://api.github.com/repos/servo/servo/teams", 140 | "open_issues": 1057, 141 | "git_commits_url": "https://api.github.com/repos/servo/servo/git/commits{/sha}", 142 | "subscribers_url": "https://api.github.com/repos/servo/servo/subscribers", 143 | "stargazers_url": "https://api.github.com/repos/servo/servo/stargazers" 144 | }, 145 | "sha": "289b421490113278d323ef482d77a1cacb560ac7", 146 | "ref": "update-some", 147 | "user": { 148 | "following_url": "https://api.github.com/users/servo/following{/other_user}", 149 | "gists_url": "https://api.github.com/users/servo/gists{/gist_id}", 150 | "organizations_url": "https://api.github.com/users/servo/orgs", 151 | "url": "https://api.github.com/users/servo", 152 | "events_url": "https://api.github.com/users/servo/events{/privacy}", 153 | "html_url": "https://github.com/servo", 154 | "subscriptions_url": "https://api.github.com/users/servo/subscriptions", 155 | "avatar_url": "https://avatars.githubusercontent.com/u/2566135?v=3", 156 | "repos_url": "https://api.github.com/users/servo/repos", 157 | "received_events_url": "https://api.github.com/users/servo/received_events", 158 | "gravatar_id": "", 159 | "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}", 160 | "site_admin": false, 161 | "login": "servo", 162 | "type": "Organization", 163 | "id": 2566135, 164 | "followers_url": "https://api.github.com/users/servo/followers" 165 | }, 166 | "label": "servo:update-some" 167 | }, 168 | "commits_url": "https://api.github.com/repos/servo/servo/pulls/7062/commits", 169 | "changed_files": 3, 170 | "comments_url": "https://api.github.com/repos/servo/servo/issues/7062/comments", 171 | "html_url": "https://github.com/servo/servo/pull/7062", 172 | "updated_at": "2015-08-07T16:09:10Z", 173 | "base": { 174 | "repo": { 175 | "issues_url": "https://api.github.com/repos/servo/servo/issues{/number}", 176 | "has_wiki": true, 177 | "forks_url": "https://api.github.com/repos/servo/servo/forks", 178 | "mirror_url": null, 179 | "subscription_url": "https://api.github.com/repos/servo/servo/subscription", 180 | "merges_url": "https://api.github.com/repos/servo/servo/merges", 181 | "collaborators_url": "https://api.github.com/repos/servo/servo/collaborators{/collaborator}", 182 | "updated_at": "2015-08-07T09:37:49Z", 183 | "private": false, 184 | "pulls_url": "https://api.github.com/repos/servo/servo/pulls{/number}", 185 | "issue_comment_url": "https://api.github.com/repos/servo/servo/issues/comments{/number}", 186 | "full_name": "servo/servo", 187 | "owner": { 188 | "following_url": "https://api.github.com/users/servo/following{/other_user}", 189 | "gists_url": "https://api.github.com/users/servo/gists{/gist_id}", 190 | "organizations_url": "https://api.github.com/users/servo/orgs", 191 | "url": "https://api.github.com/users/servo", 192 | "events_url": "https://api.github.com/users/servo/events{/privacy}", 193 | "html_url": "https://github.com/servo", 194 | "subscriptions_url": "https://api.github.com/users/servo/subscriptions", 195 | "avatar_url": "https://avatars.githubusercontent.com/u/2566135?v=3", 196 | "repos_url": "https://api.github.com/users/servo/repos", 197 | "received_events_url": "https://api.github.com/users/servo/received_events", 198 | "gravatar_id": "", 199 | "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}", 200 | "site_admin": false, 201 | "login": "servo", 202 | "type": "Organization", 203 | "id": 2566135, 204 | "followers_url": "https://api.github.com/users/servo/followers" 205 | }, 206 | "statuses_url": "https://api.github.com/repos/servo/servo/statuses/{sha}", 207 | "id": 3390243, 208 | "keys_url": "https://api.github.com/repos/servo/servo/keys{/key_id}", 209 | "size": 1903023, 210 | "tags_url": "https://api.github.com/repos/servo/servo/tags", 211 | "issue_events_url": "https://api.github.com/repos/servo/servo/issues/events{/number}", 212 | "contributors_url": "https://api.github.com/repos/servo/servo/contributors", 213 | "downloads_url": "https://api.github.com/repos/servo/servo/downloads", 214 | "has_downloads": true, 215 | "assignees_url": "https://api.github.com/repos/servo/servo/assignees{/user}", 216 | "contents_url": "https://api.github.com/repos/servo/servo/contents/{+path}", 217 | "has_pages": false, 218 | "git_refs_url": "https://api.github.com/repos/servo/servo/git/refs{/sha}", 219 | "clone_url": "https://github.com/servo/servo.git", 220 | "watchers_count": 4571, 221 | "git_tags_url": "https://api.github.com/repos/servo/servo/git/tags{/sha}", 222 | "milestones_url": "https://api.github.com/repos/servo/servo/milestones{/number}", 223 | "stargazers_count": 4571, 224 | "homepage": "", 225 | "branches_url": "https://api.github.com/repos/servo/servo/branches{/branch}", 226 | "fork": false, 227 | "commits_url": "https://api.github.com/repos/servo/servo/commits{/sha}", 228 | "releases_url": "https://api.github.com/repos/servo/servo/releases{/id}", 229 | "description": "The Servo Browser Engine", 230 | "archive_url": "https://api.github.com/repos/servo/servo/{archive_format}{/ref}", 231 | "labels_url": "https://api.github.com/repos/servo/servo/labels{/name}", 232 | "events_url": "https://api.github.com/repos/servo/servo/events", 233 | "comments_url": "https://api.github.com/repos/servo/servo/comments{/number}", 234 | "html_url": "https://github.com/servo/servo", 235 | "forks": 717, 236 | "compare_url": "https://api.github.com/repos/servo/servo/compare/{base}...{head}", 237 | "trees_url": "https://api.github.com/repos/servo/servo/git/trees{/sha}", 238 | "git_url": "git://github.com/servo/servo.git", 239 | "svn_url": "https://github.com/servo/servo", 240 | "notifications_url": "https://api.github.com/repos/servo/servo/notifications{?since,all,participating}", 241 | "has_issues": true, 242 | "ssh_url": "git@github.com:servo/servo.git", 243 | "blobs_url": "https://api.github.com/repos/servo/servo/git/blobs{/sha}", 244 | "languages_url": "https://api.github.com/repos/servo/servo/languages", 245 | "hooks_url": "https://api.github.com/repos/servo/servo/hooks", 246 | "open_issues_count": 1057, 247 | "watchers": 4571, 248 | "name": "servo", 249 | "language": "Rust", 250 | "url": "https://api.github.com/repos/servo/servo", 251 | "created_at": "2012-02-08T19:07:25Z", 252 | "pushed_at": "2015-08-07T16:09:10Z", 253 | "forks_count": 717, 254 | "default_branch": "master", 255 | "teams_url": "https://api.github.com/repos/servo/servo/teams", 256 | "open_issues": 1057, 257 | "git_commits_url": "https://api.github.com/repos/servo/servo/git/commits{/sha}", 258 | "subscribers_url": "https://api.github.com/repos/servo/servo/subscribers", 259 | "stargazers_url": "https://api.github.com/repos/servo/servo/stargazers" 260 | }, 261 | "sha": "568bd92236e6eee0a3dfc077dccf74e4fdc79582", 262 | "ref": "master", 263 | "user": { 264 | "following_url": "https://api.github.com/users/servo/following{/other_user}", 265 | "gists_url": "https://api.github.com/users/servo/gists{/gist_id}", 266 | "organizations_url": "https://api.github.com/users/servo/orgs", 267 | "url": "https://api.github.com/users/servo", 268 | "events_url": "https://api.github.com/users/servo/events{/privacy}", 269 | "html_url": "https://github.com/servo", 270 | "subscriptions_url": "https://api.github.com/users/servo/subscriptions", 271 | "avatar_url": "https://avatars.githubusercontent.com/u/2566135?v=3", 272 | "repos_url": "https://api.github.com/users/servo/repos", 273 | "received_events_url": "https://api.github.com/users/servo/received_events", 274 | "gravatar_id": "", 275 | "starred_url": "https://api.github.com/users/servo/starred{/owner}{/repo}", 276 | "site_admin": false, 277 | "login": "servo", 278 | "type": "Organization", 279 | "id": 2566135, 280 | "followers_url": "https://api.github.com/users/servo/followers" 281 | }, 282 | "label": "servo:master" 283 | }, 284 | "user": { 285 | "following_url": "https://api.github.com/users/Ms2ger/following{/other_user}", 286 | "gists_url": "https://api.github.com/users/Ms2ger/gists{/gist_id}", 287 | "organizations_url": "https://api.github.com/users/Ms2ger/orgs", 288 | "url": "https://api.github.com/users/Ms2ger", 289 | "events_url": "https://api.github.com/users/Ms2ger/events{/privacy}", 290 | "html_url": "https://github.com/Ms2ger", 291 | "subscriptions_url": "https://api.github.com/users/Ms2ger/subscriptions", 292 | "avatar_url": "https://avatars.githubusercontent.com/u/111161?v=3", 293 | "repos_url": "https://api.github.com/users/Ms2ger/repos", 294 | "received_events_url": "https://api.github.com/users/Ms2ger/received_events", 295 | "gravatar_id": "", 296 | "starred_url": "https://api.github.com/users/Ms2ger/starred{/owner}{/repo}", 297 | "site_admin": false, 298 | "login": "Ms2ger", 299 | "type": "User", 300 | "id": 111161, 301 | "followers_url": "https://api.github.com/users/Ms2ger/followers" 302 | }, 303 | "milestone": null, 304 | "locked": false, 305 | "merged_by": null, 306 | "url": "https://api.github.com/repos/servo/servo/pulls/7062", 307 | "mergeable_state": "stable", 308 | "created_at": "2015-08-07T07:37:38Z", 309 | "review_comments_url": "https://api.github.com/repos/servo/servo/pulls/7062/comments", 310 | "review_comments": 0, 311 | "review_comment_url": "https://api.github.com/repos/servo/servo/pulls/comments{/number}", 312 | "merged": false 313 | } 314 | }, 315 | "payload": { 316 | "number": 7062, 317 | "pull_request": { 318 | "draft": false, 319 | "mergeable": null, 320 | "base": { 321 | "repo": { 322 | "owner": { 323 | "login": "servo" 324 | }, 325 | "name": "servo" 326 | } 327 | } 328 | }, 329 | "action": "synchronize" 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /handlers/status_update/tests/unmergeable_pr_ready_for_review.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "labels": [ 4 | "S-awaiting-review", 5 | "S-needs-rebase" 6 | ] 7 | }, 8 | "initial": {}, 9 | "payload": { 10 | "number": 7076, 11 | "pull_request": { 12 | "draft": false, 13 | "mergeable": false, 14 | "base": { 15 | "repo": { 16 | "owner": { 17 | "login": "servo" 18 | }, 19 | "name": "servo" 20 | } 21 | } 22 | }, 23 | "action": "ready_for_review" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /handlers/unsafe/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from eventhandler import EventHandler 4 | 5 | 6 | unsafe_warning_msg = ('These commits modify **unsafe code**. ' 7 | 'Please review it carefully!') 8 | 9 | 10 | class UnsafeHandler(EventHandler): 11 | def on_pr_opened(self, api, payload): 12 | return 13 | for line in api.get_added_lines(): 14 | if line.find('unsafe ') > -1: 15 | self.warn(unsafe_warning_msg) 16 | return 17 | 18 | 19 | handler_interface = UnsafeHandler 20 | -------------------------------------------------------------------------------- /handlers/unsafe/tests/new_pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": { 3 | "comments": 1 4 | }, 5 | "initial": { 6 | "diff": "+ unsafe fn foo()" 7 | }, 8 | "payload": { 9 | "number": 7076, 10 | "pull_request": { 11 | "draft": false, 12 | "base": { 13 | "repo": { 14 | "owner": { 15 | "login": "servo" 16 | }, 17 | "name": "servo" 18 | } 19 | } 20 | }, 21 | "action": "opened" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /handlers/watchers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from collections import defaultdict 4 | import os 5 | import fnmatch 6 | 7 | from eventhandler import EventHandler 8 | from helpers import get_people_from_config 9 | 10 | WATCHERS_CONFIG_FILE = os.path.join(os.path.dirname(__file__), 'watchers.ini') 11 | 12 | 13 | def build_message(mentions): 14 | message = ['Heads up! This PR modifies the following files:'] 15 | for (watcher, file_names) in mentions.items(): 16 | message.append(" * @{}: {}".format(watcher, ', '.join(file_names[:5]))) 17 | remaining = len(file_names[5:]) 18 | if remaining: 19 | message[-1] += " and {} more".format(remaining) 20 | 21 | return '\n'.join(message) 22 | 23 | 24 | class WatchersHandler(EventHandler): 25 | def on_pr_opened(self, api, payload): 26 | if "pull_request" not in payload: 27 | return 28 | 29 | user = payload['pull_request']['user']['login'] 30 | 31 | watchers = get_people_from_config(api, WATCHERS_CONFIG_FILE) 32 | if not watchers: 33 | return 34 | 35 | mentions = defaultdict(list) 36 | 37 | for (watcher, watched_files) in watchers: 38 | watched_files = watched_files.split(' ') 39 | blacklisted_files = [] 40 | 41 | for watched_file in watched_files: 42 | if watched_file.startswith('-'): 43 | blacklisted_files.append(watched_file[1:]) 44 | for blacklisted_file in blacklisted_files: 45 | watched_files.remove('-' + blacklisted_file) 46 | 47 | for filepath in api.get_changed_files(): 48 | comment = False 49 | for watched_file in watched_files: 50 | if fnmatch.fnmatch(filepath, watched_file): 51 | comment = True 52 | for blacklisted_file in blacklisted_files: 53 | if (fnmatch.fnmatch(filepath, blacklisted_file)): 54 | comment = False 55 | if (comment and user != watcher): 56 | mentions[watcher].append(filepath) 57 | if not mentions: 58 | return 59 | 60 | message = build_message(mentions) 61 | api.post_comment(message) 62 | 63 | 64 | handler_interface = WatchersHandler 65 | -------------------------------------------------------------------------------- /handlers/watchers/tests/new_pr_author_is_not_watcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "comments": 1 5 | }, 6 | { 7 | "comments": 1 8 | }, 9 | { 10 | "comments": 0 11 | }, 12 | { 13 | "comments": 1 14 | }, 15 | { 16 | "comments": 0 17 | }, 18 | { 19 | "comments": 1 20 | }, 21 | { 22 | "comments": 1 23 | } 24 | ], 25 | "initial": [ 26 | { 27 | "diff": "diff --git a/watched/dir/file.rs b/watched/dir/file.rs" 28 | }, 29 | { 30 | "diff": "diff --git a/watched/dir/file.rs /dev/null" 31 | }, 32 | { 33 | "diff": "diff --git a/not-watched/dir/not-watched-file.rs b/not-watched/dir/not-watched-file.rs" 34 | }, 35 | { 36 | "diff": "diff --git a/not-watched/dir/not-watched-file.rs b/not-watched/dir/watched-file.rs" 37 | }, 38 | { 39 | "diff": "diff --git a/blacklisted/dir/watched-file.rs b/blacklisted/dir/watched-file.rs" 40 | }, 41 | { 42 | "diff": "diff --git a/watched/dir/blacklisted-file.rs b/watched/dir/file.rs" 43 | }, 44 | { 45 | "diff": "diff --git a/watched/dir/file.rs b/watched/dir/blacklisted-file.rs" 46 | } 47 | ], 48 | "payload": { 49 | "number": 7076, 50 | "pull_request": { 51 | "draft": false, 52 | "base": { 53 | "repo": { 54 | "owner": { 55 | "login": "servo" 56 | }, 57 | "name": "highfive-test" 58 | } 59 | }, 60 | "user": { 61 | "login": "not_a_watcher" 62 | } 63 | }, 64 | "action": "opened" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /handlers/watchers/tests/new_pr_author_is_watcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "comments": 0 5 | }, 6 | { 7 | "comments": 0 8 | }, 9 | { 10 | "comments": 0 11 | }, 12 | { 13 | "comments": 0 14 | }, 15 | { 16 | "comments": 0 17 | }, 18 | { 19 | "comments": 0 20 | }, 21 | { 22 | "comments": 0 23 | } 24 | ], 25 | "initial": [ 26 | { 27 | "diff": "diff --git a/watched/dir/file.rs b/watched/dir/file.rs" 28 | }, 29 | { 30 | "diff": "diff --git a/watched/dir/file.rs /dev/null" 31 | }, 32 | { 33 | "diff": "diff --git a/not-watched/dir/not-watched-file.rs b/not-watched/dir/not-watched-file.rs" 34 | }, 35 | { 36 | "diff": "diff --git a/not-watched/dir/not-watched-file.rs b/not-watched/dir/watched-file.rs" 37 | }, 38 | { 39 | "diff": "diff --git a/blacklisted/dir/watched-file.rs b/blacklisted/dir/watched-file.rs" 40 | }, 41 | { 42 | "diff": "diff --git a/watched/dir/blacklisted-file.rs b/watched/dir/file.rs" 43 | }, 44 | { 45 | "diff": "diff --git a/watched/dir/file.rs b/watched/dir/blacklisted-file.rs" 46 | } 47 | ], 48 | "payload": { 49 | "number": 7076, 50 | "pull_request": { 51 | "draft": false, 52 | "base": { 53 | "repo": { 54 | "owner": { 55 | "login": "servo" 56 | }, 57 | "name": "highfive-test" 58 | } 59 | }, 60 | "user": { 61 | "login": "test_user" 62 | } 63 | }, 64 | "action": "opened" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /handlers/watchers/tests/new_pr_wildcards_author_is_not_watcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "comments": 1 5 | }, 6 | { 7 | "comments": 1 8 | }, 9 | { 10 | "comments": 0 11 | }, 12 | { 13 | "comments": 0 14 | }, 15 | { 16 | "comments": 0 17 | }, 18 | { 19 | "comments": 1 20 | }, 21 | { 22 | "comments": 0 23 | }, 24 | { 25 | "comments": 0 26 | } 27 | ], 28 | "initial": [ 29 | { 30 | "diff": "diff --git a/watched/dir/file.rs b/watched/dir/file.rs" 31 | }, 32 | { 33 | "diff": "diff --git a/not-watched/dir/watched-file.rs b/not-watched/dir/watched-file.rs" 34 | }, 35 | { 36 | "diff": "diff --git a/blacklisted/dir/watched-file.rs b/blacklisted/dir/watched-file.rs" 37 | }, 38 | { 39 | "diff": "diff --git a/blacklisted/dir/file.rs b/blacklisted/dir/file.py" 40 | }, 41 | { 42 | "diff": "diff --git a/watched/dir/blacklisted-file.rs b/watched/dir/blacklisted-file.rs" 43 | }, 44 | { 45 | "diff": "diff --git a/not-watched/dir/file.txt b/not-watched/dir/file.txt" 46 | }, 47 | { 48 | "diff": "diff --git a/not-watched/dir/file.rs b/not-watched/dir/file.rs" 49 | }, 50 | { 51 | "diff": "diff --git a/not-watched/dir/black-listed-file.txt b/not-watched/dir/black-listed-file.txt" 52 | } 53 | ], 54 | "payload": { 55 | "number": 7076, 56 | "pull_request": { 57 | "draft": false, 58 | "base": { 59 | "repo": { 60 | "owner": { 61 | "login": "servo" 62 | }, 63 | "name": "highfive-test" 64 | } 65 | }, 66 | "user": { 67 | "login": "not_test_user" 68 | } 69 | }, 70 | "action": "opened" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /handlers/watchers/tests/new_pr_wildcards_author_is_watcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "expected": [ 3 | { 4 | "comments": 0 5 | }, 6 | { 7 | "comments": 0 8 | }, 9 | { 10 | "comments": 0 11 | }, 12 | { 13 | "comments": 0 14 | }, 15 | { 16 | "comments": 0 17 | }, 18 | { 19 | "comments": 0 20 | }, 21 | { 22 | "comments": 0 23 | }, 24 | { 25 | "comments": 0 26 | } 27 | ], 28 | "initial": [ 29 | { 30 | "diff": "diff --git a/watched/dir/file.rs b/watched/dir/file.rs" 31 | }, 32 | { 33 | "diff": "diff --git a/not-watched/dir/watched-file.rs b/not-watched/dir/watched-file.rs" 34 | }, 35 | { 36 | "diff": "diff --git a/blacklisted/dir/watched-file.rs b/blacklisted/dir/watched-file.rs" 37 | }, 38 | { 39 | "diff": "diff --git a/blacklisted/dir/file.rs b/blacklisted/dir/file.py" 40 | }, 41 | { 42 | "diff": "diff --git a/watched/dir/blacklisted-file.rs b/watched/dir/blacklisted-file.rs" 43 | }, 44 | { 45 | "diff": "diff --git a/not-watched/dir/file.txt b/not-watched/dir/file.txt" 46 | }, 47 | { 48 | "diff": "diff --git a/not-watched/dir/file.rs b/not-watched/dir/file.rs" 49 | }, 50 | { 51 | "diff": "diff --git a/not-watched/dir/black-listed-file.txt b/not-watched/dir/black-listed-file.txt" 52 | } 53 | ], 54 | "payload": { 55 | "number": 7076, 56 | "pull_request": { 57 | "draft": false, 58 | "base": { 59 | "repo": { 60 | "owner": { 61 | "login": "servo" 62 | }, 63 | "name": "highfive-test" 64 | } 65 | }, 66 | "user": { 67 | "login": "test_user" 68 | } 69 | }, 70 | "action": "opened" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /handlers/watchers/watchers.ini: -------------------------------------------------------------------------------- 1 | [servo/servo] 2 | emilio = 3 | 4 | [servo/highfive] 5 | jdm = 6 | 7 | [servo/highfive-test] 8 | test_user = watched/dir/* not-watched/dir/watched-file.rs blacklisted/dir/watched-file.rs -blacklisted/dir/* -watched/dir/blacklisted-file.rs not-watched/dir/*.txt -not-watched/dir/black-listed-file.txt 9 | -------------------------------------------------------------------------------- /helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from configparser import ConfigParser 4 | import os 5 | 6 | COLLABORATORS_CONFIG_FILE = os.path.join(os.path.dirname(__file__), 7 | 'collaborators.ini') 8 | 9 | 10 | _test_path_roots = ['a/', 'b/'] 11 | 12 | 13 | def get_people_from_config(api, config_abs_path): 14 | ''' 15 | Gets the people listed under a particular repo from a config file. 16 | Note that the names (despite how they're in the file) will always 17 | be parsed to 'lowercase'. 18 | ''' 19 | config = ConfigParser() 20 | config.read(config_abs_path) 21 | repo = api.owner + '/' + api.repo 22 | 23 | try: 24 | return config.items(repo) 25 | except ConfigParser.NoSectionError: 26 | return [] # No people 27 | 28 | 29 | def get_collaborators(api): 30 | config_items = get_people_from_config(api, COLLABORATORS_CONFIG_FILE) 31 | return [username for (username, _) in config_items] 32 | 33 | 34 | def is_addition(diff_line): 35 | """ 36 | Checks if a line from a unified diff is an addition. 37 | """ 38 | return diff_line.startswith('+') and not diff_line.startswith('+++') 39 | 40 | 41 | def normalize_file_path(filepath): 42 | """ 43 | Strip any leading/training whitespace. 44 | Remove any test directories from the start of the path 45 | """ 46 | if filepath is None or filepath.strip() == '': 47 | return None 48 | filepath = filepath.strip() 49 | for prefix in _test_path_roots: 50 | if filepath.startswith(prefix): 51 | return filepath[len(prefix):] 52 | return filepath 53 | 54 | 55 | def linear_search(sequence, element, callback=lambda thing: thing): 56 | """ 57 | The 'in' operator also does a linear search over a sequence, but it checks 58 | for the exact match of the given object, whereas this makes use of '==', 59 | which calls the '__eq__' magic method, which could've been overridden in 60 | a custom class (which is the case for our test lint) 61 | """ 62 | for thing in sequence: 63 | if element == thing: # element could have an overridden '__eq__' 64 | callback(thing) 65 | -------------------------------------------------------------------------------- /json_cleanup.py: -------------------------------------------------------------------------------- 1 | NODE_SEP = ' -> ' 2 | 3 | 4 | def visit_nodes(node): # simple recursive tree traversal 5 | if hasattr(node, 'mark'): # it's already a NodeMarker 6 | return node 7 | if hasattr(node, '__iter__') and not isinstance(node, str): 8 | iterator = range(len(node)) if isinstance(node, list) else node 9 | for thing in iterator: 10 | node[thing] = visit_nodes(node[thing]) 11 | return NodeMarker(node) 12 | 13 | 14 | # We need the roots of each node, so that we can trace our way back to the 15 | # root from a specific node (marking nodes along the way). 16 | # Since `visit_nodes` makes a pre-order traversal, it assigns `NodeMarker` 17 | # to each node from inside-out, which makes it difficult to assign roots 18 | # So, we do another traversal to store the references of the root nodes 19 | def assign_roots(marker_node, root=None): 20 | node = marker_node._node 21 | if hasattr(node, '__iter__') and not isinstance(node, str): 22 | iterator = range(len(node)) if isinstance(node, list) else node 23 | for thing in iterator: 24 | assign_roots(node[thing], marker_node) 25 | marker_node._root = root 26 | 27 | 28 | class NodeMarker(object): 29 | def __init__(self, node, root=None): 30 | self._root = root 31 | self._node = node # actual value 32 | self._is_used = False # marker 33 | 34 | def mark(self): 35 | self._is_used = True 36 | root = self._root 37 | while root and not root._is_used: 38 | root._is_used = True 39 | root = root._root 40 | 41 | def get_object(self, obj): 42 | return obj._node if hasattr(obj, 'mark') else obj 43 | 44 | # The following methods blindly assume that the method is supported by the 45 | # particular type (i.e., exceptions should be handled explicitly) 46 | 47 | def lower(self): 48 | return str(self).lower() 49 | 50 | # if you access the element in the usual way, then "bam!" 51 | def __getitem__(self, key): 52 | self._node[key].mark() # it will be marked as used! 53 | return self._node[key] 54 | 55 | def get(self, key, default=None): 56 | if key in self._node: 57 | self._node[key].mark() 58 | return self._node.get(key, default) 59 | 60 | def __setitem__(self, key, val): 61 | self._node[key] = visit_nodes(val) 62 | 63 | def __hash__(self): 64 | return hash(self._node) 65 | 66 | def __iter__(self): 67 | return iter(self._node) 68 | 69 | def __eq__(self, other): 70 | return self._node == self.get_object(other) 71 | 72 | def __ne__(self, other): 73 | return self._node != self.get_object(other) 74 | 75 | def __add__(self, other): 76 | return self._node + self.get_object(other) 77 | 78 | def __mod__(self, other): 79 | return self._node % self.get_object(other) 80 | 81 | def __contains__(self, other): 82 | other = self.get_object(other) 83 | # since string is also a sequence in python, we shouldn't iterate 84 | # over it and check the individual characters 85 | if isinstance(self._node, str) or isinstance(self._node, bytes): 86 | return other in self._node 87 | 88 | for idx, thing in enumerate(self._node): 89 | if thing == other: 90 | if isinstance(self._node, list): 91 | self._node[idx].mark() 92 | else: 93 | self._node[thing].mark() 94 | return True 95 | return False 96 | 97 | def __str__(self): 98 | return str(self._node) 99 | 100 | def __int__(self): 101 | return int(self._node) 102 | 103 | 104 | class JsonCleaner(object): 105 | def __init__(self, json_obj): 106 | self.unused = 0 107 | self.json = visit_nodes(json_obj) 108 | assign_roots(self.json) 109 | 110 | def clean(self, warn=True): 111 | return self._filter_nodes(self.json, warn) 112 | 113 | def _filter_nodes(self, marker_node, warn, path=''): 114 | if marker_node._is_used: 115 | node = marker_node._node 116 | if hasattr(node, '__iter__') and not isinstance(node, str): 117 | # it's either 'list' or 'dict' when it comes to JSONs 118 | removed = 0 119 | iterator = range(len(node)) if isinstance(node, list) \ 120 | else node.keys() 121 | for thing in iterator: 122 | new_path = path + str(thing) + NODE_SEP 123 | # since lists maintain order, once we pop them, 124 | # we decrement their indices as their length is reduced 125 | if isinstance(node, list): 126 | thing -= removed 127 | node[thing] = self._filter_nodes(node[thing], 128 | warn, new_path) 129 | if node[thing] == (): 130 | self.unused += 1 131 | if warn: 132 | new_path = new_path.strip(NODE_SEP) 133 | print('unused node at "%s"' % new_path) 134 | node.pop(thing) 135 | removed += 1 136 | return node 137 | return () 138 | -------------------------------------------------------------------------------- /newpr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import absolute_import, print_function 4 | 5 | from base64 import standard_b64encode 6 | import cgi 7 | from configparser import RawConfigParser 8 | import contextlib 9 | import gzip 10 | try: 11 | import simplejson as json 12 | except ImportError: 13 | import json 14 | from io import StringIO 15 | from urllib import request, error 16 | 17 | import eventhandler 18 | from helpers import is_addition, normalize_file_path 19 | 20 | DIFF_HEADER_PREFIX = 'diff --git ' 21 | 22 | 23 | class APIProvider(object): 24 | def __init__(self, payload, user): 25 | (owner, repo, issue) = extract_globals_from_payload(payload) 26 | self.owner = owner 27 | self.repo = repo 28 | self.issue = issue 29 | self.user = user 30 | self.changed_files = None 31 | 32 | def is_new_contributor(self, username): 33 | raise NotImplementedError 34 | 35 | def post_comment(self, body): 36 | raise NotImplementedError 37 | 38 | def add_label(self, label): 39 | raise NotImplementedError 40 | 41 | def remove_label(self, label): 42 | raise NotImplementedError 43 | 44 | def get_labels(self): 45 | raise NotImplementedError 46 | 47 | def get_diff(self): 48 | return NotImplementedError 49 | 50 | def set_assignee(self, assignee): 51 | raise NotImplementedError 52 | 53 | def get_pull(self): 54 | raise NotImplementedError 55 | 56 | def get_page_content(self, url): 57 | raise NotImplementedError 58 | 59 | def get_diff_headers(self): 60 | diff = self.get_diff() 61 | for line in diff.splitlines(): 62 | if line.startswith(DIFF_HEADER_PREFIX): 63 | yield line 64 | 65 | def get_changed_files(self): 66 | if self.changed_files is None: 67 | changed_files = [] 68 | for line in self.get_diff_headers(): 69 | files = line.split(DIFF_HEADER_PREFIX)[-1].split(' ') 70 | changed_files.extend(files) 71 | 72 | # And get unique values using `set()` 73 | normalized = map(normalize_file_path, changed_files) 74 | self.changed_files = set(f for f in normalized if f is not None) 75 | return self.changed_files 76 | 77 | def get_added_lines(self): 78 | diff = self.get_diff() 79 | for line in diff.splitlines(): 80 | if is_addition(line): 81 | # prefix of one or two pluses (+) 82 | yield line 83 | 84 | 85 | class GithubAPIProvider(APIProvider): 86 | BASE_URL = "https://api.github.com/repos/" 87 | contributors_url = BASE_URL + "%s/%s/contributors?per_page=400" 88 | post_comment_url = BASE_URL + "%s/%s/issues/%s/comments" 89 | collaborators_url = BASE_URL + "%s/%s/collaborators" 90 | issue_url = BASE_URL + "%s/%s/issues/%s" 91 | get_label_url = BASE_URL + "%s/%s/issues/%s/labels" 92 | add_label_url = BASE_URL + "%s/%s/issues/%s/labels" 93 | remove_label_url = BASE_URL + "%s/%s/issues/%s/labels/%s" 94 | 95 | def __init__(self, payload, user, token): 96 | APIProvider.__init__(self, payload, user) 97 | self.token = token 98 | self._labels = None 99 | self._diff = None 100 | self.diff_url = None 101 | self.pull_url = None 102 | if "issue" in payload and "pull_request" in payload["issue"]: 103 | self.diff_url = payload["issue"]["pull_request"]["diff_url"] 104 | self.pull_url = payload["issue"]["pull_request"]["url"] 105 | if "pull_request" in payload: 106 | self.diff_url = payload["pull_request"]["diff_url"] 107 | self.pull_url = payload["pull_request"]["url"] 108 | 109 | def api_req(self, method, url, data=None, media_type=None): 110 | data = None if not data else json.dumps(data).encode('utf-8') 111 | headers = {} if not data else {'Content-Type': 'application/json'} 112 | req = request.Request(url, data, headers) 113 | req.get_method = lambda: method 114 | if self.token: 115 | authorization = '%s:%s' % (self.user, self.token) 116 | encoded = authorization.encode('utf-8') 117 | decoded = standard_b64encode(encoded).decode('utf-8') 118 | base64string = decoded.replace('\n', '') 119 | req.add_header("Authorization", "Basic %s" % base64string) 120 | 121 | if media_type: 122 | req.add_header("Accept", media_type) 123 | f = request.urlopen(req) 124 | header = f.info() 125 | if header.get('Content-Encoding') == 'gzip': 126 | buf = StringIO(f.read()) 127 | f = gzip.GzipFile(fileobj=buf) 128 | return {"header": header, "body": f.read()} 129 | 130 | # This function is adapted from https://github.com/kennethreitz/requests/blob/209a871b638f85e2c61966f82e547377ed4260d9/requests/utils.py#L562 # noqa 131 | # Licensed under Apache 2.0: http://www.apache.org/licenses/LICENSE-2.0 132 | def parse_header_links(self, value): 133 | if not value: 134 | return None 135 | 136 | links = {} 137 | replace_chars = " '\"" 138 | for val in value.split(","): 139 | try: 140 | url, params = val.split(";", 1) 141 | except ValueError: 142 | url, params = val, '' 143 | 144 | url = url.strip("<> '\"") 145 | 146 | for param in params.split(";"): 147 | try: 148 | key, value = param.split("=") 149 | except ValueError: 150 | break 151 | key = key.strip(replace_chars) 152 | if key == 'rel': 153 | links[value.strip(replace_chars)] = url 154 | 155 | return links 156 | 157 | def is_new_contributor(self, username): 158 | url = self.contributors_url % (self.owner, self.repo) 159 | # iterate through the pages to try and find the contributor 160 | while True: 161 | stats_raw = self.api_req("GET", url) 162 | stats = json.loads(stats_raw['body']) 163 | links = self.parse_header_links(stats_raw['header'].get('Link')) 164 | 165 | for contributor in stats: 166 | if contributor['login'] == username: 167 | return False 168 | 169 | if not links or 'next' not in links: 170 | return True 171 | url = links['next'] 172 | 173 | def post_comment(self, body): 174 | url = self.post_comment_url % (self.owner, self.repo, self.issue) 175 | try: 176 | self.api_req("POST", url, {"body": body}) 177 | except error.HTTPError as e: 178 | if e.code == 201: 179 | pass 180 | else: 181 | raise e 182 | 183 | def add_label(self, label): 184 | url = self.add_label_url % (self.owner, self.repo, self.issue) 185 | if self._labels: 186 | self._labels += [label] 187 | try: 188 | self.api_req("POST", url, [label]) 189 | except error.HTTPError as e: 190 | if e.code == 201: 191 | pass 192 | else: 193 | raise e 194 | 195 | def remove_label(self, label): 196 | url = self.remove_label_url % (self.owner, self.repo, self.issue, 197 | label) 198 | if self._labels and label in self._labels: 199 | self._labels.remove(label) 200 | try: 201 | self.api_req("DELETE", url, {}) 202 | except error.HTTPError: 203 | pass 204 | 205 | def get_labels(self): 206 | url = self.get_label_url % (self.owner, self.repo, self.issue) 207 | if self._labels is not None: 208 | return self._labels 209 | try: 210 | result = self.api_req("GET", url) 211 | except error.HTTPError as e: 212 | if e.code == 201: 213 | pass 214 | else: 215 | raise e 216 | self._labels = list(map( 217 | lambda x: x["name"], 218 | json.loads(result['body']) 219 | )) 220 | return self._labels 221 | 222 | def get_diff(self): 223 | if not self.diff_url: 224 | return '' 225 | if self._diff: 226 | return self._diff 227 | self._diff = self.api_req("GET", self.diff_url)['body'].decode('utf-8') 228 | return self._diff 229 | 230 | def set_assignee(self, assignee): 231 | url = self.issue_url % (self.owner, self.repo, self.issue) 232 | try: 233 | self.api_req("PATCH", url, {"assignee": assignee})['body'] 234 | except error.HTTPError as e: 235 | if e.code == 201: 236 | pass 237 | else: 238 | raise e 239 | 240 | def get_pull(self): 241 | return json.loads(self.api_req("GET", self.pull_url)["body"]) 242 | 243 | def get_page_content(self, url): 244 | try: 245 | with contextlib.closing(request.urlopen(url)) as fd: 246 | return fd.read() 247 | except error.URLError: 248 | return None 249 | 250 | 251 | img = ('') 253 | warning_header = '{} **Warning** {}'.format(img, img) 254 | warning_summary = warning_header + '\n\n%s' 255 | 256 | 257 | def extract_globals_from_payload(payload): 258 | if "issue" in payload: 259 | owner = payload['repository']['owner']['login'] 260 | repo = payload['repository']['name'] 261 | issue = str(payload['issue']['number']) 262 | else: 263 | owner = payload['pull_request']['base']['repo']['owner']['login'] 264 | repo = payload['pull_request']['base']['repo']['name'] 265 | if "number" in payload: 266 | issue = str(payload["number"]) 267 | else: 268 | issue = str(payload["pull_request"]["number"]) 269 | return (owner, repo, issue) 270 | 271 | 272 | def handle_payload(api, payload, handlers=None): 273 | if not handlers: 274 | modules, handlers = eventhandler.get_handlers() 275 | for handler in handlers: 276 | handler.handle_payload(api, payload) 277 | warnings = eventhandler.get_warnings() 278 | if warnings: 279 | formatted_warnings = '\n'.join(map(lambda x: '* ' + x, warnings)) 280 | api.post_comment(warning_summary % formatted_warnings) 281 | 282 | 283 | if __name__ == "__main__": 284 | print("Content-Type: text/html;charset=utf-8") 285 | print() 286 | 287 | config = RawConfigParser() 288 | config.read('./config') 289 | user = config.get('github', 'user') 290 | token = config.get('github', 'token') 291 | 292 | post = cgi.FieldStorage() 293 | payload_raw = post.getfirst("payload", '') 294 | payload = json.loads(payload_raw) 295 | 296 | handle_payload(GithubAPIProvider(payload, user, token), payload) 297 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 == 2.5.4 2 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | from copy import deepcopy 3 | 4 | import json 5 | import os 6 | import sys 7 | import traceback 8 | 9 | import eventhandler 10 | from json_cleanup import JsonCleaner 11 | from newpr import APIProvider, handle_payload 12 | 13 | 14 | class TestAPIProvider(APIProvider): 15 | def __init__(self, payload, user, new_contributor, labels, assignee, 16 | diff=None, pull_request=""): 17 | super(TestAPIProvider, self).__init__(payload, user) 18 | self.new_contributor = new_contributor 19 | self.comments_posted = [] 20 | self.labels = labels 21 | self.assignee = assignee 22 | self.diff = diff 23 | self.pull_request = pull_request 24 | self.repo = str(self.repo) # workaround for testing 25 | 26 | def is_new_contributor(self, username): 27 | return self.new_contributor 28 | 29 | def post_comment(self, body): 30 | self.comments_posted += [body] 31 | 32 | def add_label(self, label): 33 | self.labels += [label] 34 | 35 | def remove_label(self, label): 36 | self.labels.remove(label) 37 | 38 | def get_labels(self): 39 | return self.labels 40 | 41 | def get_diff(self): 42 | if not self.diff: 43 | return '' 44 | return self.diff.decode('utf-8') 45 | 46 | def get_pull(self): 47 | return json.loads(self.pull_request) 48 | 49 | def set_assignee(self, assignee): 50 | self.assignee = assignee 51 | 52 | def get_page_content(self, path): 53 | with open(path) as fd: 54 | return fd.read() 55 | 56 | 57 | def create_test(filename, initial, expected, 58 | payload_wrapper, to_clean, clean_dict): 59 | initial_values = { 60 | 'new_contributor': initial.get('new_contributor', False), 61 | 'labels': initial.get('labels', []), 62 | 'diff': initial.get('diff', None), 63 | 'pull_request': json.dumps(initial.get('pull_request', {})), 64 | 'assignee': initial.get('assignee', None), 65 | } 66 | 67 | return { 68 | 'filename': filename, 69 | 'initial': initial_values, 70 | 'expected': expected, 71 | 'wrapper': payload_wrapper, 72 | 'clean': to_clean, 73 | 'dict': clean_dict, 74 | } 75 | 76 | 77 | def run_tests(tests, warn=True, overwrite=False): 78 | failed, dirty = 0, 0 79 | for handler, test in tests: 80 | eventhandler.reset_test_state() 81 | 82 | try: 83 | # print(test["filename"]) 84 | initial, expected, = test['initial'], test['expected'] 85 | wrapper = test['wrapper'] 86 | payload = wrapper.json['payload'] 87 | if initial['diff']: 88 | diff = initial['diff'].encode('utf-8') 89 | else: 90 | diff = None 91 | api = TestAPIProvider(payload, 92 | 'highfive', 93 | initial['new_contributor'], 94 | initial['labels'], 95 | initial['assignee'], 96 | diff, 97 | initial['pull_request']) 98 | handle_payload(api, payload, [handler]) 99 | 100 | if 'comments' in expected: 101 | assert len(api.comments_posted) == expected['comments'], \ 102 | "%d == %d" % (len(api.comments_posted), 103 | expected['comments']) 104 | if 'labels' in expected: 105 | assert api.labels == expected['labels'], \ 106 | "%s == %s" % (api.labels, expected['labels']) 107 | if 'assignee' in expected: 108 | assert api.assignee == expected['assignee'], \ 109 | "%s == %s" % (api.assignee, expected['assignee']) 110 | 111 | # If this is the last test in the file, then it's time for cleanup 112 | if test['clean']: 113 | cleaned = wrapper.clean(warn) 114 | 115 | if wrapper.unused and not overwrite: 116 | error = '\033[91m%s\033[0m: The file has %s unused nodes' 117 | print(error % (test['filename'], wrapper.unused)) 118 | dirty += 1 119 | 120 | if overwrite: # useful for cleaning up the tests locally 121 | clean_dict = test['dict'] 122 | clean_dict['payload'] = cleaned['payload'] 123 | with open(test['filename'], 'w') as fd: 124 | json.dump(clean_dict, fd, indent=2) 125 | error = '\033[91m%s\033[0m: Rewrote the JSON file' 126 | print(error % test['filename']) 127 | 128 | except AssertionError as error: 129 | _, _, tb = sys.exc_info() 130 | traceback.print_tb(tb) # Fixed format 131 | tb_info = traceback.extract_tb(tb) 132 | filename, line, func, text = tb_info[-1] 133 | error_template = '\033[91m{}\033[0m: An error occurred on ' + \ 134 | 'line {} in statement {}' 135 | print(error_template.format(test['filename'], line, text)) 136 | print(error) 137 | failed += 1 138 | 139 | return failed, dirty 140 | 141 | 142 | def register_tests(path): 143 | tests_location = os.path.join(os.path.dirname(path), 'tests') 144 | if not os.path.isdir(tests_location): 145 | return 146 | 147 | tests = [os.path.join(tests_location, f) 148 | for f in os.listdir(tests_location) 149 | if f.endswith('.json')] 150 | 151 | for testfile in tests: 152 | with open(testfile) as f: 153 | contents = json.load(f) 154 | 155 | # backup the initial/expected values so that we can restore later 156 | # (if we plan to fix the JSON files) 157 | clean_dict = {'initial': deepcopy(contents['initial']), 158 | 'expected': deepcopy(contents['expected'])} 159 | 160 | initial_values = contents['initial'] 161 | expected_values = contents['expected'] 162 | if not isinstance(initial_values, list): 163 | assert not isinstance(expected_values, list) 164 | initial_values = [initial_values] 165 | expected_values = [expected_values] 166 | 167 | wrapper = JsonCleaner({'payload': contents['payload']}) 168 | for i, (initial, expected) in enumerate(zip(initial_values, 169 | expected_values)): 170 | min_length = min(len(initial_values), len(expected_values)) 171 | is_last_test = i == min_length - 1 172 | yield create_test(testfile, initial, expected, 173 | wrapper, is_last_test, clean_dict) 174 | 175 | 176 | def setup_tests(): 177 | (modules, handlers) = eventhandler.get_handlers() 178 | tests = [] 179 | for module, handler in zip(modules, handlers): 180 | for test in register_tests(module[1]): 181 | tests.append((handler, test)) 182 | return tests 183 | 184 | 185 | if __name__ == "__main__": 186 | args = ' '.join(sys.argv) 187 | overwrite = True if 'write' in args else False 188 | 189 | tests = setup_tests() 190 | failed, dirty = run_tests(tests, not overwrite, overwrite) 191 | 192 | print('Ran %d tests, %d failed, %d file(s) dirty' % 193 | (len(tests), failed, dirty)) 194 | 195 | if failed or dirty: 196 | if dirty: 197 | print('Run `python %s write` to cleanup the dirty files' % args) 198 | sys.exit(1) 199 | --------------------------------------------------------------------------------