├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── action.yml ├── app.yml ├── jest.config.js ├── package.json ├── scripts └── build-prod ├── src ├── action.ts ├── auto-cc-bot.ts ├── auto-label-bot.ts ├── ciflow-bot.ts ├── handler.ts ├── index.ts ├── merge-bot.ts ├── subscriptions.ts ├── trigger-circleci-workflows.ts ├── utils.ts └── verify-disable-test-issue.ts ├── test ├── auto-cc-bot.test.ts ├── auto-label-bot.test.ts ├── ciflow-bot.test.ts ├── ciflow-parse.test.ts ├── common.ts ├── fixtures │ ├── config.json │ ├── issue.json │ ├── issue_comment.json │ ├── issues.labeled.json │ ├── issues.opened.json │ ├── pull_request.labeled.json │ ├── pull_request.opened.json │ ├── pull_request.reopened.json │ ├── pull_request.synchronize.json │ └── push.json ├── index.test.ts ├── subscriptions.test.ts ├── trigger-circleci-workflows.test.ts ├── utils.ts └── verify-disable-test-issue.test.ts ├── travis.yml ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # The ID of your GitHub App 2 | APP_ID= 3 | WEBHOOK_SECRET=development 4 | 5 | # Use `trace` to get verbose logging or `info` to show less 6 | LOG_LEVEL=debug 7 | 8 | # Go to https://smee.io/new set this to the URL that you are redirected to. 9 | WEBHOOK_PROXY_URL= 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "eslint-comments/no-use": "off", 12 | "import/no-namespace": "off", 13 | "no-unused-vars": "off", 14 | "@typescript-eslint/no-unused-vars": "error", 15 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 16 | "@typescript-eslint/no-require-imports": "error", 17 | "@typescript-eslint/array-type": "error", 18 | "@typescript-eslint/await-thenable": "error", 19 | "@typescript-eslint/ban-ts-comment": "warn", 20 | "camelcase": "off", 21 | "@typescript-eslint/naming-convention": ["error", {"selector": "variableLike", "format": ["camelCase"]}], 22 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 23 | "@typescript-eslint/func-call-spacing": ["error", "never"], 24 | "@typescript-eslint/no-array-constructor": "error", 25 | "@typescript-eslint/no-empty-interface": "error", 26 | "@typescript-eslint/no-explicit-any": "warn", 27 | "@typescript-eslint/no-extraneous-class": "error", 28 | "@typescript-eslint/no-for-in-array": "error", 29 | "@typescript-eslint/no-inferrable-types": "error", 30 | "@typescript-eslint/no-misused-new": "error", 31 | "@typescript-eslint/no-namespace": "error", 32 | "@typescript-eslint/no-non-null-assertion": "warn", 33 | "@typescript-eslint/no-unnecessary-qualifier": "error", 34 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 35 | "@typescript-eslint/no-useless-constructor": "error", 36 | "@typescript-eslint/no-var-requires": "error", 37 | "@typescript-eslint/prefer-for-of": "warn", 38 | "@typescript-eslint/prefer-function-type": "warn", 39 | "@typescript-eslint/prefer-includes": "error", 40 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 41 | "@typescript-eslint/promise-function-async": "error", 42 | "@typescript-eslint/require-array-sort-compare": "error", 43 | "@typescript-eslint/restrict-plus-operands": "error", 44 | "@typescript-eslint/type-annotation-spacing": "error", 45 | "@typescript-eslint/unbound-method": "error" 46 | }, 47 | "env": { 48 | "node": true, 49 | "es6": true, 50 | "jest/globals": true 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: "lint, build, and test" 12 | runs-on: ubuntu-20.04 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | - name: Install dependencies 17 | run: yarn install 18 | - name: Lint 19 | run: yarn lint 20 | - name: Build 21 | run: yarn build 22 | - name: Test 23 | run: yarn test 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Build 15 | run: scripts/build-prod 16 | - name: Deploy 17 | uses: appleboy/lambda-action@1e05c1377056f21ebb2ce69b910bc16b943c2a66 18 | with: 19 | aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} 20 | aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 21 | aws_region: us-east-1 22 | function_name: pytorch-probot 23 | zip_file: artifacts/pytorch-probot.zip 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | .env.prod 69 | 70 | # parcel-bundler cache (https://parceljs.org/) 71 | .cache 72 | 73 | # next.js build output 74 | .next 75 | 76 | # nuxt.js build output 77 | .nuxt 78 | 79 | # vuepress build output 80 | .vuepress/dist 81 | 82 | # Serverless directories 83 | .serverless/ 84 | 85 | # FuseBox cache 86 | .fusebox/ 87 | 88 | # DynamoDB Local files 89 | .dynamodb/ 90 | 91 | # Private key files 92 | *.pem 93 | 94 | # Compiled typescript files 95 | dist/ 96 | 97 | # Build artifacts for deployment 98 | artifacts/ 99 | 100 | .vscode 101 | .history 102 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ezyang@fb.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | [style]: https://standardjs.com/ 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 11 | 12 | ## Issues and PRs 13 | 14 | If you have suggestions for how this project could be improved, or want to report a bug, open an issue! We'd love all and any contributions. If you have questions, too, we'd love to hear them. 15 | 16 | We'd also love PRs. If you're thinking of a large PR, we advise opening up an issue first to talk about it, though! Look at the links below if you're not sure how to open a PR. 17 | 18 | ## Submitting a pull request 19 | 20 | 1. [Fork][fork] and clone the repository. 21 | 1. Configure and install the dependencies: `npm install`. 22 | 1. Make sure the tests pass on your machine: `npm test`, note: these tests also apply the linter, so there's no need to lint separately. 23 | 1. Create a new branch: `git checkout -b my-branch-name`. 24 | 1. Make your change, add tests, and make sure the tests still pass. 25 | 1. Push to your fork and [submit a pull request][pr]. 26 | 1. Pat your self on the back and wait for your pull request to be reviewed and merged. 27 | 28 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 29 | 30 | - Follow the [style guide][style] which is using standard. Any linting errors should be shown when running `npm test`. 31 | - Write and update tests. 32 | - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 33 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 34 | 35 | Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocked you. 36 | 37 | ## Resources 38 | 39 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 40 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 41 | - [GitHub Help](https://help.github.com) 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 as install 2 | WORKDIR /build 3 | COPY tsconfig.json . 4 | COPY package.json . 5 | COPY yarn.lock . 6 | RUN yarn install 7 | COPY src/ src/ 8 | 9 | FROM install as build 10 | RUN yarn build 11 | 12 | FROM install as test 13 | COPY test/ test/ 14 | COPY jest.config.js . 15 | RUN yarn test 16 | 17 | FROM node:12 as prod-build 18 | WORKDIR /out 19 | COPY package.json . 20 | RUN yarn install --production 21 | 22 | FROM alpine:3 as zip 23 | RUN apk -U --no-cache add zip 24 | WORKDIR /build 25 | COPY --from=build /build/dist dist/ 26 | COPY --from=prod-build /out/node_modules node_modules/ 27 | COPY ./package.json . 28 | COPY ./yarn.lock . 29 | RUN zip -FSr /pytorch-probot.zip . 30 | 31 | FROM scratch as prod 32 | COPY --from=zip /pytorch-probot.zip . 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2019, Edward Z. Yang (https://pytorch.org) 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This repository is archived and our Probot stuff has moved to https://github.com/pytorch/test-infra/tree/main/torchci 2 | 3 | 4 | # pytorch-probot 5 | 6 | A GitHub App built with [Probot](https://github.com/probot/probot) that implements bot actions for PyTorch 7 | 8 | This bot implements a few behaviors. **This bot currently only 9 | implements idempotent behaviors (i.e., it is harmless if the bot process 10 | events multiple times.** If you add support for non-idempotent 11 | behaviors, you need to make sure only the GitHub Action or AWS Lambda is 12 | enabled. 13 | 14 | ## auto-cc-bot 15 | 16 | Add an issue to your project like https://github.com/pytorch/pytorch/issues/24422 17 | and add a `.github/pytorch-probot.yml` file with: 18 | 19 | ```yml 20 | tracking_issue: 24422 21 | ``` 22 | 23 | Based on who is listed in the tracking issue, the bot will automatically 24 | CC people when labels are added to an issue. 25 | 26 | ## auto-label-bot 27 | 28 | * If an issue is labeled **high priority**, also label it 29 | **triage review** 30 | * If an issue is labeled **topic: flaky-tests**, also label it 31 | **high priority** and **triage review** 32 | * If an issue or pull request contains a regex in its title, label 33 | it accordingly, e.g., a title containing 'ROCm' would yield the 34 | **module: rocm** label. 35 | 36 | ## trigger-circleci-workflows 37 | 38 | * Trigger circleci workflows based off of labeling events / push events 39 | 40 | Configuration (`.github/pytorch-circleci-labels.yml`) should look similar to this: 41 | ```yml 42 | labels_to_circle_params: 43 | # Refers to github labels 44 | ci/binaries: 45 | # Refers to circleci parameters 46 | # For circleci documentation on pipeline parameters check: 47 | # https://circleci.com/docs/2.0/pipeline-variables/#pipeline-parameters-in-configuration 48 | parameter: run_binaries_tests 49 | # [[optional]] Automatically trigger workflows with parameters on push 50 | default_true_on: 51 | branches: 52 | - nightly 53 | # Regex is allowed as well 54 | - ci-all/.* 55 | # Even works on tags! 56 | tags: 57 | - v[0-9]+(\.[0-9]+)*-rc[0-9]+ 58 | # Multiple label / parameters can be defined 59 | ci/bleh: 60 | parameter: run_bleh_tests 61 | ci/foo: 62 | parameter: run_foo_tests 63 | ``` 64 | 65 | ## Setup 66 | 67 | ```sh 68 | # Install dependencies 69 | yarn install 70 | 71 | # Run the tests 72 | yarn test 73 | 74 | # Run the bot 75 | yarn start 76 | ``` 77 | 78 | ## Live testing as a GitHub App 79 | 80 | If you want to smoketest the bot on a test repository, you'll need to 81 | create a GitHub app. Go to the webpage from probot; it will walk 82 | through the process. 83 | 84 | ## Deploying GitHub Actions 85 | 86 | Although a GitHub App is convenient for testing, it requires an actual 87 | server to deploy in prod. Previously we ran the server on AWS, but this 88 | deployment process was substantially more involved. GitHub Actions 89 | deployment is simpler. Follow the instructions at 90 | https://github.com/actions/toolkit/blob/master/docs/action-versioning.md 91 | 92 | Right now the GitHub Actions deployment is a little rocky because 93 | massive queueing in the PyTorch repository means it takes something 94 | like 30min before actions are run. So we are also running AWS 95 | side-by-side. 96 | 97 | ## Deploying to AWS 98 | 99 | [`.github/workflows/build.yml`](.github/workflows/build.yml) will build and deploy the code on every push to `main`. 100 | 101 | ## Contributing 102 | 103 | If you have suggestions for how pytorchbot could be improved, or want to report a bug, open an issue! We'd love all and any contributions. 104 | 105 | For more, check out the [Contributing Guide](CONTRIBUTING.md). 106 | 107 | ## License 108 | 109 | [ISC](LICENSE) © 2019 Edward Z. Yang (https://pytorch.org) 110 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'PyTorch Probot' 2 | description: 'Probot for PyTorch.' 3 | runs: 4 | using: 'node12' 5 | main: 'action.js' 6 | -------------------------------------------------------------------------------- /app.yml: -------------------------------------------------------------------------------- 1 | # This is a GitHub App Manifest. These settings will be used by default when 2 | # initially configuring your GitHub App. 3 | # 4 | # NOTE: changing this file will not update your GitHub App settings. 5 | # You must visit github.com/settings/apps/your-app-name to edit them. 6 | # 7 | # Read more about configuring your GitHub App: 8 | # https://probot.github.io/docs/development/#configuring-a-github-app 9 | # 10 | # Read more about GitHub App Manifests: 11 | # https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest/ 12 | 13 | # The list of events the GitHub App subscribes to. 14 | # Uncomment the event names below to enable them. 15 | default_events: 16 | # - check_run 17 | # - check_suite 18 | # - commit_comment 19 | # - create 20 | # - delete 21 | # - deployment 22 | # - deployment_status 23 | # - fork 24 | # - gollum 25 | - issue_comment 26 | - issues 27 | # - label 28 | # - milestone 29 | # - member 30 | # - membership 31 | # - org_block 32 | # - organization 33 | # - page_build 34 | # - project 35 | # - project_card 36 | # - project_column 37 | # - public 38 | - pull_request 39 | # - pull_request_review 40 | # - pull_request_review_comment 41 | - push 42 | # - release 43 | # - repository 44 | # - repository_import 45 | # - status 46 | # - team 47 | # - team_add 48 | # - watch 49 | 50 | # The set of permissions needed by the GitHub App. The format of the object uses 51 | # the permission name for the key (for example, issues) and the access type for 52 | # the value (for example, write). 53 | # Valid values are `read`, `write`, and `none` 54 | default_permissions: 55 | # Repository creation, deletion, settings, teams, and collaborators. 56 | # https://developer.github.com/v3/apps/permissions/#permission-on-administration 57 | # administration: read 58 | 59 | # Checks on code. 60 | # https://developer.github.com/v3/apps/permissions/#permission-on-checks 61 | # checks: read 62 | 63 | # Repository contents, commits, branches, downloads, releases, and merges. 64 | # https://developer.github.com/v3/apps/permissions/#permission-on-contents 65 | contents: read 66 | 67 | # Deployments and deployment statuses. 68 | # https://developer.github.com/v3/apps/permissions/#permission-on-deployments 69 | # deployments: read 70 | 71 | # Issues and related comments, assignees, labels, and milestones. 72 | # https://developer.github.com/v3/apps/permissions/#permission-on-issues 73 | issues: write 74 | 75 | # Search repositories, list collaborators, and access repository metadata. 76 | # https://developer.github.com/v3/apps/permissions/#metadata-permissions 77 | metadata: read 78 | 79 | # Retrieve Pages statuses, configuration, and builds, as well as create new builds. 80 | # https://developer.github.com/v3/apps/permissions/#permission-on-pages 81 | # pages: read 82 | 83 | # Pull requests and related comments, assignees, labels, milestones, and merges. 84 | # https://developer.github.com/v3/apps/permissions/#permission-on-pull-requests 85 | pull_requests: write 86 | 87 | # Manage the post-receive hooks for a repository. 88 | # https://developer.github.com/v3/apps/permissions/#permission-on-repository-hooks 89 | # repository_hooks: read 90 | 91 | # Manage repository projects, columns, and cards. 92 | # https://developer.github.com/v3/apps/permissions/#permission-on-repository-projects 93 | # repository_projects: read 94 | 95 | # Retrieve security vulnerability alerts. 96 | # https://developer.github.com/v4/object/repositoryvulnerabilityalert/ 97 | # vulnerability_alerts: read 98 | 99 | # Commit statuses. 100 | # https://developer.github.com/v3/apps/permissions/#permission-on-statuses 101 | # statuses: read 102 | 103 | # Organization members and teams. 104 | # https://developer.github.com/v3/apps/permissions/#permission-on-members 105 | members: read 106 | 107 | # View and manage users blocked by the organization. 108 | # https://developer.github.com/v3/apps/permissions/#permission-on-organization-user-blocking 109 | # organization_user_blocking: read 110 | 111 | # Manage organization projects, columns, and cards. 112 | # https://developer.github.com/v3/apps/permissions/#permission-on-organization-projects 113 | # organization_projects: read 114 | 115 | # Manage team discussions and related comments. 116 | # https://developer.github.com/v3/apps/permissions/#permission-on-team-discussions 117 | # team_discussions: read 118 | 119 | # Manage the post-receive hooks for an organization. 120 | # https://developer.github.com/v3/apps/permissions/#permission-on-organization-hooks 121 | # organization_hooks: read 122 | 123 | # Get notified of, and update, content references. 124 | # https://developer.github.com/v3/apps/permissions/ 125 | # organization_administration: read 126 | 127 | 128 | # The name of the GitHub App. Defaults to the name specified in package.json 129 | # name: My Probot App 130 | 131 | # The homepage of your GitHub App. 132 | # url: https://example.com/ 133 | 134 | # A description of the GitHub App. 135 | # description: A description of my awesome app 136 | 137 | # Set to true when your GitHub App is available to the public or false when it is only accessible to the owner of the app. 138 | # Default: true 139 | # public: false 140 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src/', '/test/'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest' 5 | }, 6 | testRegex: '(/__tests__/.*|\\.(test|spec))\\.[tj]sx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | testEnvironment: 'node' 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pytorch-probot", 3 | "version": "1.0.0", 4 | "description": "Bot actions for PyTorch", 5 | "author": "Edward Z. Yang (https://pytorch.org)", 6 | "license": "ISC", 7 | "repository": "https://github.com//pytorch.git", 8 | "homepage": "https://github.com//pytorch", 9 | "bugs": "https://github.com//pytorch/issues", 10 | "keywords": [ 11 | "probot", 12 | "github", 13 | "probot-app" 14 | ], 15 | "scripts": { 16 | "build": "tsc", 17 | "build:watch": "tsc && (tsc -w --preserveWatchOutput & nodemon)", 18 | "dev": "yarn run build:watch", 19 | "start": "probot run ./dist/index.js", 20 | "lint": "eslint src/**/*.ts", 21 | "test": "jest", 22 | "test:watch": "jest --watch --notify --notifyMode=change --coverage", 23 | "format": "prettier --write **/*.ts", 24 | "format-check": "prettier --check **/*ts" 25 | }, 26 | "dependencies": { 27 | "@probot/serverless-lambda": "^0.4.0", 28 | "@types/minimist": "^1.2.2", 29 | "@types/source-map-support": "^0.5.4", 30 | "axios": "^0.21.2", 31 | "probot": "^9", 32 | "probot-actions-adapter": "^1.0.2", 33 | "source-map-support": "^0.5.19" 34 | }, 35 | "devDependencies": { 36 | "@types/bunyan": "^1.8.6", 37 | "@types/jest": "^24.0.23", 38 | "@types/nock": "^11.1.0", 39 | "@types/node": "^12.7.12", 40 | "@typescript-eslint/eslint-plugin": "^4.28.2", 41 | "@typescript-eslint/parser": "^4.28.2", 42 | "@vercel/ncc": "^0.28.6", 43 | "axios-debug-log": "^0.7.0", 44 | "eslint": "^7.30.0", 45 | "eslint-plugin-github": "^4.1.3", 46 | "eslint-plugin-jest": "^22.21.0", 47 | "eslint-plugin-prettier": "^3.4.0", 48 | "jest": "^24.0.0", 49 | "jest-circus": "^24.9.0", 50 | "nock": "^10.0.0", 51 | "nodemon": "^1.17.2", 52 | "prettier": "^2.0.5", 53 | "smee-client": "^1.0.2", 54 | "standard": "^12.0.1", 55 | "ts-jest": "^24.2.0", 56 | "typescript": "^3.6.4" 57 | }, 58 | "engines": { 59 | "node": ">= 8.3.0" 60 | }, 61 | "standard": { 62 | "parser": "@typescript-eslint/parser", 63 | "env": [ 64 | "jest" 65 | ], 66 | "plugins": [ 67 | "typescript" 68 | ] 69 | }, 70 | "nodemonConfig": { 71 | "exec": "npm start", 72 | "watch": [ 73 | ".env", 74 | "." 75 | ] 76 | }, 77 | "jest": { 78 | "testEnvironment": "node" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scripts/build-prod: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! docker buildx version >/dev/null 2>/dev/null; then 4 | echo "ERROR: You need docker buildx on your system to use this script" 5 | echo " Refer to https://github.com/docker/buildx#installing" 6 | exit 1 7 | fi 8 | 9 | ROOT_DIR=$(git rev-parse --show-toplevel) 10 | ( 11 | set -x 12 | docker buildx build --target prod -o artifacts/ "${ROOT_DIR}" 13 | ) 14 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | // Require the adapter 2 | import adapt from 'probot-actions-adapter'; 3 | 4 | // Require your Probot app's entrypoint, usually this is just index.js 5 | import probot from './index'; 6 | 7 | // Adapt the Probot app for Actions 8 | // This also acts as the main entrypoint for the Action 9 | adapt(probot); 10 | -------------------------------------------------------------------------------- /src/auto-cc-bot.ts: -------------------------------------------------------------------------------- 1 | import {parseSubscriptions} from './subscriptions'; 2 | import {CachedIssueTracker} from './utils'; 3 | import * as probot from 'probot'; 4 | 5 | function myBot(app: probot.Application): void { 6 | const tracker = new CachedIssueTracker( 7 | app, 8 | 'tracking_issue', 9 | parseSubscriptions 10 | ); 11 | 12 | async function loadSubscriptions(context: probot.Context): Promise { 13 | return tracker.loadIssue(context); 14 | } 15 | 16 | async function runBotForLabels( 17 | context: probot.Context, 18 | payloadType: string 19 | ): Promise { 20 | const subscriptions = await loadSubscriptions(context); 21 | context.log('payload_type=', payloadType); 22 | const labels = context.payload[payloadType]['labels'].map(e => e['name']); 23 | context.log({labels}); 24 | const cc = new Set(); 25 | // eslint-disable-next-line github/array-foreach 26 | labels.forEach(l => { 27 | if (l in subscriptions) { 28 | // eslint-disable-next-line github/array-foreach 29 | subscriptions[l].forEach(u => cc.add(u)); 30 | } 31 | }); 32 | context.log({cc: Array.from(cc)}, 'from subscriptions'); 33 | if (cc.size) { 34 | const body = context.payload[payloadType]['body']; 35 | const reCC = /cc( +@[a-zA-Z0-9-/]+)+/; 36 | const oldCCMatch = body ? body.match(reCC) : null; 37 | const prevCC = new Set(); 38 | if (oldCCMatch) { 39 | const oldCCString = oldCCMatch[0]; 40 | context.log({oldCCString}, 'previous cc string'); 41 | let m; 42 | const reUsername = /@([a-zA-Z0-9-/]+)/g; 43 | while ((m = reUsername.exec(oldCCString)) !== null) { 44 | prevCC.add(m[1]); 45 | cc.add(m[1]); 46 | } 47 | context.log({prevCC: Array.from(prevCC)}, 'pre-existing ccs'); 48 | } 49 | // Invariant: prevCC is a subset of cc 50 | if (prevCC.size !== cc.size) { 51 | let newCCString = 'cc'; 52 | // eslint-disable-next-line github/array-foreach 53 | cc.forEach(u => { 54 | newCCString += ` @${u}`; 55 | }); 56 | const newBody = body 57 | ? oldCCMatch 58 | ? body.replace(reCC, newCCString) 59 | : `${body}\n\n${newCCString}` 60 | : newCCString; 61 | context.log({newBody}); 62 | if (payloadType === 'issue') { 63 | await context.github.issues.update(context.issue({body: newBody})); 64 | } else if (payloadType === 'pull_request') { 65 | await context.github.pulls.update(context.issue({body: newBody})); 66 | } 67 | } else { 68 | context.log('no action: no change from existing cc list on issue'); 69 | } 70 | } else { 71 | context.log('no action: cc list from subscription is empty'); 72 | } 73 | } 74 | 75 | app.on('issues.labeled', async context => { 76 | await runBotForLabels(context, 'issue'); 77 | }); 78 | app.on('pull_request.labeled', async context => { 79 | await runBotForLabels(context, 'pull_request'); 80 | }); 81 | } 82 | 83 | export default myBot; 84 | -------------------------------------------------------------------------------- /src/auto-label-bot.ts: -------------------------------------------------------------------------------- 1 | import * as probot from 'probot'; 2 | 3 | const regexToLabel: [RegExp, string][] = [ 4 | [/rocm/gi, 'module: rocm'], 5 | [/DISABLED\s+test.*\(.*\)/g, 'skipped'] 6 | ]; 7 | 8 | function myBot(app: probot.Application): void { 9 | function addLabel( 10 | labelSet: Set, 11 | newLabels: string[], 12 | l: string 13 | ): void { 14 | if (!labelSet.has(l)) { 15 | newLabels.push(l); 16 | labelSet.add(l); 17 | } 18 | } 19 | 20 | app.on('issues.labeled', async context => { 21 | // Careful! For most labels, we only apply actions *when the issue 22 | // is added*; not if the issue is pre-existing (for example, high 23 | // priority label results in triage review, but if we unlabel it 24 | // from triage review, we shouldn't readd triage review the next 25 | // time the issue is labeled). 26 | 27 | const label = context.payload['label']['name']; 28 | const labels: string[] = context.payload['issue']['labels'].map( 29 | e => e['name'] 30 | ); 31 | context.log({label, labels}); 32 | 33 | const labelSet = new Set(labels); 34 | const newLabels = []; 35 | 36 | // NB: Added labels here will trigger more issues.labeled actions, 37 | // so be careful about accidentally adding a cycle. With just label 38 | // addition it's not possible to infinite loop as you will 39 | // eventually quiesce, beware if you remove labels though! 40 | switch (label) { 41 | case 'high priority': 42 | case 'critical': 43 | addLabel(labelSet, newLabels, 'triage review'); 44 | break; 45 | case 'module: flaky-tests': 46 | addLabel(labelSet, newLabels, 'high priority'); 47 | addLabel(labelSet, newLabels, 'triage review'); 48 | break; 49 | } 50 | 51 | if (newLabels.length) { 52 | await context.github.issues.addLabels(context.issue({labels: newLabels})); 53 | } 54 | }); 55 | 56 | async function addLabelsFromTitle( 57 | existingLabels: string[], 58 | title: string, 59 | context 60 | ): Promise { 61 | const labelSet = new Set(existingLabels); 62 | const newLabels = []; 63 | 64 | for (const [regex, label] of regexToLabel) { 65 | if (title.match(regex)) { 66 | addLabel(labelSet, newLabels, label); 67 | } 68 | } 69 | 70 | if (newLabels.length) { 71 | await context.github.issues.addLabels(context.issue({labels: newLabels})); 72 | } 73 | } 74 | 75 | app.on(['issues.opened', 'issues.edited'], async context => { 76 | const labels: string[] = context.payload['issue']['labels'].map( 77 | e => e['name'] 78 | ); 79 | const title = context.payload['issue']['title']; 80 | context.log({labels, title}); 81 | await addLabelsFromTitle(labels, title, context); 82 | }); 83 | 84 | app.on(['pull_request.opened', 'pull_request.edited'], async context => { 85 | const labels: string[] = context.payload['pull_request']['labels'].map( 86 | e => e['name'] 87 | ); 88 | const title = context.payload['pull_request']['title']; 89 | context.log({labels, title}); 90 | 91 | await addLabelsFromTitle(labels, title, context); 92 | }); 93 | } 94 | 95 | export default myBot; 96 | -------------------------------------------------------------------------------- /src/ciflow-bot.ts: -------------------------------------------------------------------------------- 1 | import * as probot from 'probot'; 2 | import minimist from 'minimist'; 3 | import {CachedIssueTracker} from './utils'; 4 | 5 | const ciflowCommentStart = ''; 6 | const ciflowCommentEnd = ''; 7 | 8 | interface IUserConfig { 9 | optOut: boolean; 10 | defaultLabels?: string[]; 11 | } 12 | 13 | // parseCIFlowIssue parses the issue body for default labels and opt-out users 14 | export function parseCIFlowIssue(rawText: string): Map { 15 | const [optIn, optOut] = ['@', '-@']; 16 | const rows = rawText.replace('\r', '').split('\n'); 17 | const userConfigMap: Map = new Map(); 18 | // eslint-disable-next-line github/array-foreach 19 | rows.forEach((row: string) => { 20 | const elements = row 21 | .trim() 22 | .replace(/^-\s*@/, '-@') 23 | .split(' '); 24 | if ( 25 | elements.length < 1 || 26 | elements[0].length < 1 || 27 | !(elements[0].startsWith(optIn) || elements[0].startsWith(optOut)) 28 | ) { 29 | return; 30 | } 31 | 32 | // opt-out users 33 | if (elements[0].startsWith(optOut)) { 34 | const login = elements[0].substring(2); 35 | userConfigMap.set(login, { 36 | optOut: true 37 | }); 38 | return; 39 | } 40 | 41 | // users with custom labels 42 | const login = elements[0].substring(1); 43 | const defaultLabels = 44 | elements.length === 1 ? CIFlowBot.defaultLabels : elements.slice(1); 45 | userConfigMap.set(login, { 46 | optOut: false, 47 | defaultLabels 48 | }); 49 | }); 50 | return userConfigMap; 51 | } 52 | 53 | // The CIFlowBot helps to dispatch labels and signal GitHub Action workflows to run. 54 | // For more details about the design, please refer to the RFC: https://github.com/pytorch/pytorch/issues/61888 55 | // Currently it supports strong validation and slow rollout, and it runs through a pipeline of dispatch strategies. 56 | export class CIFlowBot { 57 | // Constructor required 58 | readonly ctx: probot.Context; 59 | readonly tracker: CachedIssueTracker; 60 | 61 | // Static readonly configurations 62 | static readonly command_ciflow = 'ciflow'; 63 | static readonly command_ciflow_rerun = 'rerun'; 64 | static readonly allowed_commands: string[] = [CIFlowBot.command_ciflow]; 65 | 66 | static readonly bot_assignee = 'pytorchbot'; 67 | static readonly event_issue_comment = 'issue_comment'; 68 | static readonly event_pull_request = 'pull_request'; 69 | static readonly pr_label_prefix = 'ciflow/'; 70 | 71 | static readonly strategy_add_default_labels = 'strategy_add_default_labels'; 72 | static readonly defaultLabels = ['ciflow/default']; 73 | 74 | // Stateful instance variables 75 | command = ''; 76 | command_args: minimist.ParsedArgs; 77 | comment_id = 0; 78 | comment_author = ''; 79 | comment_author_permission = ''; 80 | comment_body = ''; 81 | confusing_command = false; 82 | dispatch_labels: string[] = []; 83 | dispatch_strategies = [CIFlowBot.strategy_add_default_labels]; 84 | default_labels = CIFlowBot.defaultLabels; 85 | event = ''; 86 | owner = ''; 87 | pr_author = ''; 88 | pr_labels: string[] = []; 89 | pr_number = 0; 90 | repo = ''; 91 | 92 | constructor(ctx: probot.Context, tracker: CachedIssueTracker = null) { 93 | this.ctx = ctx; 94 | this.tracker = tracker; 95 | } 96 | 97 | valid(): boolean { 98 | if ( 99 | this.event !== CIFlowBot.event_pull_request && 100 | this.event !== CIFlowBot.event_issue_comment 101 | ) { 102 | this.ctx.log.error({ctx: this.ctx}, 'Unknown webhook event'); 103 | return false; 104 | } 105 | 106 | // validate the issue_comment event 107 | if (this.event === CIFlowBot.event_issue_comment) { 108 | if (!CIFlowBot.allowed_commands.includes(this.command)) { 109 | return false; 110 | } 111 | 112 | if ( 113 | this.comment_author !== this.pr_author && 114 | !( 115 | this.comment_author_permission === 'admin' || 116 | this.comment_author_permission === 'write' 117 | ) 118 | ) { 119 | return false; 120 | } 121 | } 122 | 123 | // validate the pull_request event, so far we just return true 124 | return true; 125 | } 126 | 127 | async getUserPermission(username: string): Promise { 128 | const res = await this.ctx.github.repos.getCollaboratorPermissionLevel({ 129 | owner: this.owner, 130 | repo: this.repo, 131 | username 132 | }); 133 | return res?.data?.permission; 134 | } 135 | 136 | async getUserLabels(): Promise { 137 | const userConfigMap: Map = 138 | this.tracker != null 139 | ? ((await this.tracker.loadIssue(this.ctx)) as Map) 140 | : new Map(); 141 | 142 | // rollout to everyone if no config is found 143 | if (!userConfigMap.has(this.pr_author)) { 144 | return CIFlowBot.defaultLabels; 145 | } 146 | 147 | // respect opt-out users 148 | if (userConfigMap.get(this.pr_author).optOut) { 149 | return []; 150 | } 151 | 152 | return ( 153 | userConfigMap.get(this.pr_author).defaultLabels || CIFlowBot.defaultLabels 154 | ); 155 | } 156 | 157 | async dispatch(): Promise { 158 | if (this.confusing_command) { 159 | await this.postReaction(); 160 | this.ctx.log.info( 161 | { 162 | event: this.event, 163 | owner: this.owner, 164 | command_args: this.command_args 165 | }, 166 | 'ciflow dispatch is confused!' 167 | ); 168 | return; 169 | } 170 | // Dispatch_strategies is like a pipeline of functions we can apply to 171 | // change `this.dispatch_labels`. We can add other dispatch algorithms 172 | // based on the ctx or user instructions. 173 | // The future algorithms can manipulate the `this.dispatch_labels`, and 174 | // individual workflows that can build up `if` conditions on the labels 175 | // can be found in `.github/workflows` of pytorch/pytorch repo. 176 | this.dispatch_strategies.map(this.dispatchStrategyFunc.bind(this)); 177 | 178 | // Signal the dispatch to GitHub 179 | await this.setLabels(); 180 | await this.signalGithub(); 181 | 182 | // Logging of the dispatch 183 | this.ctx.log.info( 184 | { 185 | dispatch_labels: this.dispatch_labels, 186 | dispatch_strategies: this.dispatch_strategies, 187 | event: this.event, 188 | owner: this.owner, 189 | pr_number: this.pr_number, 190 | pr_labels: this.pr_labels, 191 | repo: this.repo 192 | }, 193 | 'ciflow dispatch success!' 194 | ); 195 | } 196 | 197 | dispatchStrategyFunc(strategyName: string): void { 198 | switch (strategyName) { 199 | case CIFlowBot.strategy_add_default_labels: 200 | // strategy_add_default_labels: just make sure the we add `default_labels` to the existing set of pr_labels 201 | if (this.dispatch_labels.length === 0) { 202 | this.dispatch_labels = this.pr_labels; 203 | } 204 | 205 | // respect author's ciflow labels before PR is made 206 | if (this.hasLabelsBeforePR()) { 207 | break; 208 | } 209 | 210 | this.dispatch_labels = this.default_labels.concat(this.dispatch_labels); 211 | break; 212 | default: { 213 | this.ctx.log.error({strategyName}, 'Unknown dispatch strategy'); 214 | break; 215 | } 216 | } 217 | } 218 | 219 | // triggerGHADispatch sends a signal to GitHub to trigger the dispatch 220 | // The logic here is leverage some event that's rarely triggered by other users or bots, 221 | // thus we pick "assign/unassign" to begin with. See details from the CIFlow RFC: 222 | // https://github.com/pytorch/pytorch/issues/61888 223 | async triggerGHADispatch(): Promise { 224 | await this.ctx.github.issues.addAssignees({ 225 | owner: this.owner, 226 | repo: this.repo, 227 | issue_number: this.pr_number, 228 | assignees: [CIFlowBot.bot_assignee] 229 | }); 230 | 231 | await this.ctx.github.issues.removeAssignees({ 232 | owner: this.owner, 233 | repo: this.repo, 234 | issue_number: this.pr_number, 235 | assignees: [CIFlowBot.bot_assignee] 236 | }); 237 | } 238 | 239 | async postReaction(): Promise { 240 | if (this.event === CIFlowBot.event_issue_comment) { 241 | await this.ctx.github.reactions.createForIssueComment({ 242 | comment_id: this.comment_id, 243 | content: this.confusing_command ? 'confused' : '+1', 244 | owner: this.owner, 245 | repo: this.repo 246 | }); 247 | } 248 | } 249 | 250 | // signalGithub triggers a dispatch (if needed) as well as reacts to the comment 251 | async signalGithub(): Promise { 252 | if ( 253 | this.event === CIFlowBot.event_pull_request && 254 | this.default_labels.length === CIFlowBot.defaultLabels.length && 255 | this.default_labels.every( 256 | (val, idx) => val === CIFlowBot.defaultLabels[idx] 257 | ) 258 | ) { 259 | this.ctx.log.info( 260 | { 261 | dispatch_labels: this.dispatch_labels, 262 | default_labels: this.default_labels, 263 | event: this.event, 264 | owner: this.owner, 265 | pr_number: this.pr_number, 266 | pr_labels: this.pr_labels, 267 | repo: this.repo 268 | }, 269 | 'skipping pull request dispatch for defaultLabel' 270 | ); 271 | } else { 272 | await this.triggerGHADispatch(); 273 | } 274 | 275 | await this.postReaction(); 276 | 277 | await new Ruleset( 278 | this.ctx, 279 | this.owner, 280 | this.repo, 281 | this.pr_number, 282 | this.dispatch_labels 283 | ).upsertRootComment(); 284 | } 285 | 286 | hasLabelsBeforePR(): boolean { 287 | return ( 288 | this.event === CIFlowBot.event_pull_request && 289 | this.pr_labels.some(l => l.startsWith(CIFlowBot.pr_label_prefix)) 290 | ); 291 | } 292 | 293 | isRerunCommand(): boolean { 294 | return ( 295 | this.event === CIFlowBot.event_issue_comment && 296 | this.command_args.length > 0 && 297 | this.command_args._[0] === CIFlowBot.command_ciflow_rerun 298 | ); 299 | } 300 | 301 | logSkipLabels(message: string): void { 302 | this.ctx.log.info( 303 | { 304 | dispatch_labels: this.dispatch_labels, 305 | default_labels: this.default_labels, 306 | event: this.event, 307 | owner: this.owner, 308 | pr_number: this.pr_number, 309 | pr_labels: this.pr_labels, 310 | repo: this.repo 311 | }, 312 | message 313 | ); 314 | } 315 | 316 | async setLabels(): Promise { 317 | if (this.hasLabelsBeforePR()) { 318 | this.logSkipLabels("do not set labels as it'll override users choice."); 319 | return; 320 | } 321 | 322 | if (this.isRerunCommand() && typeof this.command_args.l === undefined) { 323 | this.logSkipLabels( 324 | 'Do not set labels for rerun comments without -l option.' 325 | ); 326 | return; 327 | } 328 | 329 | const labels = this.dispatch_labels.filter(label => 330 | label.startsWith(CIFlowBot.pr_label_prefix) 331 | ); 332 | const labelsToDelete = this.pr_labels.filter(l => !labels.includes(l)); 333 | const labelsToAdd = labels.filter(l => !this.pr_labels.includes(l)); 334 | for (const label of labelsToDelete) { 335 | await this.ctx.github.issues.removeLabel({ 336 | owner: this.ctx.payload.repository.owner.login, 337 | repo: this.ctx.payload.repository.name, 338 | issue_number: this.pr_number, 339 | name: label 340 | }); 341 | } 342 | 343 | // skip addLabels if there's no label to add 344 | if (labelsToAdd.length > 0) { 345 | await this.ctx.github.issues.addLabels({ 346 | owner: this.ctx.payload.repository.owner.login, 347 | repo: this.ctx.payload.repository.name, 348 | issue_number: this.pr_number, 349 | labels: labelsToAdd 350 | }); 351 | } 352 | this.dispatch_labels = labels; 353 | } 354 | 355 | parseCommandArgs(): boolean { 356 | switch (this.command) { 357 | case CIFlowBot.command_ciflow: { 358 | if (this.command_args._.length === 0) { 359 | return false; 360 | } 361 | const commandArgsLength = Object.keys(this.command_args).length; 362 | const subCommand = this.command_args._[0]; 363 | if (subCommand !== CIFlowBot.command_ciflow_rerun) { 364 | return false; 365 | } 366 | // `rerun` command is confusing if it has any other subcommand 367 | if (this.command_args._.length !== 1) { 368 | this.confusing_command = true; 369 | } 370 | const lType = typeof this.command_args.l; 371 | // `rerun` does not accept any other options but "-l" 372 | // So, mark command as confusing if it has any other arguments than l 373 | if (lType === 'undefined') { 374 | this.confusing_command = 375 | this.confusing_command || commandArgsLength !== 1; 376 | break; 377 | } 378 | this.confusing_command = 379 | this.confusing_command || commandArgsLength !== 2; 380 | if (lType !== 'object') { 381 | // Arg can be string, integer or boolean 382 | this.command_args.l = [this.command_args.l]; 383 | } 384 | for (const label of this.command_args.l) { 385 | if (typeof label !== 'string') { 386 | this.confusing_command = true; 387 | continue; 388 | } 389 | if (!label.startsWith(CIFlowBot.pr_label_prefix)) { 390 | this.confusing_command = true; 391 | } 392 | this.dispatch_labels.push(label); 393 | } 394 | break; 395 | } 396 | default: 397 | return false; 398 | } 399 | 400 | return true; 401 | } 402 | 403 | parseComment(): boolean { 404 | // skip if the comment edit event is from the bot comment itself 405 | if ( 406 | this.command.includes(ciflowCommentStart) || 407 | this.command.includes(ciflowCommentEnd) 408 | ) { 409 | return false; 410 | } 411 | 412 | // considering the `m` multi-line comment match 413 | const re = new RegExp( 414 | `^.*@${CIFlowBot.bot_assignee}\\s+(\\w+)\\s+(.*)$`, 415 | 'm' 416 | ); 417 | 418 | const found = this.comment_body?.match(re); 419 | if (!found) { 420 | return false; 421 | } 422 | 423 | if (found.length >= 2) { 424 | this.command = found[1]; 425 | } 426 | if (found.length === 3) { 427 | this.command_args = minimist(found[2].trim().split(/\s+/)); 428 | } 429 | 430 | return this.parseCommandArgs(); 431 | } 432 | 433 | async setContext(): Promise { 434 | this.event = this.ctx.name; 435 | const pr = this.ctx.payload?.pull_request || this.ctx.payload?.issue; 436 | this.pr_number = pr?.number; 437 | this.pr_author = pr?.user?.login; 438 | this.pr_labels = pr?.labels 439 | ?.filter(label => label.name.startsWith(CIFlowBot.pr_label_prefix)) 440 | ?.map(label => label.name); 441 | this.owner = this.ctx.payload?.repository?.owner?.login; 442 | this.repo = this.ctx.payload?.repository?.name; 443 | 444 | if (this.event === CIFlowBot.event_issue_comment) { 445 | this.comment_author = this.ctx.payload?.comment?.user?.login; 446 | this.comment_body = this.ctx.payload?.comment?.body; 447 | this.comment_id = this.ctx.payload?.comment?.id; 448 | 449 | // if parseComment returns false, we don't need to do anything 450 | if (!this.parseComment()) { 451 | return false; 452 | } 453 | 454 | const permission = await this.getUserPermission(this.comment_author); 455 | this.comment_author_permission = permission; 456 | } 457 | 458 | return this.valid(); 459 | } 460 | 461 | async handler(): Promise { 462 | const isValid = await this.setContext(); 463 | this.default_labels = await this.getUserLabels(); 464 | 465 | this.ctx.log.info( 466 | { 467 | command: this.command, 468 | command_args: this.command_args, 469 | comment_author: this.comment_author, 470 | comment_author_permission: this.comment_author_permission, 471 | dispatch_labels: this.dispatch_labels, 472 | dispatch_strategies: this.dispatch_strategies, 473 | event: this.event, 474 | owner: this.owner, 475 | pr_author: this.pr_author, 476 | pr_labels: this.pr_labels, 477 | pr_number: this.pr_number, 478 | repo: this.repo, 479 | default_labels: this.default_labels, 480 | confusing_command: this.confusing_command, 481 | valid: isValid 482 | }, 483 | 'ciflow dispatch started!' 484 | ); 485 | 486 | if (!isValid || this.default_labels.length === 0) { 487 | return; 488 | } 489 | await this.dispatch(); 490 | } 491 | 492 | static main(app: probot.Application): void { 493 | const tracker = new CachedIssueTracker( 494 | app, 495 | 'ciflow_tracking_issue', 496 | parseCIFlowIssue 497 | ); 498 | const webhookHandler = async (ctx: probot.Context): Promise => { 499 | await new CIFlowBot(ctx, tracker).handler(); 500 | }; 501 | app.on('pull_request.opened', webhookHandler); 502 | app.on('pull_request.reopened', webhookHandler); 503 | app.on('pull_request.synchronize', webhookHandler); 504 | app.on('issue_comment.created', webhookHandler); 505 | app.on('issue_comment.edited', webhookHandler); 506 | } 507 | } 508 | 509 | interface IRulesetJson { 510 | version: string; 511 | label_rules: {[key: string]: string[]}; 512 | } 513 | 514 | // Ruleset is a class that represents the configuration of ciflow rules 515 | // defined by in the pytorch/pytorch repo (.github/generated-ciflow-ruleset.json) 516 | // Its purpose here for the CIFlowBot is to explicitly visualize the ruleset on PR 517 | export class Ruleset { 518 | static readonly ruleset_json_path = '.github/generated-ciflow-ruleset.json'; 519 | 520 | ruleset_json_link: string; 521 | 522 | constructor( 523 | readonly ctx: probot.Context, 524 | readonly owner: string, 525 | readonly repo: string, 526 | readonly pr_number: number, 527 | readonly labels: string[] 528 | ) {} 529 | 530 | async fetchRulesetJson(): Promise { 531 | const prRes = await this.ctx.github.pulls.get({ 532 | owner: this.owner, 533 | repo: this.repo, 534 | pull_number: this.pr_number 535 | }); 536 | const head = prRes?.data?.head; 537 | const contentRes = await this.ctx.github.repos.getContents({ 538 | ref: head.sha, 539 | owner: head.repo.owner.login, 540 | repo: head.repo.name, 541 | path: Ruleset.ruleset_json_path 542 | }); 543 | 544 | if ('content' in contentRes.data) { 545 | this.ruleset_json_link = contentRes?.data?.html_url; 546 | return JSON.parse( 547 | Buffer.from(contentRes?.data?.content, 'base64').toString('utf-8') 548 | ); 549 | } 550 | return null; 551 | } 552 | 553 | async fetchRootComment(perPage = 10): Promise<[number, string]> { 554 | const commentsRes = await this.ctx.github.issues.listComments({ 555 | owner: this.owner, 556 | repo: this.repo, 557 | issue_number: this.pr_number, 558 | per_page: perPage 559 | }); 560 | for (const comment of commentsRes.data) { 561 | if (comment.body.includes(ciflowCommentStart)) { 562 | return [comment.id, comment.body]; 563 | } 564 | } 565 | return [0, '']; 566 | } 567 | 568 | genRootCommentBody(ruleset: IRulesetJson, labels: Set): string { 569 | let body = '\n
CI Flow Status
\n'; 570 | body += '\n## :atom_symbol: CI Flow'; 571 | body += `\nRuleset - Version: \`${ruleset.version}\``; 572 | body += `\nRuleset - File: ${this.ruleset_json_link}`; 573 | body += `\nPR ciflow labels: \`${Array.from(labels)}\``; 574 | 575 | const workflowToLabelMap = {}; 576 | 577 | for (const l in ruleset.label_rules) { 578 | for (const w of ruleset.label_rules[l]) { 579 | workflowToLabelMap[w] = workflowToLabelMap[w] || new Set(); 580 | workflowToLabelMap[w].add(l); 581 | } 582 | } 583 | 584 | const triggeredRows = []; 585 | const skippedRows = []; 586 | for (const w in workflowToLabelMap) { 587 | let enabled = false; 588 | for (const l of Array.from(workflowToLabelMap[w])) { 589 | if (labels.has(l as string)) { 590 | enabled = true; 591 | break; 592 | } 593 | } 594 | 595 | const ls = Array.from(workflowToLabelMap[w]); 596 | const rowLabels = (ls as string[]) 597 | .sort((a, b) => a.localeCompare(b)) 598 | .map(l => { 599 | return labels.has(l) ? `**\`${l}\`**` : `\`${l}\``; 600 | }); 601 | 602 | if (enabled) { 603 | triggeredRows.push([w, rowLabels, ':white_check_mark: triggered']); 604 | } else { 605 | skippedRows.push([w, rowLabels, ':no_entry_sign: skipped']); 606 | } 607 | } 608 | triggeredRows.sort((a, b) => a[0].localeCompare(b[0])); 609 | skippedRows.sort((a, b) => a[0].localeCompare(b[0])); 610 | 611 | body += '\n| Workflows | Labels (bold enabled) | Status |'; 612 | body += '\n| :-------- | :-------------------- | :------ |'; 613 | body += '\n| **Triggered Workflows** |'; 614 | for (const row of triggeredRows) { 615 | body += `\n| ${row[0]} | ${row[1].join(', ')} | ${row[2]} |`; 616 | } 617 | body += '\n| **Skipped Workflows** |'; 618 | for (const row of skippedRows) { 619 | body += `\n| ${row[0]} | ${row[1].join(', ')} | ${row[2]} |`; 620 | } 621 | 622 | body += ` 623 |
624 | You can add a comment to the PR and tag @pytorchbot with the following commands: 625 |
626 | 627 | \`\`\`sh 628 | # ciflow rerun, "ciflow/default" will always be added automatically 629 | @pytorchbot ciflow rerun 630 | 631 | # ciflow rerun with additional labels "-l ", which is equivalent to adding these labels manually and trigger the rerun 632 | @pytorchbot ciflow rerun -l ciflow/scheduled -l ciflow/slow 633 | \`\`\` 634 | 635 |
636 | 637 | For more information, please take a look at the [CI Flow Wiki](https://github.com/pytorch/pytorch/wiki/Continuous-Integration#using-ciflow). 638 |
`; 639 | 640 | return body; 641 | } 642 | 643 | async upsertRootComment(): Promise { 644 | const ruleset = await this.fetchRulesetJson(); 645 | if (!ruleset) { 646 | this.ctx.log.error( 647 | {pr_number: this.pr_number}, 648 | 'failed to fetchRulesetJson' 649 | ); 650 | return; 651 | } 652 | 653 | // eslint-disable-next-line prefer-const 654 | let [commentId, commentBody] = await this.fetchRootComment(); 655 | 656 | let body = this.genRootCommentBody(ruleset, new Set(this.labels)); 657 | if (commentBody.includes(ciflowCommentStart)) { 658 | body = commentBody.replace( 659 | new RegExp(`${ciflowCommentStart}(.*?)${ciflowCommentEnd}`, 's'), 660 | `${ciflowCommentStart}${body}${ciflowCommentEnd}` 661 | ); 662 | } else { 663 | body = `${commentBody}\n${ciflowCommentStart}${body}${ciflowCommentEnd}`; 664 | } 665 | 666 | if (commentId === 0) { 667 | const res = await this.ctx.github.issues.createComment({ 668 | body, 669 | owner: this.owner, 670 | repo: this.repo, 671 | issue_number: this.pr_number 672 | }); 673 | commentId = res.data.id; 674 | } else { 675 | await this.ctx.github.issues.updateComment({ 676 | body, 677 | owner: this.owner, 678 | repo: this.repo, 679 | comment_id: commentId 680 | }); 681 | } 682 | } 683 | } 684 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import {serverless} from '@probot/serverless-lambda'; 2 | import {install} from 'source-map-support'; 3 | import appFn from './'; 4 | 5 | // Needed for traceback translation from transpiled javascript -> typescript 6 | install(); 7 | 8 | module.exports.probot = serverless(appFn); 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {install} from 'source-map-support'; 2 | 3 | import autoCcBot from './auto-cc-bot'; 4 | import autoLabelBot from './auto-label-bot'; 5 | import triggerCircleCiBot from './trigger-circleci-workflows'; 6 | import verifyDisableTestIssueBot from './verify-disable-test-issue'; 7 | import {CIFlowBot} from './ciflow-bot'; 8 | import mergeBot from './merge-bot'; 9 | import {Application} from 'probot'; 10 | 11 | // Needed for traceback translation from transpiled javascript -> typescript 12 | install(); 13 | 14 | function runBot(app: Application): void { 15 | autoCcBot(app); 16 | autoLabelBot(app); 17 | triggerCircleCiBot(app); 18 | verifyDisableTestIssueBot(app); 19 | mergeBot(app); 20 | 21 | // kill switch for ciflow 22 | if (process.env.ENABLE_CIFLOWBOT === 'true') { 23 | CIFlowBot.main(app); 24 | } 25 | } 26 | 27 | export = runBot; 28 | -------------------------------------------------------------------------------- /src/merge-bot.ts: -------------------------------------------------------------------------------- 1 | import * as probot from 'probot'; 2 | 3 | function mergeBot(app: probot.Application): void { 4 | const cmdPat = new RegExp('@pytorchbot\\s+merge\\s+this'); 5 | app.on('issue_comment.created', async ctx => { 6 | const commentBody = ctx.payload?.comment?.body; 7 | const owner = ctx.payload?.repository?.owner?.login; 8 | const repo = ctx.payload?.repository?.name; 9 | const commentId = ctx.payload?.comment?.id; 10 | const prNum = ctx.payload?.issue?.number; 11 | if (commentBody?.match(cmdPat)) { 12 | await ctx.github.repos.createDispatchEvent({ 13 | owner, 14 | repo, 15 | event_type: 'try-merge', 16 | client_payload: { 17 | pr_num: prNum 18 | } 19 | }); 20 | await ctx.github.reactions.createForIssueComment({ 21 | comment_id: commentId, 22 | content: '+1', 23 | owner, 24 | repo 25 | }); 26 | } 27 | }); 28 | } 29 | 30 | export default mergeBot; 31 | -------------------------------------------------------------------------------- /src/subscriptions.ts: -------------------------------------------------------------------------------- 1 | export function parseSubscriptions(rawSubsText): object { 2 | const subsText = rawSubsText.replace('\r', ''); 3 | const subsRows = subsText.match(/^\*.+/gm); 4 | const subscriptions = {}; 5 | if (subsRows == null) { 6 | return subscriptions; 7 | } 8 | // eslint-disable-next-line github/array-foreach 9 | subsRows.forEach((row: string) => { 10 | const labelMatch = row.match(/^\* +([^@]+)/); 11 | if (labelMatch) { 12 | const label = labelMatch[1].trim(); 13 | const users = row.match(/@[a-zA-Z0-9-/]+/g); 14 | if (users) { 15 | subscriptions[label] = users.map(u => u.substring(1)); 16 | } 17 | } 18 | }); 19 | return subscriptions; 20 | } 21 | -------------------------------------------------------------------------------- /src/trigger-circleci-workflows.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as probot from 'probot'; 3 | import * as utils from './utils'; 4 | 5 | interface Params { 6 | [param: string]: boolean; 7 | } 8 | 9 | interface LabelParams { 10 | parameter?: string; 11 | default_true_on?: { 12 | branches?: string[]; 13 | tags?: string[]; 14 | pull_request?: null; 15 | }; 16 | set_to_false?: boolean; 17 | } 18 | 19 | interface Config { 20 | default_params?: Params; 21 | labels_to_circle_params: { 22 | [label: string]: LabelParams; 23 | }; 24 | } 25 | 26 | export const configName = 'pytorch-circleci-labels.yml'; 27 | export const circleAPIUrl = 'https://circleci.com'; 28 | const circleToken = process.env.CIRCLE_TOKEN; 29 | const repoMap = new Map(); 30 | 31 | async function loadConfig(context: probot.Context): Promise { 32 | const repoKey = utils.repoKey(context); 33 | let configObj = repoMap.get(repoKey); 34 | if (configObj === undefined) { 35 | context.log.info({repoKey}, 'loadConfig'); 36 | configObj = (await context.config(configName)) as Config | {}; 37 | if (configObj === null || !configObj['labels_to_circle_params']) { 38 | return {}; 39 | } 40 | context.log.info({configObj}, 'loadConfig'); 41 | repoMap.set(repoKey, configObj); 42 | } 43 | return repoMap.get(repoKey); 44 | } 45 | 46 | function isValidConfig( 47 | context: probot.Context, 48 | config: Config | {} 49 | ): config is Config { 50 | if (Object.keys(config).length === 0 || !config['labels_to_circle_params']) { 51 | context.log.info( 52 | `No valid configuration found for repository ${utils.repoKey(context)}` 53 | ); 54 | return false; 55 | } 56 | return true; 57 | } 58 | 59 | function stripReference(reference: string): string { 60 | return reference.replace(/refs\/(heads|tags)\//, ''); 61 | } 62 | 63 | async function getAppliedLabels(context: probot.Context): Promise { 64 | const appliedLabels = new Array(); 65 | // Check if we already have the applied labels in our context payload 66 | if (context.payload['pull_request']['labels']) { 67 | for (const label of context.payload['pull_request']['labels']) { 68 | appliedLabels.push(label['name']); 69 | } 70 | } 71 | context.log.info({appliedLabels}, 'getAppliedLabels'); 72 | return appliedLabels; 73 | } 74 | 75 | async function triggerCircleCI( 76 | context: probot.Context, 77 | data: object 78 | ): Promise { 79 | const repoKey = utils.repoKey(context); 80 | context.log.info({repoKey, data}, 'triggerCircleCI'); 81 | const resp = await axios.post( 82 | `${circleAPIUrl}${circlePipelineEndpoint(repoKey)}`, 83 | data, 84 | { 85 | validateStatus: () => { 86 | return true; 87 | }, 88 | auth: { 89 | username: circleToken, 90 | password: '' 91 | } 92 | } 93 | ); 94 | 95 | if (resp.status !== 201) { 96 | throw Error( 97 | `Error triggering downstream circleci workflow (${ 98 | resp.status 99 | }): ${JSON.stringify(resp.data)}` 100 | ); 101 | } 102 | context.log.info({data}, `Build triggered successfully for ${repoKey}`); 103 | } 104 | 105 | export function circlePipelineEndpoint(repoKey: string): string { 106 | return `/api/v2/project/github/${repoKey}/pipeline`; 107 | } 108 | 109 | function invert(label: string): string { 110 | return label.replace(/^ci\//, 'ci/no-'); 111 | } 112 | 113 | export function genCircleParametersForPR( 114 | config: Config, 115 | context: probot.Context, 116 | appliedLabels: string[] 117 | ): Params { 118 | context.log.info({config, appliedLabels}, 'genParametersForPR'); 119 | const { 120 | default_params: parameters = {}, 121 | labels_to_circle_params: labelsToParams 122 | } = config; 123 | context.log.info({parameters}, 'genCircleParametersForPR (default_params)'); 124 | for (const label of Object.keys(labelsToParams)) { 125 | const defaultTrueOn = labelsToParams[label].default_true_on || {}; 126 | if ( 127 | appliedLabels.includes(label) || 128 | (defaultTrueOn.pull_request !== undefined && 129 | !appliedLabels.includes(invert(label))) 130 | ) { 131 | const {parameter} = labelsToParams[label]; 132 | if (parameter !== undefined) { 133 | context.log.info( 134 | {parameter}, 135 | 'genCircleParametersForPR setting parameter to true' 136 | ); 137 | parameters[parameter] = true; 138 | } 139 | if (labelsToParams[label].set_to_false) { 140 | const falseParams = labelsToParams[label].set_to_false; 141 | // There's potential for labels to override each other which we should 142 | // probably be careful of 143 | for (const falseLabel of Object.keys(falseParams)) { 144 | const param = falseParams[falseLabel]; 145 | context.log.info( 146 | {param}, 147 | 'genCircleParametersForPR (set_to_false) setting param to false' 148 | ); 149 | parameters[param] = false; 150 | } 151 | } 152 | } 153 | } 154 | return parameters; 155 | } 156 | 157 | function genCircleParametersForPush( 158 | config: Config, 159 | context: probot.Context 160 | ): Params { 161 | const { 162 | default_params: parameters = {}, 163 | labels_to_circle_params: labelsToParams 164 | } = config; 165 | context.log.info({parameters}, 'genCircleParametersForPush (default_params)'); 166 | const onTag: boolean = context.payload['ref'].startsWith('refs/tags'); 167 | const strippedRef: string = stripReference(context.payload['ref']); 168 | for (const label of Object.keys(labelsToParams)) { 169 | context.log.info({label}, 'genParametersForPush'); 170 | const defaultTrueOn = labelsToParams[label].default_true_on; 171 | if (!defaultTrueOn) { 172 | context.log.info( 173 | {label}, 174 | 'genParametersForPush (no default_true_on found)' 175 | ); 176 | continue; 177 | } 178 | const refsToMatch = onTag ? 'tags' : 'branches'; 179 | context.log.info({defaultTrueOn, refsToMatch, strippedRef}); 180 | for (const pattern of defaultTrueOn[refsToMatch] || []) { 181 | context.log.info({pattern}, 'genParametersForPush'); 182 | if (strippedRef.match(pattern)) { 183 | const {parameter} = labelsToParams[label]; 184 | if (parameter !== undefined) { 185 | context.log.info( 186 | {parameter}, 187 | 'genParametersForPush setting parameter to true' 188 | ); 189 | parameters[parameter] = true; 190 | } 191 | if (labelsToParams[label].set_to_false) { 192 | const falseParams = labelsToParams[label].set_to_false; 193 | // There's potential for labels to override each other which we should 194 | // probably be careful of 195 | for (const falseLabel of Object.keys(falseParams)) { 196 | const param = falseParams[falseLabel]; 197 | context.log.info( 198 | {param}, 199 | 'genParametersForPush (set_to_false) setting param to false' 200 | ); 201 | parameters[param] = false; 202 | } 203 | } 204 | } 205 | } 206 | } 207 | return parameters; 208 | } 209 | 210 | async function runBotForPR(context: probot.Context): Promise { 211 | try { 212 | let triggerBranch = context.payload['pull_request']['head']['ref']; 213 | if (context.payload['pull_request']['head']['repo']['fork']) { 214 | triggerBranch = `pull/${context.payload['pull_request']['number']}/head`; 215 | } 216 | context.log.info({triggerBranch}, 'runBotForPR'); 217 | const config = await loadConfig(context); 218 | if (!isValidConfig(context, config)) { 219 | return; 220 | } 221 | const labels = await getAppliedLabels(context); 222 | const parameters = genCircleParametersForPR(config, context, labels); 223 | context.log.info({config, labels, parameters}, 'runBot'); 224 | if (Object.keys(parameters).length !== 0) { 225 | await triggerCircleCI(context, { 226 | branch: triggerBranch, 227 | parameters 228 | }); 229 | } else { 230 | context.log.info( 231 | `No labels applied for ${context.payload['number']}, not triggering an extra CircleCI build` 232 | ); 233 | } 234 | } catch (err) { 235 | context.log.error({err}, 'runBotForPR'); 236 | } 237 | } 238 | 239 | async function runBotForPush(context: probot.Context): Promise { 240 | try { 241 | const rawRef = context.payload['ref']; 242 | const onTag: boolean = rawRef.startsWith('refs/tags'); 243 | const ref = stripReference(rawRef); 244 | context.log.info({rawRef, onTag, ref}, 'runBotForPush'); 245 | const config = await loadConfig(context); 246 | if (!isValidConfig(context, config)) { 247 | return; 248 | } 249 | const parameters = genCircleParametersForPush(config, context); 250 | const refKey: string = onTag ? 'tag' : 'branch'; 251 | context.log.info({parameters}, 'runBot'); 252 | if (Object.keys(parameters).length !== 0) { 253 | await triggerCircleCI(context, { 254 | [refKey]: ref, 255 | parameters 256 | }); 257 | } 258 | } catch (err) { 259 | context.log.error({err}, 'runBotForPush'); 260 | } 261 | } 262 | 263 | export function myBot(app: probot.Application): void { 264 | app.on('pull_request.labeled', runBotForPR); 265 | app.on('pull_request.synchronize', runBotForPR); 266 | app.on('push', runBotForPush); 267 | } 268 | 269 | export default myBot; 270 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as probot from 'probot'; 2 | 3 | export function repoKey(context: probot.Context): string { 4 | const repo = context.repo(); 5 | return `${repo.owner}/${repo.repo}`; 6 | } 7 | 8 | export class CachedConfigTracker { 9 | repoConfigs = {}; 10 | 11 | constructor(app: probot.Application) { 12 | app.on('push', async context => { 13 | if (context.payload.ref === 'refs/heads/master') { 14 | await this.loadConfig(context, /* force */ true); 15 | } 16 | }); 17 | } 18 | 19 | async loadConfig(context: probot.Context, force = false): Promise { 20 | const key = repoKey(context); 21 | if (!(key in this.repoConfigs) || force) { 22 | context.log({key}, 'loadConfig'); 23 | this.repoConfigs[key] = await context.config('pytorch-probot.yml'); 24 | } 25 | return this.repoConfigs[key]; 26 | } 27 | } 28 | 29 | export class CachedIssueTracker extends CachedConfigTracker { 30 | repoIssues = {}; 31 | configName: string; 32 | issueParser: (data: string) => object; 33 | 34 | constructor( 35 | app: probot.Application, 36 | configName: string, 37 | issueParser: (data: string) => object 38 | ) { 39 | super(app); 40 | this.configName = configName; 41 | this.issueParser = issueParser; 42 | 43 | app.on('issues.edited', async context => { 44 | const config = await this.loadConfig(context); 45 | const issue = context.issue(); 46 | if (config[this.configName] === issue.number) { 47 | await this.loadIssue(context, /* force */ true); 48 | } 49 | }); 50 | } 51 | 52 | async loadIssue(context: probot.Context, force = false): Promise { 53 | const key = repoKey(context); 54 | if (!(key in this.repoIssues) || force) { 55 | context.log({key}, 'loadIssue'); 56 | const config = await this.loadConfig(context); 57 | if (config != null && this.configName in config) { 58 | const subsPayload = await context.github.issues.get( 59 | context.repo({issue_number: config[this.configName]}) 60 | ); 61 | const subsText = subsPayload.data['body']; 62 | context.log({subsText}); 63 | this.repoIssues[key] = this.issueParser(subsText); 64 | } else { 65 | context.log( 66 | `${this.configName} is not found in config, initializing with empty string` 67 | ); 68 | this.repoIssues[key] = this.issueParser(''); 69 | } 70 | context.log({parsedIssue: this.repoIssues[key]}); 71 | } 72 | return this.repoIssues[key]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/verify-disable-test-issue.ts: -------------------------------------------------------------------------------- 1 | import * as probot from 'probot'; 2 | 3 | const validationCommentStart = ''; 4 | const validationCommentEnd = ''; 5 | const disabledKey = 'DISABLED '; 6 | const supportedPlatforms = new Set([ 7 | 'asan', 8 | 'linux', 9 | 'mac', 10 | 'macos', 11 | 'rocm', 12 | 'win', 13 | 'windows' 14 | ]); 15 | 16 | async function getValidationComment( 17 | context, 18 | issueNumber: number, 19 | owner: string, 20 | repo: string 21 | ): Promise<[number, string]> { 22 | const commentsRes = await context.github.issues.listComments({ 23 | owner, 24 | repo, 25 | issue_number: issueNumber, 26 | per_page: 10 27 | }); 28 | for (const comment of commentsRes.data) { 29 | if (comment.body.includes(validationCommentStart)) { 30 | return [comment.id, comment.body]; 31 | } 32 | } 33 | return [0, '']; 34 | } 35 | 36 | export function parseBody(body: string): [Set, Set] { 37 | const lines = body.split(/[\r\n]+/); 38 | const platformsToSkip = new Set(); 39 | const invalidPlatforms = new Set(); 40 | const key = 'platforms:'; 41 | for (let line of lines) { 42 | line = line.toLowerCase(); 43 | if (line.startsWith(key)) { 44 | for (const platform of line 45 | .slice(key.length) 46 | .split(/^\s+|\s*,\s*|\s+$/)) { 47 | if (supportedPlatforms.has(platform)) { 48 | platformsToSkip.add(platform); 49 | } else if (platform !== '') { 50 | invalidPlatforms.add(platform); 51 | } 52 | } 53 | } 54 | } 55 | return [platformsToSkip, invalidPlatforms]; 56 | } 57 | 58 | export function parseTitle(title: string): string { 59 | return title.slice(disabledKey.length).trim(); 60 | } 61 | 62 | function testNameIsExpected(testName: string): boolean { 63 | const split = testName.split(' '); 64 | if (split.length !== 2) { 65 | return false; 66 | } 67 | 68 | const testSuite = split[1].split('.'); 69 | if (testSuite.length !== 2) { 70 | return false; 71 | } 72 | return true; 73 | } 74 | 75 | export function formValidationComment( 76 | testName: string, 77 | platforms: [Set, Set] 78 | ): string { 79 | const platformsToSkip = Array.from(platforms[0]).sort((a, b) => 80 | a.localeCompare(b) 81 | ); 82 | const platformMsg = 83 | platformsToSkip.length === 0 84 | ? 'none parsed, defaulting to ALL platforms' 85 | : platformsToSkip.join(', '); 86 | const invalidPlatforms = Array.from(platforms[1]).sort((a, b) => 87 | a.localeCompare(b) 88 | ); 89 | 90 | let body = 91 | 'Hello there! From the DISABLED prefix in this issue title, '; 92 | body += 'it looks like you are attempting to disable a test in PyTorch CI. '; 93 | body += 'The information I have parsed is below:\n\n'; 94 | body += `* Test name: \`${testName}\`\n`; 95 | body += `* Platforms for which to skip the test: ${platformMsg}\n\n`; 96 | 97 | if (invalidPlatforms.length > 0) { 98 | body += 99 | 'WARNING! In the parsing process, I received these invalid inputs as platforms for '; 100 | body += `which the test will be disabled: ${invalidPlatforms.join( 101 | ', ' 102 | )}. These could `; 103 | body += 104 | 'be typos or platforms we do not yet support test disabling. Please '; 105 | body += 106 | 'verify the platform list above and modify your issue body if needed.\n\n'; 107 | } 108 | 109 | if (!testNameIsExpected(testName)) { 110 | body += 111 | 'ERROR! As you can see above, I could not properly parse the test '; 112 | body += 113 | 'information and determine which test to disable. Please modify the '; 114 | body += 115 | 'title to be of the format: DISABLED test_case_name (test.ClassName), '; 116 | body += 'for example, `test_cuda_assert_async (__main__.TestCuda)`.\n\n'; 117 | } else { 118 | body += `Within ~15 minutes, \`${testName}\` will be disabled in PyTorch CI for `; 119 | body += 120 | platformsToSkip.length === 0 121 | ? 'all platforms' 122 | : `these platforms: ${platformsToSkip.join(', ')}`; 123 | body += 124 | '. Please verify that your test name looks correct, e.g., `test_cuda_assert_async (__main__.TestCuda)`.\n\n'; 125 | } 126 | 127 | body += 128 | 'To modify the platforms list, please include a line in the issue body, like below. The default '; 129 | body += 130 | 'action will disable the test for all platforms if no platforms list is specified. \n'; 131 | body += 132 | '```\nPlatforms: case-insensitive, list, of, platforms\n```\nWe currently support the following platforms: '; 133 | body += `${Array.from(supportedPlatforms) 134 | .sort((a, b) => a.localeCompare(b)) 135 | .join(', ')}.`; 136 | 137 | return validationCommentStart + body + validationCommentEnd; 138 | } 139 | 140 | function myBot(app: probot.Application): void { 141 | app.on( 142 | ['issues.opened', 'issues.edited'], 143 | async (context: probot.Context) => { 144 | const state = context.payload['issue']['state']; 145 | const title = context.payload['issue']['title']; 146 | const owner = context.payload['repository']['owner']['login']; 147 | const repo = context.payload['repository']['name']; 148 | 149 | if (state === 'closed' || !title.startsWith(disabledKey)) { 150 | return; 151 | } 152 | 153 | const body = context.payload['issue']['body']; 154 | const number = context.payload['issue']['number']; 155 | const existingValidationCommentData = await getValidationComment( 156 | context, 157 | number, 158 | owner, 159 | repo 160 | ); 161 | const existingValidationCommentID = existingValidationCommentData[0]; 162 | const existingValidationComment = existingValidationCommentData[1]; 163 | 164 | const testName = parseTitle(title); 165 | const platforms = parseBody(body); 166 | const validationComment = formValidationComment(testName, platforms); 167 | 168 | if (existingValidationComment === validationComment) { 169 | return; 170 | } 171 | 172 | if (existingValidationCommentID === 0) { 173 | await context.github.issues.createComment({ 174 | body: validationComment, 175 | owner, 176 | repo, 177 | issue_number: number 178 | }); 179 | } else { 180 | await context.github.issues.updateComment({ 181 | body: validationComment, 182 | owner, 183 | repo, 184 | comment_id: existingValidationCommentID 185 | }); 186 | } 187 | } 188 | ); 189 | } 190 | 191 | export default myBot; 192 | -------------------------------------------------------------------------------- /test/auto-cc-bot.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import * as utils from './utils'; 3 | import myProbotApp from '../src/auto-cc-bot'; 4 | import {nockTracker} from './common'; 5 | 6 | nock.disableNetConnect(); 7 | 8 | describe('auto-cc-bot', () => { 9 | let probot; 10 | 11 | beforeEach(() => { 12 | probot = utils.testProbot(); 13 | probot.load(myProbotApp); 14 | }); 15 | 16 | test('no-op when tracker is missing', async () => { 17 | nock('https://api.github.com') 18 | .get( 19 | '/repos/ezyang/testing-ideal-computing-machine/contents/.github/pytorch-probot.yml' 20 | ) 21 | .reply(404, {message: 'There is nothing here'}); 22 | 23 | // Not sure why, but ProBot will look here if config is missing in the actual repo 24 | nock('https://api.github.com') 25 | .get('/repos/ezyang/.github/contents/.github/pytorch-probot.yml') 26 | .reply(404, {message: 'There is nothing here'}); 27 | 28 | const payload = require('./fixtures/issues.labeled'); // testlabel 29 | await probot.receive({name: 'issues', payload, id: '2'}); 30 | }); 31 | 32 | test('add a cc when issue is labeled', async () => { 33 | nock('https://api.github.com') 34 | .post('/app/installations/2/access_tokens') 35 | .reply(200, {token: 'test'}); 36 | 37 | nockTracker(` 38 | Some header text 39 | 40 | * testlabel @ezyang 41 | `); 42 | 43 | const payload = require('./fixtures/issues.labeled'); // testlabel 44 | payload['issue']['body'] = 'Arf arf'; 45 | 46 | const scope = nock('https://api.github.com') 47 | .patch( 48 | '/repos/ezyang/testing-ideal-computing-machine/issues/5', 49 | (body: any) => { 50 | expect(body).toMatchObject({ 51 | body: 'Arf arf\n\ncc @ezyang' 52 | }); 53 | return true; 54 | } 55 | ) 56 | .reply(200); 57 | 58 | await probot.receive({name: 'issues', payload, id: '2'}); 59 | 60 | scope.done(); 61 | }); 62 | test('add a cc to issue with empty body', async () => { 63 | nock('https://api.github.com') 64 | .post('/app/installations/2/access_tokens') 65 | .reply(200, {token: 'test'}); 66 | 67 | nockTracker(` 68 | Some header text 69 | 70 | * testlabel @ezyang 71 | `); 72 | 73 | const payload = require('./fixtures/issues.labeled'); // testlabel 74 | payload['issue']['body'] = null; 75 | 76 | const scope = nock('https://api.github.com') 77 | .patch( 78 | '/repos/ezyang/testing-ideal-computing-machine/issues/5', 79 | (body: any) => { 80 | expect(body).toMatchObject({ 81 | body: 'cc @ezyang' 82 | }); 83 | return true; 84 | } 85 | ) 86 | .reply(200); 87 | 88 | await probot.receive({name: 'issues', payload, id: '2'}); 89 | 90 | scope.done(); 91 | }); 92 | 93 | test('add a cc when PR is labeled', async () => { 94 | nock('https://api.github.com') 95 | .post('/app/installations/2/access_tokens') 96 | .reply(200, {token: 'test'}); 97 | 98 | nockTracker( 99 | ` 100 | Some header text 101 | 102 | * ci/windows @ezyang 103 | `, 104 | 'seemethere/test-repo' 105 | ); 106 | 107 | const payload = require('./fixtures/pull_request.labeled'); 108 | payload['pull_request']['body'] = 'Arf arf'; 109 | 110 | const scope = nock('https://api.github.com') 111 | .patch('/repos/seemethere/test-repo/pulls/20', (body: any) => { 112 | expect(body).toMatchObject({ 113 | body: 'Arf arf\n\ncc @ezyang' 114 | }); 115 | return true; 116 | }) 117 | .reply(200); 118 | 119 | await probot.receive({name: 'pull_request', payload, id: '2'}); 120 | 121 | scope.done(); 122 | }); 123 | 124 | test('update an existing cc when issue is labeled', async () => { 125 | nock('https://api.github.com') 126 | .post('/app/installations/2/access_tokens') 127 | .reply(200, {token: 'test'}); 128 | 129 | nockTracker(` 130 | Some header text 131 | 132 | * testlabel @ezyang 133 | `); 134 | 135 | const payload = require('./fixtures/issues.labeled'); 136 | payload['issue']['body'] = 'Arf arf\n\ncc @moo @foo/bar @mar\nxxxx'; 137 | 138 | const scope = nock('https://api.github.com') 139 | .patch( 140 | '/repos/ezyang/testing-ideal-computing-machine/issues/5', 141 | (body: any) => { 142 | expect(body).toMatchObject({ 143 | body: 'Arf arf\n\ncc @ezyang @moo @foo/bar @mar\nxxxx' 144 | }); 145 | return true; 146 | } 147 | ) 148 | .reply(200); 149 | 150 | await probot.receive({name: 'issues', payload, id: '2'}); 151 | 152 | scope.done(); 153 | }); 154 | 155 | test('mkldnn update bug', async () => { 156 | nock('https://api.github.com') 157 | .post('/app/installations/2/access_tokens') 158 | .reply(200, {token: 'test'}); 159 | 160 | nockTracker( 161 | `* module: mkldnn @gujinghui @PenghuiCheng @XiaobingSuper @ezyang` 162 | ); 163 | 164 | const payload = require('./fixtures/issues.labeled'); 165 | payload['issue'][ 166 | 'body' 167 | ] = `its from master branch, seems related with mklml. any idea? 168 | 169 | cc @ezyang`; 170 | payload['issue']['labels'] = [{name: 'module: mkldnn'}]; 171 | 172 | const scope = nock('https://api.github.com') 173 | .patch( 174 | '/repos/ezyang/testing-ideal-computing-machine/issues/5', 175 | (body: any) => { 176 | expect(body).toMatchObject({ 177 | body: `its from master branch, seems related with mklml. any idea? 178 | 179 | cc @gujinghui @PenghuiCheng @XiaobingSuper @ezyang` 180 | }); 181 | return true; 182 | } 183 | ) 184 | .reply(200); 185 | 186 | await probot.receive({name: 'issues', payload, id: '2'}); 187 | 188 | scope.done(); 189 | }); 190 | }); 191 | 192 | // For more information about testing with Jest see: 193 | // https://facebook.github.io/jest/ 194 | 195 | // For more information about testing with Nock see: 196 | // https://github.com/nock/nock 197 | -------------------------------------------------------------------------------- /test/auto-label-bot.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import {Probot} from 'probot'; 3 | import * as utils from './utils'; 4 | import myProbotApp from '../src/auto-label-bot'; 5 | 6 | nock.disableNetConnect(); 7 | 8 | describe('auto-label-bot', () => { 9 | let probot: Probot; 10 | 11 | beforeEach(() => { 12 | probot = utils.testProbot(); 13 | probot.load(myProbotApp); 14 | }); 15 | 16 | test('add triage review when issue is labeled high priority', async () => { 17 | nock('https://api.github.com') 18 | .post('/app/installations/2/access_tokens') 19 | .reply(200, {token: 'test'}); 20 | 21 | const payload = require('./fixtures/issues.labeled'); 22 | payload['label'] = {name: 'high priority'}; 23 | payload['issue']['labels'] = [{name: 'high priority'}]; 24 | 25 | const scope = nock('https://api.github.com') 26 | .post( 27 | '/repos/ezyang/testing-ideal-computing-machine/issues/5/labels', 28 | body => { 29 | expect(body).toMatchObject(['triage review']); 30 | return true; 31 | } 32 | ) 33 | .reply(200); 34 | 35 | await probot.receive({name: 'issues', payload, id: '2'}); 36 | 37 | scope.done(); 38 | }); 39 | 40 | test('add rocm label when issue title contains ROCm', async () => { 41 | nock('https://api.github.com') 42 | .post('/app/installations/2/access_tokens') 43 | .reply(200, {token: 'test'}); 44 | 45 | const payload = require('./fixtures/issues.opened'); 46 | payload['issue']['title'] = 'Issue regarding ROCm'; 47 | payload['issue']['labels'] = []; 48 | 49 | const scope = nock('https://api.github.com') 50 | .post( 51 | '/repos/ezyang/testing-ideal-computing-machine/issues/5/labels', 52 | body => { 53 | expect(body).toMatchObject(['module: rocm']); 54 | return true; 55 | } 56 | ) 57 | .reply(200); 58 | 59 | await probot.receive({name: 'issues', payload: payload, id: '2'}); 60 | 61 | scope.done(); 62 | }); 63 | 64 | test('add rocm label when PR title contains ROCm', async () => { 65 | nock('https://api.github.com') 66 | .post('/app/installations/2/access_tokens') 67 | .reply(200, {token: 'test'}); 68 | 69 | const payload = require('./fixtures/pull_request.opened')['payload']; 70 | payload['pull_request']['title'] = 'Issue regarding ROCm'; 71 | payload['pull_request']['labels'] = []; 72 | 73 | const scope = nock('https://api.github.com') 74 | .post('/repos/zhouzhuojie/gha-ci-playground/issues/31/labels', body => { 75 | expect(body).toMatchObject(['module: rocm']); 76 | return true; 77 | }) 78 | .reply(200); 79 | 80 | await probot.receive({name: 'pull_request', payload: payload, id: '2'}); 81 | 82 | scope.done(); 83 | }); 84 | 85 | test('add skipped label when issue title contains DISABLED test', async () => { 86 | nock('https://api.github.com') 87 | .post('/app/installations/2/access_tokens') 88 | .reply(200, {token: 'test'}); 89 | 90 | const payload = require('./fixtures/issues.opened'); 91 | payload['issue']['title'] = 'DISABLED test_blah (__main__.TestClass)'; 92 | payload['issue']['labels'] = []; 93 | 94 | const scope = nock('https://api.github.com') 95 | .post( 96 | '/repos/ezyang/testing-ideal-computing-machine/issues/5/labels', 97 | body => { 98 | expect(body).toMatchObject(['skipped']); 99 | return true; 100 | } 101 | ) 102 | .reply(200); 103 | 104 | await probot.receive({name: 'issues', payload: payload, id: '2'}); 105 | 106 | scope.done(); 107 | }); 108 | 109 | test('add skipped label when PR title contains DISABLED test', async () => { 110 | nock('https://api.github.com') 111 | .post('/app/installations/2/access_tokens') 112 | .reply(200, {token: 'test'}); 113 | 114 | const payload = require('./fixtures/pull_request.opened')['payload']; 115 | payload['pull_request']['title'] = 116 | 'DISABLED test_blah (__main__.TestClass)'; 117 | payload['pull_request']['labels'] = []; 118 | 119 | const scope = nock('https://api.github.com') 120 | .post('/repos/zhouzhuojie/gha-ci-playground/issues/31/labels', body => { 121 | expect(body).toMatchObject(['skipped']); 122 | return true; 123 | }) 124 | .reply(200); 125 | 126 | await probot.receive({name: 'pull_request', payload: payload, id: '2'}); 127 | 128 | scope.done(); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/ciflow-bot.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import * as probot from 'probot'; 3 | import * as utils from './utils'; 4 | import {CIFlowBot, Ruleset} from '../src/ciflow-bot'; 5 | import {nockTracker} from './common'; 6 | 7 | nock.disableNetConnect(); 8 | jest.setTimeout(60000); // 60 seconds 9 | 10 | describe('CIFlowBot Unit Tests', () => { 11 | const pr_number = 5; 12 | const owner = 'pytorch'; 13 | const repo = 'pytorch'; 14 | 15 | beforeEach(() => { 16 | jest 17 | .spyOn(CIFlowBot.prototype, 'getUserPermission') 18 | .mockResolvedValue('write'); 19 | }); 20 | 21 | afterEach(() => { 22 | jest.restoreAllMocks(); 23 | }); 24 | 25 | test('parseContext for pull_request.opened', async () => { 26 | const event = require('./fixtures/pull_request.opened.json'); 27 | event.payload.pull_request.number = pr_number; 28 | event.payload.repository.owner.login = owner; 29 | event.payload.repository.name = repo; 30 | 31 | const ciflow = new CIFlowBot(new probot.Context(event, null, null)); 32 | const isValid = await ciflow.setContext(); 33 | expect(isValid).toBe(true); 34 | }); 35 | 36 | test('parseContext for pull_request.reopened', async () => { 37 | const event = require('./fixtures/pull_request.reopened.json'); 38 | event.payload.pull_request.number = pr_number; 39 | event.payload.repository.owner.login = owner; 40 | event.payload.repository.name = repo; 41 | 42 | const ciflow = new CIFlowBot(new probot.Context(event, null, null)); 43 | const isValid = await ciflow.setContext(); 44 | expect(isValid).toBe(true); 45 | }); 46 | 47 | describe('parseContext for issue_comment.created with valid or invalid comments', () => { 48 | const event = require('./fixtures/issue_comment.json'); 49 | 50 | beforeEach(() => { 51 | event.payload.issue.number = pr_number; 52 | event.payload.repository.owner.login = owner; 53 | event.payload.repository.name = repo; 54 | event.payload.comment.user.login = event.payload.issue.user.login; 55 | }); 56 | 57 | const validComments = [ 58 | `@${CIFlowBot.bot_assignee} ciflow rerun`, 59 | `@${CIFlowBot.bot_assignee} ciflow rerun `, 60 | ` @${CIFlowBot.bot_assignee} ciflow rerun`, 61 | ` @${CIFlowBot.bot_assignee} ciflow rerun`, 62 | ` @${CIFlowBot.bot_assignee} ciflow rerun`, 63 | ` @${CIFlowBot.bot_assignee} ciflow rerun `, 64 | `Some other comments, \n@${CIFlowBot.bot_assignee} ciflow rerun`, 65 | `Some other comments, \n @${CIFlowBot.bot_assignee} ciflow rerun`, 66 | `Some other comments, \n@${CIFlowBot.bot_assignee} ciflow rerun`, 67 | `Some other comments, \n@${CIFlowBot.bot_assignee} ciflow rerun`, 68 | `Some other comments, \n@${CIFlowBot.bot_assignee} ciflow rerun -l ciflow/slow`, 69 | `Some other comments, \n@${CIFlowBot.bot_assignee} ciflow rerun -l ciflow/slow -l ciflow/scheduled`, 70 | `Some other comments, \n@${CIFlowBot.bot_assignee} ciflow rerun -l ciflow/slow`, // with spaces 71 | `Some other comments, \n@${CIFlowBot.bot_assignee} ciflow rerun -l ciflow/slow -l ciflow/scheduled`, 72 | `Some other comments, \n@${CIFlowBot.bot_assignee} ciflow rerun\nNew comments\n` 73 | ]; 74 | test.each(validComments)( 75 | `valid comment: %s`, 76 | async (validComment: string) => { 77 | event.payload.comment.body = validComment; 78 | const ciflow = new CIFlowBot(new probot.Context(event, null, null)); 79 | const isValid = await ciflow.setContext(); 80 | expect(isValid).toBe(true); 81 | expect(ciflow.confusing_command).toBe(false); 82 | } 83 | ); 84 | 85 | const invalidComments = [ 86 | `invalid`, 87 | `@${CIFlowBot.bot_assignee}`, // without commands appended after the @assignee 88 | `@${CIFlowBot.bot_assignee} ciflow` // without subcommand rerun 89 | ]; 90 | test.each(invalidComments)( 91 | 'invalid comment: %s', 92 | async (invalidComment: string) => { 93 | event.payload.comment.body = invalidComment; 94 | const ciflow = new CIFlowBot(new probot.Context(event, null, null)); 95 | const isValid = await ciflow.setContext(); 96 | expect(isValid).toBe(false); 97 | } 98 | ); 99 | const confusingComments = [ 100 | `@${CIFlowBot.bot_assignee} ciflow rerun again`, // two subcommands 101 | `@${CIFlowBot.bot_assignee} ciflow rerun -m foo`, // subcommand with invalid flag 102 | `@${CIFlowBot.bot_assignee} ciflow rerun -l`, // rerun -l with no args 103 | `@${CIFlowBot.bot_assignee} ciflow rerun -l 1`, // rerun -l with integer arg 104 | `@${CIFlowBot.bot_assignee} ciflow rerun -l meow` // rerun -l with integer arg 105 | ]; 106 | test.each(confusingComments)( 107 | 'confusing comment: %s', 108 | async (confusingComment: string) => { 109 | event.payload.comment.body = confusingComment; 110 | const ciflow = new CIFlowBot(new probot.Context(event, null, null)); 111 | const isValid = await ciflow.setContext(); 112 | expect(isValid).toBe(true); 113 | expect(ciflow.confusing_command).toBe(true); 114 | } 115 | ); 116 | }); 117 | 118 | test('parseContext for issue_comment.created with comment author that has write permission', async () => { 119 | const event = require('./fixtures/issue_comment.json'); 120 | event.payload.issue.number = pr_number; 121 | event.payload.repository.owner.login = owner; 122 | event.payload.repository.name = repo; 123 | event.payload.comment.body = `@${CIFlowBot.bot_assignee} ciflow rerun`; 124 | event.payload.comment.user.login = 'non-exist-user'; 125 | 126 | const ciflow = new CIFlowBot(new probot.Context(event, null, null)); 127 | jest.spyOn(ciflow, 'getUserPermission').mockResolvedValue('write'); 128 | const isValid = await ciflow.setContext(); 129 | expect(isValid).toBe(true); 130 | }); 131 | 132 | test('parseContext for issue_comment.created with comment author that has read permission', async () => { 133 | const event = require('./fixtures/issue_comment.json'); 134 | event.payload.issue.number = pr_number; 135 | event.payload.repository.owner.login = owner; 136 | event.payload.repository.name = repo; 137 | event.payload.comment.body = `@${CIFlowBot.bot_assignee} ciflow rerun`; 138 | event.payload.comment.user.login = 'non-exist-user'; 139 | 140 | const ciflow = new CIFlowBot(new probot.Context(event, null, null)); 141 | jest.spyOn(ciflow, 'getUserPermission').mockResolvedValue('read'); 142 | const isValid = await ciflow.setContext(); 143 | expect(isValid).toBe(false); 144 | }); 145 | /* 146 | test('parseContext for issue_comment.created invalid owner/repo', async () => { 147 | const event = require('./fixtures/issue_comment.json'); 148 | event.payload.issue.number = pr_number; 149 | event.payload.repository.owner.login = 'invalid'; 150 | event.payload.repository.name = 'invalid'; 151 | event.payload.comment.body = `@${CIFlowBot.bot_assignee} ciflow rerun`; 152 | event.payload.comment.user.login = event.payload.issue.user.login; 153 | 154 | const ciflow = new CIFlowBot(new probot.Context(event, null, null)); 155 | const isValid = await ciflow.setContext(); 156 | expect(isValid).toBe(false); 157 | }); 158 | */ 159 | }); 160 | 161 | describe('CIFlowBot Integration Tests', () => { 162 | let p: probot.Probot; 163 | const pr_number = 5; 164 | const owner = 'pytorch'; 165 | const repo = 'pytorch'; 166 | const comment_id = 10; 167 | 168 | beforeEach(() => { 169 | p = utils.testProbot(); 170 | p.load(CIFlowBot.main); 171 | 172 | nock('https://api.github.com') 173 | .post('/app/installations/2/access_tokens') 174 | .reply(200, {token: 'test'}); 175 | 176 | nockTracker( 177 | ` 178 | @zzj-bot 179 | @octocat ciflow/default cats 180 | -@opt-out-users`, 181 | 'pytorch/pytorch', 182 | 'ciflow_tracking_issue: 6' 183 | ); 184 | 185 | jest.spyOn(Ruleset.prototype, 'upsertRootComment').mockReturnValue(null); 186 | }); 187 | 188 | afterEach(() => { 189 | jest.restoreAllMocks(); 190 | }); 191 | 192 | test('pull_request.opened event: add_default_labels strategy happy path', async () => { 193 | const event = require('./fixtures/pull_request.opened.json'); 194 | event.payload.pull_request.number = pr_number; 195 | event.payload.repository.owner.login = owner; 196 | event.payload.repository.name = repo; 197 | 198 | const scope = nock('https://api.github.com') 199 | .post(`/repos/${owner}/${repo}/issues/${pr_number}/labels`, body => { 200 | expect(body).toMatchObject(['ciflow/default']); 201 | return true; 202 | }) 203 | .reply(200); 204 | 205 | await p.receive(event); 206 | 207 | if (!scope.isDone()) { 208 | console.error('pending mocks: %j', scope.pendingMocks()); 209 | } 210 | scope.done(); 211 | }); 212 | 213 | test('pull_request.opened event: add_default_labels strategy for a_random_user', async () => { 214 | const event = require('./fixtures/pull_request.opened.json'); 215 | event.payload.pull_request.number = pr_number; 216 | event.payload.pull_request.user.login = 'a_random_user'; 217 | event.payload.repository.owner.login = owner; 218 | event.payload.repository.name = repo; 219 | 220 | const scope = nock('https://api.github.com') 221 | .post(`/repos/${owner}/${repo}/issues/${pr_number}/labels`, body => { 222 | expect(body).toMatchObject(['ciflow/default']); 223 | return true; 224 | }) 225 | .reply(200); 226 | 227 | await p.receive(event); 228 | 229 | if (!scope.isDone()) { 230 | console.error('pending mocks: %j', scope.pendingMocks()); 231 | } 232 | scope.done(); 233 | }); 234 | 235 | test('pull_request.opened event: respect opt-out users', async () => { 236 | const event = require('./fixtures/pull_request.opened.json'); 237 | event.payload.pull_request.number = pr_number; 238 | event.payload.pull_request.user.login = 'opt-out-users'; 239 | event.payload.repository.owner.login = owner; 240 | event.payload.repository.name = repo; 241 | 242 | const scope = nock('https://api.github.com'); 243 | await p.receive(event); 244 | 245 | if (!scope.isDone()) { 246 | console.error('pending mocks: %j', scope.pendingMocks()); 247 | } 248 | scope.done(); 249 | }); 250 | 251 | test('pull_request.opened event: do not override pre-existing labels', async () => { 252 | const event = require('./fixtures/pull_request.opened.json'); 253 | event.payload.pull_request.number = pr_number; 254 | event.payload.pull_request.labels = [{name: 'ciflow/eeklo'}]; 255 | event.payload.repository.owner.login = owner; 256 | event.payload.repository.name = repo; 257 | 258 | const scope = nock('https://api.github.com'); 259 | await p.receive(event); 260 | 261 | if (!scope.isDone()) { 262 | console.error('pending mocks: %j', scope.pendingMocks()); 263 | } 264 | scope.done(); 265 | }); 266 | 267 | test('pull_request.opened event: add_default_labels strategy not rolled out', async () => { 268 | const event = require('./fixtures/pull_request.opened.json'); 269 | event.payload.pull_request.user.login = 'rumpelstiltskin'; 270 | event.payload.pull_request.number = pr_number; 271 | event.payload.repository.owner.login = owner; 272 | event.payload.repository.name = repo; 273 | 274 | const scope = nock('https://api.github.com'); 275 | await p.receive(event); 276 | 277 | if (!scope.isDone()) { 278 | console.error('pending mocks: %j', scope.pendingMocks()); 279 | } 280 | scope.done(); 281 | }); 282 | 283 | describe('issue_comment.created event: add_default_labels strategy happy path', () => { 284 | const event = require('./fixtures/issue_comment.json'); 285 | 286 | beforeEach(() => { 287 | event.payload.issue.number = pr_number; 288 | event.payload.repository.owner.login = owner; 289 | event.payload.repository.name = repo; 290 | event.payload.comment.user.login = 'non-exist-user'; 291 | event.payload.comment.id = comment_id; 292 | }); 293 | 294 | test.each([ 295 | [`@${CIFlowBot.bot_assignee} ciflow rerun`, ['ciflow/default']], 296 | [ 297 | `@${CIFlowBot.bot_assignee} ciflow rerun -l ciflow/scheduled`, 298 | ['ciflow/default', 'ciflow/scheduled'] 299 | ], 300 | [ 301 | `@${CIFlowBot.bot_assignee} ciflow rerun -l ciflow/scheduled -l ciflow/slow`, 302 | ['ciflow/default', 'ciflow/scheduled', 'ciflow/slow'] 303 | ] 304 | ])( 305 | `valid comment: %s, expected labels: %j`, 306 | async (validComment: string, expectedLabels: string[]) => { 307 | event.payload.comment.body = validComment; 308 | for (const permission of ['write', 'admin']) { 309 | const scope = nock('https://api.github.com') 310 | .get( 311 | `/repos/${owner}/${repo}/collaborators/${event.payload.comment.user.login}/permission` 312 | ) 313 | .reply(200, {permission: `${permission}`}) 314 | .post( 315 | `/repos/${owner}/${repo}/issues/${pr_number}/labels`, 316 | body => { 317 | expect(body).toMatchObject(expectedLabels); 318 | return true; 319 | } 320 | ) 321 | .reply(200) 322 | .post( 323 | `/repos/${owner}/${repo}/issues/${pr_number}/assignees`, 324 | body => { 325 | expect(body).toMatchObject({ 326 | assignees: [CIFlowBot.bot_assignee] 327 | }); 328 | return true; 329 | } 330 | ) 331 | .reply(200) 332 | .delete( 333 | `/repos/${owner}/${repo}/issues/${pr_number}/assignees`, 334 | body => { 335 | expect(body).toMatchObject({ 336 | assignees: [CIFlowBot.bot_assignee] 337 | }); 338 | return true; 339 | } 340 | ) 341 | .reply(200) 342 | .post( 343 | `/repos/${owner}/${repo}/issues/comments/${comment_id}/reactions`, 344 | body => { 345 | expect(body).toMatchObject({content: '+1'}); 346 | return true; 347 | } 348 | ) 349 | .reply(200); 350 | 351 | await p.receive(event); 352 | 353 | if (!scope.isDone()) { 354 | console.error('pending mocks: %j', scope.pendingMocks()); 355 | } 356 | scope.done(); 357 | } 358 | } 359 | ); 360 | }); 361 | 362 | describe('issue_comment.created event: add_default_labels strategy with invalid parseComments', () => { 363 | const event = require('./fixtures/issue_comment.json'); 364 | 365 | beforeEach(() => { 366 | event.payload.issue.number = pr_number; 367 | event.payload.repository.owner.login = owner; 368 | event.payload.repository.name = repo; 369 | event.payload.comment.user.login = 'non-exist-user'; 370 | }); 371 | 372 | test.each([ 373 | `invalid`, 374 | `@${CIFlowBot.bot_assignee} invalid`, 375 | `@${CIFlowBot.bot_assignee} ciflow invalid` 376 | ])(`invalid comment: %s`, async (invalidComment: string) => { 377 | event.payload.comment.body = invalidComment; 378 | 379 | // we shouldn't hit the github API, thus a catch-all scope and asserting no api calls 380 | const scope = nock('https://api.github.com'); 381 | 382 | await p.receive(event); 383 | if (!scope.isDone()) { 384 | console.error('pending mocks: %j', scope.pendingMocks()); 385 | } 386 | scope.done(); 387 | }); 388 | }); 389 | 390 | test('issue_comment.created event: add_default_labels strategy not not enough permission', async () => { 391 | const event = require('./fixtures/issue_comment.json'); 392 | event.payload.issue.number = pr_number; 393 | event.payload.repository.owner.login = owner; 394 | event.payload.repository.name = repo; 395 | event.payload.comment.body = `@${CIFlowBot.bot_assignee} ciflow rerun`; 396 | event.payload.comment.user.login = 'non-exist-user'; 397 | 398 | for (const permission of ['read', 'none']) { 399 | const scope = nock('https://api.github.com') 400 | .get( 401 | `/repos/${owner}/${repo}/collaborators/${event.payload.comment.user.login}/permission` 402 | ) 403 | .reply(200, {permission: `${permission}`}); 404 | await p.receive(event); 405 | 406 | if (!scope.isDone()) { 407 | console.error('pending mocks: %j', scope.pendingMocks()); 408 | } 409 | scope.done(); 410 | } 411 | }); 412 | }); 413 | 414 | describe('Ruleset Integration Tests', () => { 415 | const pr_number = 5; 416 | const owner = 'ezyang'; 417 | const repo = 'testing-ideal-computing-machine'; 418 | const comment_id = 10; 419 | const comment_node_id = 'abcd'; 420 | const sha = '6f0d678512460e8a1e797d31928b97b5e6244088'; 421 | 422 | const event = require('./fixtures/issue_comment.json'); 423 | event.payload.issue.number = pr_number; 424 | event.payload.repository.owner.login = owner; 425 | event.payload.repository.name = repo; 426 | event.payload.comment.user.login = event.payload.issue.user.login; 427 | const github = probot.GitHubAPI(); 428 | 429 | beforeEach(() => { 430 | nock('https://api.github.com') 431 | .post('/app/installations/2/access_tokens') 432 | .reply(200, {token: 'test'}); 433 | nockTracker('@octocat ciflow/none', 'pytorch/pytorch'); 434 | }); 435 | 436 | afterEach(() => { 437 | jest.restoreAllMocks(); 438 | }); 439 | 440 | test('Upsert ruleset to the root comment block: create new comment when not found', async () => { 441 | const ctx = new probot.Context(event, github, null); 442 | const ruleset = new Ruleset(ctx, owner, repo, pr_number, [ 443 | 'ciflow/default' 444 | ]); 445 | 446 | const scope = nock('https://api.github.com') 447 | .get(`/repos/${owner}/${repo}/pulls/${pr_number}`) 448 | .reply(200, { 449 | head: { 450 | sha: sha, 451 | repo: { 452 | name: repo, 453 | owner: { 454 | login: owner 455 | } 456 | } 457 | } 458 | }) 459 | .get( 460 | `/repos/${owner}/${repo}/contents/.github/generated-ciflow-ruleset.json?ref=${sha}` 461 | ) 462 | .reply(200, { 463 | content: Buffer.from( 464 | JSON.stringify({ 465 | version: 'v1', 466 | label_rules: { 467 | 'ciflow/default': ['sample_ci.yml'] 468 | } 469 | }) 470 | ).toString('base64') 471 | }) 472 | .get(`/repos/${owner}/${repo}/issues/${pr_number}/comments?per_page=10`) 473 | .reply(200, []) 474 | .post(`/repos/${owner}/${repo}/issues/${pr_number}/comments`, body => { 475 | expect(JSON.stringify(body)).toContain(''); 476 | expect(JSON.stringify(body)).toContain(''); 477 | return true; 478 | }) 479 | .reply(200, {}); 480 | await ruleset.upsertRootComment(); 481 | 482 | if (!scope.isDone()) { 483 | console.error('pending mocks: %j', scope.pendingMocks()); 484 | } 485 | scope.done(); 486 | }); 487 | 488 | test('Upsert ruleset to the root comment block: update existing comment when found', async () => { 489 | const ctx = new probot.Context(event, github, null); 490 | const ruleset = new Ruleset(ctx, owner, repo, pr_number, [ 491 | 'ciflow/default' 492 | ]); 493 | 494 | const scope = nock('https://api.github.com') 495 | .get(`/repos/${owner}/${repo}/pulls/${pr_number}`) 496 | .reply(200, { 497 | head: { 498 | sha: sha, 499 | repo: { 500 | name: repo, 501 | owner: { 502 | login: owner 503 | } 504 | } 505 | } 506 | }) 507 | .get( 508 | `/repos/${owner}/${repo}/contents/.github/generated-ciflow-ruleset.json?ref=${sha}` 509 | ) 510 | .reply(200, { 511 | content: Buffer.from( 512 | JSON.stringify({ 513 | version: 'v1', 514 | label_rules: { 515 | 'ciflow/default': ['sample_ci.yml'] 516 | } 517 | }) 518 | ).toString('base64') 519 | }) 520 | .get(`/repos/${owner}/${repo}/issues/${pr_number}/comments?per_page=10`) 521 | .reply(200, [ 522 | { 523 | id: comment_id, 524 | node_id: comment_node_id, 525 | body: 526 | '\nshould_be_removed\n\nshould_not_be_removed \n' 527 | } 528 | ]) 529 | .patch(`/repos/${owner}/${repo}/issues/comments/${comment_id}`, body => { 530 | expect(JSON.stringify(body)).toContain(''); 531 | expect(JSON.stringify(body)).toContain(':white_check_mark:'); 532 | expect(JSON.stringify(body)).toContain('should_not_be_removed'); 533 | expect(JSON.stringify(body)).not.toContain('should_be_removed'); 534 | return true; 535 | }) 536 | .reply(200); 537 | await ruleset.upsertRootComment(); 538 | 539 | if (!scope.isDone()) { 540 | console.error('pending mocks: %j', scope.pendingMocks()); 541 | } 542 | scope.done(); 543 | }); 544 | }); 545 | -------------------------------------------------------------------------------- /test/ciflow-parse.test.ts: -------------------------------------------------------------------------------- 1 | import {parseCIFlowIssue} from '../src/ciflow-bot'; 2 | 3 | describe('Parse CIFflow issue', () => { 4 | test('Empty', () => { 5 | expect(parseCIFlowIssue('')).toStrictEqual(new Map()); 6 | }); 7 | 8 | test('One line', () => { 9 | expect(parseCIFlowIssue('@malfet')).toStrictEqual( 10 | new Map([ 11 | [ 12 | 'malfet', 13 | { 14 | optOut: false, 15 | defaultLabels: ['ciflow/default'] 16 | } 17 | ] 18 | ]) 19 | ); 20 | }); 21 | 22 | test('Empty lines', () => { 23 | expect( 24 | parseCIFlowIssue(` 25 | 26 | @malfet 27 | 28 | `) 29 | ).toStrictEqual( 30 | new Map([ 31 | [ 32 | 'malfet', 33 | { 34 | optOut: false, 35 | defaultLabels: ['ciflow/default'] 36 | } 37 | ] 38 | ]) 39 | ); 40 | }); 41 | 42 | test('Two users', () => { 43 | expect( 44 | parseCIFlowIssue(` 45 | @malfet 46 | @octocat cats 47 | -@opt-out-user 48 | - @another-opt-out-user 49 | `) 50 | ).toStrictEqual( 51 | new Map([ 52 | [ 53 | 'malfet', 54 | { 55 | optOut: false, 56 | defaultLabels: ['ciflow/default'] 57 | } 58 | ], 59 | [ 60 | 'octocat', 61 | { 62 | optOut: false, 63 | defaultLabels: ['cats'] 64 | } 65 | ], 66 | [ 67 | 'opt-out-user', 68 | { 69 | optOut: true 70 | } 71 | ], 72 | [ 73 | 'another-opt-out-user', 74 | { 75 | optOut: true 76 | } 77 | ] 78 | ]) 79 | ); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/common.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | 3 | export function nockTracker( 4 | contents: string, 5 | ghaPath: string = 'ezyang/testing-ideal-computing-machine', 6 | configContent: string = 'tracking_issue: 6' 7 | ): void { 8 | // Setup mock for the "tracking issue" which specifies where 9 | // CC bot can get labels 10 | const configPayload = require('./fixtures/config.json'); 11 | configPayload['content'] = Buffer.from(configContent).toString('base64'); 12 | nock('https://api.github.com') 13 | .get('/repos/' + ghaPath + '/contents/.github/pytorch-probot.yml') 14 | .reply(200, configPayload); 15 | 16 | const payload = require('./fixtures/issue.json'); 17 | payload['body'] = contents; 18 | nock('https://api.github.com') 19 | .get('/repos/' + ghaPath + '/issues/6') 20 | .reply(200, payload); 21 | } 22 | -------------------------------------------------------------------------------- /test/fixtures/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pytorch-probot.yml", 3 | "path": ".github/pytorch-probot.yml", 4 | "sha": "0afb4efc85edd842bf642509e2e11a2d0d3e40bb", 5 | "size": 77, 6 | "url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/contents/.github/pytorch-probot.yml?ref=master", 7 | "html_url": "https://github.com/ezyang/testing-ideal-computing-machine/blob/master/.github/pytorch-probot.yml", 8 | "git_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/blobs/0afb4efc85edd842bf642509e2e11a2d0d3e40bb", 9 | "download_url": "https://raw.githubusercontent.com/ezyang/testing-ideal-computing-machine/master/.github/pytorch-probot.yml", 10 | "type": "file", 11 | "content": "dHJhY2tlcjoKICBvd25lcjogZXp5YW5nCiAgcmVwbzogdGVzdGluZy1pZGVh\nbC1jb21wdXRpbmctbWFjaGluZQogIG51bWJlcjogNgo=\n", 12 | "encoding": "base64", 13 | "_links": { 14 | "self": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/contents/.github/pytorch-probot.yml?ref=master", 15 | "git": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/blobs/0afb4efc85edd842bf642509e2e11a2d0d3e40bb", 16 | "html": "https://github.com/ezyang/testing-ideal-computing-machine/blob/master/.github/pytorch-probot.yml" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/fixtures/issue.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/6", 3 | "repository_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine", 4 | "labels_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/6/labels{/name}", 5 | "comments_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/6/comments", 6 | "events_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/6/events", 7 | "html_url": "https://github.com/ezyang/testing-ideal-computing-machine/issues/6", 8 | "id": 481215852, 9 | "node_id": "MDU6SXNzdWU0ODEyMTU4NTI=", 10 | "number": 6, 11 | "title": "CC tracking", 12 | "user": { 13 | "login": "ezyang", 14 | "id": 13564, 15 | "node_id": "MDQ6VXNlcjEzNTY0", 16 | "avatar_url": "https://avatars0.githubusercontent.com/u/13564?v=4", 17 | "gravatar_id": "", 18 | "url": "https://api.github.com/users/ezyang", 19 | "html_url": "https://github.com/ezyang", 20 | "followers_url": "https://api.github.com/users/ezyang/followers", 21 | "following_url": "https://api.github.com/users/ezyang/following{/other_user}", 22 | "gists_url": "https://api.github.com/users/ezyang/gists{/gist_id}", 23 | "starred_url": "https://api.github.com/users/ezyang/starred{/owner}{/repo}", 24 | "subscriptions_url": "https://api.github.com/users/ezyang/subscriptions", 25 | "organizations_url": "https://api.github.com/users/ezyang/orgs", 26 | "repos_url": "https://api.github.com/users/ezyang/repos", 27 | "events_url": "https://api.github.com/users/ezyang/events{/privacy}", 28 | "received_events_url": "https://api.github.com/users/ezyang/received_events", 29 | "type": "User", 30 | "site_admin": false 31 | }, 32 | "labels": [ 33 | 34 | ], 35 | "state": "open", 36 | "locked": false, 37 | "assignee": null, 38 | "assignees": [ 39 | 40 | ], 41 | "milestone": null, 42 | "comments": 0, 43 | "created_at": "2019-08-15T15:49:53Z", 44 | "updated_at": "2019-08-15T15:49:53Z", 45 | "closed_at": null, 46 | "author_association": "OWNER", 47 | "body": "", 48 | "closed_by": null 49 | } 50 | -------------------------------------------------------------------------------- /test/fixtures/issue_comment.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issue_comment", 3 | "id": "7835ed00-f180-11eb-9c94-b7c265ade873", 4 | "payload": { 5 | "action": "created", 6 | "issue": { 7 | "url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues/31", 8 | "repository_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground", 9 | "labels_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues/31/labels{/name}", 10 | "comments_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues/31/comments", 11 | "events_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues/31/events", 12 | "html_url": "https://github.com/zhouzhuojie/gha-ci-playground/pull/31", 13 | "id": 957080054, 14 | "node_id": "MDExOlB1bGxSZXF1ZXN0NzAwNTc5MDc4", 15 | "number": 31, 16 | "title": "Create README", 17 | "user": { 18 | "login": "zzj-bot", 19 | "id": 88213931, 20 | "node_id": "MDQ6VXNlcjg4MjEzOTMx", 21 | "avatar_url": "https://avatars.githubusercontent.com/u/88213931?v=4", 22 | "gravatar_id": "", 23 | "url": "https://api.github.com/users/zzj-bot", 24 | "html_url": "https://github.com/zzj-bot", 25 | "followers_url": "https://api.github.com/users/zzj-bot/followers", 26 | "following_url": "https://api.github.com/users/zzj-bot/following{/other_user}", 27 | "gists_url": "https://api.github.com/users/zzj-bot/gists{/gist_id}", 28 | "starred_url": "https://api.github.com/users/zzj-bot/starred{/owner}{/repo}", 29 | "subscriptions_url": "https://api.github.com/users/zzj-bot/subscriptions", 30 | "organizations_url": "https://api.github.com/users/zzj-bot/orgs", 31 | "repos_url": "https://api.github.com/users/zzj-bot/repos", 32 | "events_url": "https://api.github.com/users/zzj-bot/events{/privacy}", 33 | "received_events_url": "https://api.github.com/users/zzj-bot/received_events", 34 | "type": "User", 35 | "site_admin": false 36 | }, 37 | "labels": [], 38 | "state": "open", 39 | "locked": false, 40 | "assignee": null, 41 | "assignees": [], 42 | "milestone": null, 43 | "comments": 1, 44 | "created_at": "2021-07-30T21:50:32Z", 45 | "updated_at": "2021-07-30T21:52:43Z", 46 | "closed_at": null, 47 | "author_association": "NONE", 48 | "active_lock_reason": null, 49 | "pull_request": { 50 | "url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/pulls/31", 51 | "html_url": "https://github.com/zhouzhuojie/gha-ci-playground/pull/31", 52 | "diff_url": "https://github.com/zhouzhuojie/gha-ci-playground/pull/31.diff", 53 | "patch_url": "https://github.com/zhouzhuojie/gha-ci-playground/pull/31.patch" 54 | }, 55 | "body": "", 56 | "performed_via_github_app": null 57 | }, 58 | "comment": { 59 | "url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues/comments/890173751", 60 | "html_url": "https://github.com/zhouzhuojie/gha-ci-playground/pull/31#issuecomment-890173751", 61 | "issue_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues/31", 62 | "id": 890173751, 63 | "node_id": "IC_kwDOFhsA3M41Dvk3", 64 | "user": { 65 | "login": "zhouzhuojie", 66 | "id": 658840, 67 | "node_id": "MDQ6VXNlcjY1ODg0MA==", 68 | "avatar_url": "https://avatars.githubusercontent.com/u/658840?v=4", 69 | "gravatar_id": "", 70 | "url": "https://api.github.com/users/zhouzhuojie", 71 | "html_url": "https://github.com/zhouzhuojie", 72 | "followers_url": "https://api.github.com/users/zhouzhuojie/followers", 73 | "following_url": "https://api.github.com/users/zhouzhuojie/following{/other_user}", 74 | "gists_url": "https://api.github.com/users/zhouzhuojie/gists{/gist_id}", 75 | "starred_url": "https://api.github.com/users/zhouzhuojie/starred{/owner}{/repo}", 76 | "subscriptions_url": "https://api.github.com/users/zhouzhuojie/subscriptions", 77 | "organizations_url": "https://api.github.com/users/zhouzhuojie/orgs", 78 | "repos_url": "https://api.github.com/users/zhouzhuojie/repos", 79 | "events_url": "https://api.github.com/users/zhouzhuojie/events{/privacy}", 80 | "received_events_url": "https://api.github.com/users/zhouzhuojie/received_events", 81 | "type": "User", 82 | "site_admin": false 83 | }, 84 | "created_at": "2021-07-30T21:52:43Z", 85 | "updated_at": "2021-07-30T21:52:43Z", 86 | "author_association": "OWNER", 87 | "body": "test a new comment", 88 | "performed_via_github_app": null 89 | }, 90 | "repository": { 91 | "id": 370868444, 92 | "node_id": "MDEwOlJlcG9zaXRvcnkzNzA4Njg0NDQ=", 93 | "name": "gha-ci-playground", 94 | "full_name": "zhouzhuojie/gha-ci-playground", 95 | "private": false, 96 | "owner": { 97 | "login": "zhouzhuojie", 98 | "id": 658840, 99 | "node_id": "MDQ6VXNlcjY1ODg0MA==", 100 | "avatar_url": "https://avatars.githubusercontent.com/u/658840?v=4", 101 | "gravatar_id": "", 102 | "url": "https://api.github.com/users/zhouzhuojie", 103 | "html_url": "https://github.com/zhouzhuojie", 104 | "followers_url": "https://api.github.com/users/zhouzhuojie/followers", 105 | "following_url": "https://api.github.com/users/zhouzhuojie/following{/other_user}", 106 | "gists_url": "https://api.github.com/users/zhouzhuojie/gists{/gist_id}", 107 | "starred_url": "https://api.github.com/users/zhouzhuojie/starred{/owner}{/repo}", 108 | "subscriptions_url": "https://api.github.com/users/zhouzhuojie/subscriptions", 109 | "organizations_url": "https://api.github.com/users/zhouzhuojie/orgs", 110 | "repos_url": "https://api.github.com/users/zhouzhuojie/repos", 111 | "events_url": "https://api.github.com/users/zhouzhuojie/events{/privacy}", 112 | "received_events_url": "https://api.github.com/users/zhouzhuojie/received_events", 113 | "type": "User", 114 | "site_admin": false 115 | }, 116 | "html_url": "https://github.com/zhouzhuojie/gha-ci-playground", 117 | "description": "testing github action ci", 118 | "fork": false, 119 | "url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground", 120 | "forks_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/forks", 121 | "keys_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/keys{/key_id}", 122 | "collaborators_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/collaborators{/collaborator}", 123 | "teams_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/teams", 124 | "hooks_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/hooks", 125 | "issue_events_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues/events{/number}", 126 | "events_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/events", 127 | "assignees_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/assignees{/user}", 128 | "branches_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/branches{/branch}", 129 | "tags_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/tags", 130 | "blobs_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/git/blobs{/sha}", 131 | "git_tags_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/git/tags{/sha}", 132 | "git_refs_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/git/refs{/sha}", 133 | "trees_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/git/trees{/sha}", 134 | "statuses_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/statuses/{sha}", 135 | "languages_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/languages", 136 | "stargazers_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/stargazers", 137 | "contributors_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/contributors", 138 | "subscribers_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/subscribers", 139 | "subscription_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/subscription", 140 | "commits_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/commits{/sha}", 141 | "git_commits_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/git/commits{/sha}", 142 | "comments_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/comments{/number}", 143 | "issue_comment_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues/comments{/number}", 144 | "contents_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/contents/{+path}", 145 | "compare_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/compare/{base}...{head}", 146 | "merges_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/merges", 147 | "archive_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/{archive_format}{/ref}", 148 | "downloads_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/downloads", 149 | "issues_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/issues{/number}", 150 | "pulls_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/pulls{/number}", 151 | "milestones_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/milestones{/number}", 152 | "notifications_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/notifications{?since,all,participating}", 153 | "labels_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/labels{/name}", 154 | "releases_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/releases{/id}", 155 | "deployments_url": "https://api.github.com/repos/zhouzhuojie/gha-ci-playground/deployments", 156 | "created_at": "2021-05-26T01:06:09Z", 157 | "updated_at": "2021-07-19T23:21:14Z", 158 | "pushed_at": "2021-07-30T21:50:32Z", 159 | "git_url": "git://github.com/zhouzhuojie/gha-ci-playground.git", 160 | "ssh_url": "git@github.com:zhouzhuojie/gha-ci-playground.git", 161 | "clone_url": "https://github.com/zhouzhuojie/gha-ci-playground.git", 162 | "svn_url": "https://github.com/zhouzhuojie/gha-ci-playground", 163 | "homepage": null, 164 | "size": 96, 165 | "stargazers_count": 0, 166 | "watchers_count": 0, 167 | "language": null, 168 | "has_issues": true, 169 | "has_projects": true, 170 | "has_downloads": true, 171 | "has_wiki": true, 172 | "has_pages": false, 173 | "forks_count": 2, 174 | "mirror_url": null, 175 | "archived": false, 176 | "disabled": false, 177 | "open_issues_count": 11, 178 | "license": null, 179 | "forks": 2, 180 | "open_issues": 11, 181 | "watchers": 0, 182 | "default_branch": "main" 183 | }, 184 | "sender": { 185 | "login": "zhouzhuojie", 186 | "id": 658840, 187 | "node_id": "MDQ6VXNlcjY1ODg0MA==", 188 | "avatar_url": "https://avatars.githubusercontent.com/u/658840?v=4", 189 | "gravatar_id": "", 190 | "url": "https://api.github.com/users/zhouzhuojie", 191 | "html_url": "https://github.com/zhouzhuojie", 192 | "followers_url": "https://api.github.com/users/zhouzhuojie/followers", 193 | "following_url": "https://api.github.com/users/zhouzhuojie/following{/other_user}", 194 | "gists_url": "https://api.github.com/users/zhouzhuojie/gists{/gist_id}", 195 | "starred_url": "https://api.github.com/users/zhouzhuojie/starred{/owner}{/repo}", 196 | "subscriptions_url": "https://api.github.com/users/zhouzhuojie/subscriptions", 197 | "organizations_url": "https://api.github.com/users/zhouzhuojie/orgs", 198 | "repos_url": "https://api.github.com/users/zhouzhuojie/repos", 199 | "events_url": "https://api.github.com/users/zhouzhuojie/events{/privacy}", 200 | "received_events_url": "https://api.github.com/users/zhouzhuojie/received_events", 201 | "type": "User", 202 | "site_admin": false 203 | }, 204 | "installation": { 205 | "id": 18596033, 206 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uMTg1OTYwMzM=" 207 | } 208 | }, 209 | "github": { 210 | "log": {}, 211 | "actions": {}, 212 | "activity": {}, 213 | "apps": {}, 214 | "checks": {}, 215 | "codesOfConduct": {}, 216 | "emojis": {}, 217 | "gists": {}, 218 | "git": {}, 219 | "gitignore": {}, 220 | "interactions": {}, 221 | "issues": {}, 222 | "licenses": {}, 223 | "markdown": {}, 224 | "meta": {}, 225 | "migrations": {}, 226 | "oauthAuthorizations": {}, 227 | "orgs": {}, 228 | "projects": {}, 229 | "pulls": {}, 230 | "rateLimit": {}, 231 | "reactions": {}, 232 | "repos": {}, 233 | "search": {}, 234 | "teams": {}, 235 | "users": {}, 236 | "retry": {} 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /test/fixtures/issues.labeled.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "labeled", 3 | "issue": { 4 | "url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/5", 5 | "repository_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine", 6 | "labels_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/5/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/5/comments", 8 | "events_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/5/events", 9 | "html_url": "https://github.com/ezyang/testing-ideal-computing-machine/issues/5", 10 | "id": 480359954, 11 | "node_id": "MDU6SXNzdWU0ODAzNTk5NTQ=", 12 | "number": 5, 13 | "title": "asdf", 14 | "user": { 15 | "login": "ezyang", 16 | "id": 13564, 17 | "node_id": "MDQ6VXNlcjEzNTY0", 18 | "avatar_url": "https://avatars0.githubusercontent.com/u/13564?v=4", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/ezyang", 21 | "html_url": "https://github.com/ezyang", 22 | "followers_url": "https://api.github.com/users/ezyang/followers", 23 | "following_url": "https://api.github.com/users/ezyang/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/ezyang/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/ezyang/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/ezyang/subscriptions", 27 | "organizations_url": "https://api.github.com/users/ezyang/orgs", 28 | "repos_url": "https://api.github.com/users/ezyang/repos", 29 | "events_url": "https://api.github.com/users/ezyang/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/ezyang/received_events", 31 | "type": "User", 32 | "site_admin": false 33 | }, 34 | "labels": [ 35 | { 36 | "id": 1500928154, 37 | "node_id": "MDU6TGFiZWwxNTAwOTI4MTU0", 38 | "url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/labels/testlabel", 39 | "name": "testlabel", 40 | "color": "f98eef", 41 | "default": false 42 | } 43 | ], 44 | "state": "open", 45 | "locked": false, 46 | "assignee": null, 47 | "assignees": [ 48 | 49 | ], 50 | "milestone": null, 51 | "comments": 1, 52 | "created_at": "2019-08-13T20:44:49Z", 53 | "updated_at": "2019-08-14T16:58:26Z", 54 | "closed_at": null, 55 | "author_association": "OWNER", 56 | "body": "Arf arf\n\ncc @moo @mar\nxxxx" 57 | }, 58 | "label": { 59 | "id": 1500928154, 60 | "node_id": "MDU6TGFiZWwxNTAwOTI4MTU0", 61 | "url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/labels/testlabel", 62 | "name": "testlabel", 63 | "color": "f98eef", 64 | "default": false 65 | }, 66 | "repository": { 67 | "id": 156317224, 68 | "node_id": "MDEwOlJlcG9zaXRvcnkxNTYzMTcyMjQ=", 69 | "name": "testing-ideal-computing-machine", 70 | "full_name": "ezyang/testing-ideal-computing-machine", 71 | "private": false, 72 | "owner": { 73 | "login": "ezyang", 74 | "id": 13564, 75 | "node_id": "MDQ6VXNlcjEzNTY0", 76 | "avatar_url": "https://avatars0.githubusercontent.com/u/13564?v=4", 77 | "gravatar_id": "", 78 | "url": "https://api.github.com/users/ezyang", 79 | "html_url": "https://github.com/ezyang", 80 | "followers_url": "https://api.github.com/users/ezyang/followers", 81 | "following_url": "https://api.github.com/users/ezyang/following{/other_user}", 82 | "gists_url": "https://api.github.com/users/ezyang/gists{/gist_id}", 83 | "starred_url": "https://api.github.com/users/ezyang/starred{/owner}{/repo}", 84 | "subscriptions_url": "https://api.github.com/users/ezyang/subscriptions", 85 | "organizations_url": "https://api.github.com/users/ezyang/orgs", 86 | "repos_url": "https://api.github.com/users/ezyang/repos", 87 | "events_url": "https://api.github.com/users/ezyang/events{/privacy}", 88 | "received_events_url": "https://api.github.com/users/ezyang/received_events", 89 | "type": "User", 90 | "site_admin": false 91 | }, 92 | "html_url": "https://github.com/ezyang/testing-ideal-computing-machine", 93 | "description": "Testing repo", 94 | "fork": false, 95 | "url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine", 96 | "forks_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/forks", 97 | "keys_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/keys{/key_id}", 98 | "collaborators_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/collaborators{/collaborator}", 99 | "teams_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/teams", 100 | "hooks_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/hooks", 101 | "issue_events_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/events{/number}", 102 | "events_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/events", 103 | "assignees_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/assignees{/user}", 104 | "branches_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/branches{/branch}", 105 | "tags_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/tags", 106 | "blobs_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/blobs{/sha}", 107 | "git_tags_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/tags{/sha}", 108 | "git_refs_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/refs{/sha}", 109 | "trees_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/trees{/sha}", 110 | "statuses_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/statuses/{sha}", 111 | "languages_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/languages", 112 | "stargazers_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/stargazers", 113 | "contributors_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/contributors", 114 | "subscribers_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/subscribers", 115 | "subscription_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/subscription", 116 | "commits_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/commits{/sha}", 117 | "git_commits_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/commits{/sha}", 118 | "comments_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/comments{/number}", 119 | "issue_comment_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/comments{/number}", 120 | "contents_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/contents/{+path}", 121 | "compare_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/compare/{base}...{head}", 122 | "merges_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/merges", 123 | "archive_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/{archive_format}{/ref}", 124 | "downloads_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/downloads", 125 | "issues_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues{/number}", 126 | "pulls_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/pulls{/number}", 127 | "milestones_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/milestones{/number}", 128 | "notifications_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/notifications{?since,all,participating}", 129 | "labels_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/labels{/name}", 130 | "releases_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/releases{/id}", 131 | "deployments_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/deployments", 132 | "created_at": "2018-11-06T02:57:39Z", 133 | "updated_at": "2018-11-06T02:57:42Z", 134 | "pushed_at": "2018-11-06T03:58:56Z", 135 | "git_url": "git://github.com/ezyang/testing-ideal-computing-machine.git", 136 | "ssh_url": "git@github.com:ezyang/testing-ideal-computing-machine.git", 137 | "clone_url": "https://github.com/ezyang/testing-ideal-computing-machine.git", 138 | "svn_url": "https://github.com/ezyang/testing-ideal-computing-machine", 139 | "homepage": null, 140 | "size": 3, 141 | "stargazers_count": 0, 142 | "watchers_count": 0, 143 | "language": null, 144 | "has_issues": true, 145 | "has_projects": true, 146 | "has_downloads": true, 147 | "has_wiki": true, 148 | "has_pages": false, 149 | "forks_count": 0, 150 | "mirror_url": null, 151 | "archived": false, 152 | "disabled": false, 153 | "open_issues_count": 5, 154 | "license": null, 155 | "forks": 0, 156 | "open_issues": 5, 157 | "watchers": 0, 158 | "default_branch": "master" 159 | }, 160 | "sender": { 161 | "login": "ezyang", 162 | "id": 13564, 163 | "node_id": "MDQ6VXNlcjEzNTY0", 164 | "avatar_url": "https://avatars0.githubusercontent.com/u/13564?v=4", 165 | "gravatar_id": "", 166 | "url": "https://api.github.com/users/ezyang", 167 | "html_url": "https://github.com/ezyang", 168 | "followers_url": "https://api.github.com/users/ezyang/followers", 169 | "following_url": "https://api.github.com/users/ezyang/following{/other_user}", 170 | "gists_url": "https://api.github.com/users/ezyang/gists{/gist_id}", 171 | "starred_url": "https://api.github.com/users/ezyang/starred{/owner}{/repo}", 172 | "subscriptions_url": "https://api.github.com/users/ezyang/subscriptions", 173 | "organizations_url": "https://api.github.com/users/ezyang/orgs", 174 | "repos_url": "https://api.github.com/users/ezyang/repos", 175 | "events_url": "https://api.github.com/users/ezyang/events{/privacy}", 176 | "received_events_url": "https://api.github.com/users/ezyang/received_events", 177 | "type": "User", 178 | "site_admin": false 179 | }, 180 | "installation": { 181 | "id": 1492531, 182 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uMTQ5MjUzMQ==" 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /test/fixtures/issues.opened.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/5", 5 | "repository_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine", 6 | "labels_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/5/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/5/comments", 8 | "events_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/5/events", 9 | "html_url": "https://github.com/ezyang/testing-ideal-computing-machine/issues/5", 10 | "id": 480359954, 11 | "node_id": "MDU6SXNzdWU0ODAzNTk5NTQ=", 12 | "number": 5, 13 | "title": "ROCm", 14 | "user": { 15 | "login": "ezyang", 16 | "id": 13564, 17 | "node_id": "MDQ6VXNlcjEzNTY0", 18 | "avatar_url": "https://avatars0.githubusercontent.com/u/13564?v=4", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/ezyang", 21 | "html_url": "https://github.com/ezyang", 22 | "followers_url": "https://api.github.com/users/ezyang/followers", 23 | "following_url": "https://api.github.com/users/ezyang/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/ezyang/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/ezyang/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/ezyang/subscriptions", 27 | "organizations_url": "https://api.github.com/users/ezyang/orgs", 28 | "repos_url": "https://api.github.com/users/ezyang/repos", 29 | "events_url": "https://api.github.com/users/ezyang/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/ezyang/received_events", 31 | "type": "User", 32 | "site_admin": false 33 | }, 34 | "labels": [ 35 | ], 36 | "state": "open", 37 | "locked": false, 38 | "assignee": null, 39 | "assignees": [ 40 | 41 | ], 42 | "milestone": null, 43 | "comments": 1, 44 | "created_at": "2019-08-13T20:44:49Z", 45 | "updated_at": "2019-08-14T16:58:26Z", 46 | "closed_at": null, 47 | "author_association": "OWNER", 48 | "body": "Arf arf\n\ncc @moo @mar\nxxxx" 49 | }, 50 | "repository": { 51 | "id": 156317224, 52 | "node_id": "MDEwOlJlcG9zaXRvcnkxNTYzMTcyMjQ=", 53 | "name": "testing-ideal-computing-machine", 54 | "full_name": "ezyang/testing-ideal-computing-machine", 55 | "private": false, 56 | "owner": { 57 | "login": "ezyang", 58 | "id": 13564, 59 | "node_id": "MDQ6VXNlcjEzNTY0", 60 | "avatar_url": "https://avatars0.githubusercontent.com/u/13564?v=4", 61 | "gravatar_id": "", 62 | "url": "https://api.github.com/users/ezyang", 63 | "html_url": "https://github.com/ezyang", 64 | "followers_url": "https://api.github.com/users/ezyang/followers", 65 | "following_url": "https://api.github.com/users/ezyang/following{/other_user}", 66 | "gists_url": "https://api.github.com/users/ezyang/gists{/gist_id}", 67 | "starred_url": "https://api.github.com/users/ezyang/starred{/owner}{/repo}", 68 | "subscriptions_url": "https://api.github.com/users/ezyang/subscriptions", 69 | "organizations_url": "https://api.github.com/users/ezyang/orgs", 70 | "repos_url": "https://api.github.com/users/ezyang/repos", 71 | "events_url": "https://api.github.com/users/ezyang/events{/privacy}", 72 | "received_events_url": "https://api.github.com/users/ezyang/received_events", 73 | "type": "User", 74 | "site_admin": false 75 | }, 76 | "html_url": "https://github.com/ezyang/testing-ideal-computing-machine", 77 | "description": "Testing repo", 78 | "fork": false, 79 | "url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine", 80 | "forks_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/forks", 81 | "keys_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/keys{/key_id}", 82 | "collaborators_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/collaborators{/collaborator}", 83 | "teams_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/teams", 84 | "hooks_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/hooks", 85 | "issue_events_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/events{/number}", 86 | "events_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/events", 87 | "assignees_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/assignees{/user}", 88 | "branches_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/branches{/branch}", 89 | "tags_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/tags", 90 | "blobs_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/blobs{/sha}", 91 | "git_tags_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/tags{/sha}", 92 | "git_refs_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/refs{/sha}", 93 | "trees_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/trees{/sha}", 94 | "statuses_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/statuses/{sha}", 95 | "languages_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/languages", 96 | "stargazers_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/stargazers", 97 | "contributors_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/contributors", 98 | "subscribers_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/subscribers", 99 | "subscription_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/subscription", 100 | "commits_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/commits{/sha}", 101 | "git_commits_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/git/commits{/sha}", 102 | "comments_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/comments{/number}", 103 | "issue_comment_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues/comments{/number}", 104 | "contents_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/contents/{+path}", 105 | "compare_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/compare/{base}...{head}", 106 | "merges_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/merges", 107 | "archive_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/{archive_format}{/ref}", 108 | "downloads_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/downloads", 109 | "issues_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/issues{/number}", 110 | "pulls_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/pulls{/number}", 111 | "milestones_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/milestones{/number}", 112 | "notifications_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/notifications{?since,all,participating}", 113 | "labels_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/labels{/name}", 114 | "releases_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/releases{/id}", 115 | "deployments_url": "https://api.github.com/repos/ezyang/testing-ideal-computing-machine/deployments", 116 | "created_at": "2018-11-06T02:57:39Z", 117 | "updated_at": "2018-11-06T02:57:42Z", 118 | "pushed_at": "2018-11-06T03:58:56Z", 119 | "git_url": "git://github.com/ezyang/testing-ideal-computing-machine.git", 120 | "ssh_url": "git@github.com:ezyang/testing-ideal-computing-machine.git", 121 | "clone_url": "https://github.com/ezyang/testing-ideal-computing-machine.git", 122 | "svn_url": "https://github.com/ezyang/testing-ideal-computing-machine", 123 | "homepage": null, 124 | "size": 3, 125 | "stargazers_count": 0, 126 | "watchers_count": 0, 127 | "language": null, 128 | "has_issues": true, 129 | "has_projects": true, 130 | "has_downloads": true, 131 | "has_wiki": true, 132 | "has_pages": false, 133 | "forks_count": 0, 134 | "mirror_url": null, 135 | "archived": false, 136 | "disabled": false, 137 | "open_issues_count": 5, 138 | "license": null, 139 | "forks": 0, 140 | "open_issues": 5, 141 | "watchers": 0, 142 | "default_branch": "master" 143 | }, 144 | "sender": { 145 | "login": "ezyang", 146 | "id": 13564, 147 | "node_id": "MDQ6VXNlcjEzNTY0", 148 | "avatar_url": "https://avatars0.githubusercontent.com/u/13564?v=4", 149 | "gravatar_id": "", 150 | "url": "https://api.github.com/users/ezyang", 151 | "html_url": "https://github.com/ezyang", 152 | "followers_url": "https://api.github.com/users/ezyang/followers", 153 | "following_url": "https://api.github.com/users/ezyang/following{/other_user}", 154 | "gists_url": "https://api.github.com/users/ezyang/gists{/gist_id}", 155 | "starred_url": "https://api.github.com/users/ezyang/starred{/owner}{/repo}", 156 | "subscriptions_url": "https://api.github.com/users/ezyang/subscriptions", 157 | "organizations_url": "https://api.github.com/users/ezyang/orgs", 158 | "repos_url": "https://api.github.com/users/ezyang/repos", 159 | "events_url": "https://api.github.com/users/ezyang/events{/privacy}", 160 | "received_events_url": "https://api.github.com/users/ezyang/received_events", 161 | "type": "User", 162 | "site_admin": false 163 | }, 164 | "installation": { 165 | "id": 1492531, 166 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uMTQ5MjUzMQ==" 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /test/fixtures/pull_request.labeled.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "labeled", 3 | "number": 20, 4 | "pull_request": { 5 | "url": "https://api.github.com/repos/seemethere/test-repo/pulls/20", 6 | "id": 394993632, 7 | "node_id": "MDExOlB1bGxSZXF1ZXN0Mzk0OTkzNjMy", 8 | "html_url": "https://github.com/seemethere/test-repo/pull/20", 9 | "diff_url": "https://github.com/seemethere/test-repo/pull/20.diff", 10 | "patch_url": "https://github.com/seemethere/test-repo/pull/20.patch", 11 | "issue_url": "https://api.github.com/repos/seemethere/test-repo/issues/20", 12 | "number": 20, 13 | "state": "open", 14 | "locked": false, 15 | "title": "WIP add docker build steps", 16 | "user": { 17 | "login": "seemethere", 18 | "id": 1700823, 19 | "node_id": "MDQ6VXNlcjE3MDA4MjM=", 20 | "avatar_url": "https://avatars2.githubusercontent.com/u/1700823?v=4", 21 | "gravatar_id": "", 22 | "url": "https://api.github.com/users/seemethere", 23 | "html_url": "https://github.com/seemethere", 24 | "followers_url": "https://api.github.com/users/seemethere/followers", 25 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 26 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 27 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 28 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 29 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 30 | "repos_url": "https://api.github.com/users/seemethere/repos", 31 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 32 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 33 | "type": "User", 34 | "site_admin": false 35 | }, 36 | "body": "Signed-off-by: Eli Uriegas ", 37 | "created_at": "2020-03-27T22:22:55Z", 38 | "updated_at": "2020-04-29T19:39:22Z", 39 | "closed_at": null, 40 | "merged_at": null, 41 | "merge_commit_sha": "2c85e5ba77bdffb2c99d515ed0e622bfae296ef0", 42 | "assignee": null, 43 | "assignees": [ 44 | 45 | ], 46 | "requested_reviewers": [ 47 | 48 | ], 49 | "requested_teams": [ 50 | 51 | ], 52 | "labels": [ 53 | { 54 | "id": 666130978, 55 | "node_id": "MDU6TGFiZWw2NjYxMzA5Nzg=", 56 | "url": "https://api.github.com/repos/seemethere/test-repo/labels/bug", 57 | "name": "bug", 58 | "color": "ee0701", 59 | "default": true, 60 | "description": null 61 | }, 62 | { 63 | "id": 2025194437, 64 | "node_id": "MDU6TGFiZWwyMDI1MTk0NDM3", 65 | "url": "https://api.github.com/repos/seemethere/test-repo/labels/ci/binary", 66 | "name": "ci/binary", 67 | "color": "c5def5", 68 | "default": false, 69 | "description": "" 70 | }, 71 | { 72 | "id": 2025194794, 73 | "node_id": "MDU6TGFiZWwyMDI1MTk0Nzk0", 74 | "url": "https://api.github.com/repos/seemethere/test-repo/labels/ci/bleh", 75 | "name": "ci/bleh", 76 | "color": "550782", 77 | "default": false, 78 | "description": "" 79 | }, 80 | { 81 | "id": 2025194687, 82 | "node_id": "MDU6TGFiZWwyMDI1MTk0Njg3", 83 | "url": "https://api.github.com/repos/seemethere/test-repo/labels/ci/windows", 84 | "name": "ci/windows", 85 | "color": "fcd4ab", 86 | "default": false, 87 | "description": "" 88 | } 89 | ], 90 | "milestone": null, 91 | "draft": false, 92 | "commits_url": "https://api.github.com/repos/seemethere/test-repo/pulls/20/commits", 93 | "review_comments_url": "https://api.github.com/repos/seemethere/test-repo/pulls/20/comments", 94 | "review_comment_url": "https://api.github.com/repos/seemethere/test-repo/pulls/comments{/number}", 95 | "comments_url": "https://api.github.com/repos/seemethere/test-repo/issues/20/comments", 96 | "statuses_url": "https://api.github.com/repos/seemethere/test-repo/statuses/6163ecefc4cbb06c20208b51c617b1e2bfeb4109", 97 | "head": { 98 | "label": "seemethere:test_docker_build", 99 | "ref": "test_docker_build", 100 | "sha": "6163ecefc4cbb06c20208b51c617b1e2bfeb4109", 101 | "user": { 102 | "login": "seemethere", 103 | "id": 1700823, 104 | "node_id": "MDQ6VXNlcjE3MDA4MjM=", 105 | "avatar_url": "https://avatars2.githubusercontent.com/u/1700823?v=4", 106 | "gravatar_id": "", 107 | "url": "https://api.github.com/users/seemethere", 108 | "html_url": "https://github.com/seemethere", 109 | "followers_url": "https://api.github.com/users/seemethere/followers", 110 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 111 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 112 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 113 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 114 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 115 | "repos_url": "https://api.github.com/users/seemethere/repos", 116 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 117 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 118 | "type": "User", 119 | "site_admin": false 120 | }, 121 | "repo": { 122 | "id": 99942684, 123 | "node_id": "MDEwOlJlcG9zaXRvcnk5OTk0MjY4NA==", 124 | "name": "test-repo", 125 | "full_name": "seemethere/test-repo", 126 | "private": false, 127 | "owner": { 128 | "login": "seemethere", 129 | "id": 1700823, 130 | "node_id": "MDQ6VXNlcjE3MDA4MjM=", 131 | "avatar_url": "https://avatars2.githubusercontent.com/u/1700823?v=4", 132 | "gravatar_id": "", 133 | "url": "https://api.github.com/users/seemethere", 134 | "html_url": "https://github.com/seemethere", 135 | "followers_url": "https://api.github.com/users/seemethere/followers", 136 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 137 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 138 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 139 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 140 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 141 | "repos_url": "https://api.github.com/users/seemethere/repos", 142 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 143 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 144 | "type": "User", 145 | "site_admin": false 146 | }, 147 | "html_url": "https://github.com/seemethere/test-repo", 148 | "description": "this is a playground for github automation", 149 | "fork": false, 150 | "url": "https://api.github.com/repos/seemethere/test-repo", 151 | "forks_url": "https://api.github.com/repos/seemethere/test-repo/forks", 152 | "keys_url": "https://api.github.com/repos/seemethere/test-repo/keys{/key_id}", 153 | "collaborators_url": "https://api.github.com/repos/seemethere/test-repo/collaborators{/collaborator}", 154 | "teams_url": "https://api.github.com/repos/seemethere/test-repo/teams", 155 | "hooks_url": "https://api.github.com/repos/seemethere/test-repo/hooks", 156 | "issue_events_url": "https://api.github.com/repos/seemethere/test-repo/issues/events{/number}", 157 | "events_url": "https://api.github.com/repos/seemethere/test-repo/events", 158 | "assignees_url": "https://api.github.com/repos/seemethere/test-repo/assignees{/user}", 159 | "branches_url": "https://api.github.com/repos/seemethere/test-repo/branches{/branch}", 160 | "tags_url": "https://api.github.com/repos/seemethere/test-repo/tags", 161 | "blobs_url": "https://api.github.com/repos/seemethere/test-repo/git/blobs{/sha}", 162 | "git_tags_url": "https://api.github.com/repos/seemethere/test-repo/git/tags{/sha}", 163 | "git_refs_url": "https://api.github.com/repos/seemethere/test-repo/git/refs{/sha}", 164 | "trees_url": "https://api.github.com/repos/seemethere/test-repo/git/trees{/sha}", 165 | "statuses_url": "https://api.github.com/repos/seemethere/test-repo/statuses/{sha}", 166 | "languages_url": "https://api.github.com/repos/seemethere/test-repo/languages", 167 | "stargazers_url": "https://api.github.com/repos/seemethere/test-repo/stargazers", 168 | "contributors_url": "https://api.github.com/repos/seemethere/test-repo/contributors", 169 | "subscribers_url": "https://api.github.com/repos/seemethere/test-repo/subscribers", 170 | "subscription_url": "https://api.github.com/repos/seemethere/test-repo/subscription", 171 | "commits_url": "https://api.github.com/repos/seemethere/test-repo/commits{/sha}", 172 | "git_commits_url": "https://api.github.com/repos/seemethere/test-repo/git/commits{/sha}", 173 | "comments_url": "https://api.github.com/repos/seemethere/test-repo/comments{/number}", 174 | "issue_comment_url": "https://api.github.com/repos/seemethere/test-repo/issues/comments{/number}", 175 | "contents_url": "https://api.github.com/repos/seemethere/test-repo/contents/{+path}", 176 | "compare_url": "https://api.github.com/repos/seemethere/test-repo/compare/{base}...{head}", 177 | "merges_url": "https://api.github.com/repos/seemethere/test-repo/merges", 178 | "archive_url": "https://api.github.com/repos/seemethere/test-repo/{archive_format}{/ref}", 179 | "downloads_url": "https://api.github.com/repos/seemethere/test-repo/downloads", 180 | "issues_url": "https://api.github.com/repos/seemethere/test-repo/issues{/number}", 181 | "pulls_url": "https://api.github.com/repos/seemethere/test-repo/pulls{/number}", 182 | "milestones_url": "https://api.github.com/repos/seemethere/test-repo/milestones{/number}", 183 | "notifications_url": "https://api.github.com/repos/seemethere/test-repo/notifications{?since,all,participating}", 184 | "labels_url": "https://api.github.com/repos/seemethere/test-repo/labels{/name}", 185 | "releases_url": "https://api.github.com/repos/seemethere/test-repo/releases{/id}", 186 | "deployments_url": "https://api.github.com/repos/seemethere/test-repo/deployments", 187 | "created_at": "2017-08-10T16:18:38Z", 188 | "updated_at": "2020-03-02T21:39:16Z", 189 | "pushed_at": "2020-04-10T17:26:45Z", 190 | "git_url": "git://github.com/seemethere/test-repo.git", 191 | "ssh_url": "git@github.com:seemethere/test-repo.git", 192 | "clone_url": "https://github.com/seemethere/test-repo.git", 193 | "svn_url": "https://github.com/seemethere/test-repo", 194 | "homepage": "", 195 | "size": 19, 196 | "stargazers_count": 0, 197 | "watchers_count": 0, 198 | "language": null, 199 | "has_issues": true, 200 | "has_projects": true, 201 | "has_downloads": true, 202 | "has_wiki": true, 203 | "has_pages": false, 204 | "forks_count": 3, 205 | "mirror_url": null, 206 | "archived": false, 207 | "disabled": false, 208 | "open_issues_count": 11, 209 | "license": null, 210 | "forks": 3, 211 | "open_issues": 11, 212 | "watchers": 0, 213 | "default_branch": "master" 214 | } 215 | }, 216 | "base": { 217 | "label": "seemethere:master", 218 | "ref": "master", 219 | "sha": "4e02847c6197b173253716b5b00b0b2436033f04", 220 | "user": { 221 | "login": "seemethere", 222 | "id": 1700823, 223 | "node_id": "MDQ6VXNlcjE3MDA4MjM=", 224 | "avatar_url": "https://avatars2.githubusercontent.com/u/1700823?v=4", 225 | "gravatar_id": "", 226 | "url": "https://api.github.com/users/seemethere", 227 | "html_url": "https://github.com/seemethere", 228 | "followers_url": "https://api.github.com/users/seemethere/followers", 229 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 230 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 231 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 232 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 233 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 234 | "repos_url": "https://api.github.com/users/seemethere/repos", 235 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 236 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 237 | "type": "User", 238 | "site_admin": false 239 | }, 240 | "repo": { 241 | "id": 99942684, 242 | "node_id": "MDEwOlJlcG9zaXRvcnk5OTk0MjY4NA==", 243 | "name": "test-repo", 244 | "full_name": "seemethere/test-repo", 245 | "private": false, 246 | "owner": { 247 | "login": "seemethere", 248 | "id": 1700823, 249 | "node_id": "MDQ6VXNlcjE3MDA4MjM=", 250 | "avatar_url": "https://avatars2.githubusercontent.com/u/1700823?v=4", 251 | "gravatar_id": "", 252 | "url": "https://api.github.com/users/seemethere", 253 | "html_url": "https://github.com/seemethere", 254 | "followers_url": "https://api.github.com/users/seemethere/followers", 255 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 256 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 257 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 258 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 259 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 260 | "repos_url": "https://api.github.com/users/seemethere/repos", 261 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 262 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 263 | "type": "User", 264 | "site_admin": false 265 | }, 266 | "html_url": "https://github.com/seemethere/test-repo", 267 | "description": "this is a playground for github automation", 268 | "fork": false, 269 | "url": "https://api.github.com/repos/seemethere/test-repo", 270 | "forks_url": "https://api.github.com/repos/seemethere/test-repo/forks", 271 | "keys_url": "https://api.github.com/repos/seemethere/test-repo/keys{/key_id}", 272 | "collaborators_url": "https://api.github.com/repos/seemethere/test-repo/collaborators{/collaborator}", 273 | "teams_url": "https://api.github.com/repos/seemethere/test-repo/teams", 274 | "hooks_url": "https://api.github.com/repos/seemethere/test-repo/hooks", 275 | "issue_events_url": "https://api.github.com/repos/seemethere/test-repo/issues/events{/number}", 276 | "events_url": "https://api.github.com/repos/seemethere/test-repo/events", 277 | "assignees_url": "https://api.github.com/repos/seemethere/test-repo/assignees{/user}", 278 | "branches_url": "https://api.github.com/repos/seemethere/test-repo/branches{/branch}", 279 | "tags_url": "https://api.github.com/repos/seemethere/test-repo/tags", 280 | "blobs_url": "https://api.github.com/repos/seemethere/test-repo/git/blobs{/sha}", 281 | "git_tags_url": "https://api.github.com/repos/seemethere/test-repo/git/tags{/sha}", 282 | "git_refs_url": "https://api.github.com/repos/seemethere/test-repo/git/refs{/sha}", 283 | "trees_url": "https://api.github.com/repos/seemethere/test-repo/git/trees{/sha}", 284 | "statuses_url": "https://api.github.com/repos/seemethere/test-repo/statuses/{sha}", 285 | "languages_url": "https://api.github.com/repos/seemethere/test-repo/languages", 286 | "stargazers_url": "https://api.github.com/repos/seemethere/test-repo/stargazers", 287 | "contributors_url": "https://api.github.com/repos/seemethere/test-repo/contributors", 288 | "subscribers_url": "https://api.github.com/repos/seemethere/test-repo/subscribers", 289 | "subscription_url": "https://api.github.com/repos/seemethere/test-repo/subscription", 290 | "commits_url": "https://api.github.com/repos/seemethere/test-repo/commits{/sha}", 291 | "git_commits_url": "https://api.github.com/repos/seemethere/test-repo/git/commits{/sha}", 292 | "comments_url": "https://api.github.com/repos/seemethere/test-repo/comments{/number}", 293 | "issue_comment_url": "https://api.github.com/repos/seemethere/test-repo/issues/comments{/number}", 294 | "contents_url": "https://api.github.com/repos/seemethere/test-repo/contents/{+path}", 295 | "compare_url": "https://api.github.com/repos/seemethere/test-repo/compare/{base}...{head}", 296 | "merges_url": "https://api.github.com/repos/seemethere/test-repo/merges", 297 | "archive_url": "https://api.github.com/repos/seemethere/test-repo/{archive_format}{/ref}", 298 | "downloads_url": "https://api.github.com/repos/seemethere/test-repo/downloads", 299 | "issues_url": "https://api.github.com/repos/seemethere/test-repo/issues{/number}", 300 | "pulls_url": "https://api.github.com/repos/seemethere/test-repo/pulls{/number}", 301 | "milestones_url": "https://api.github.com/repos/seemethere/test-repo/milestones{/number}", 302 | "notifications_url": "https://api.github.com/repos/seemethere/test-repo/notifications{?since,all,participating}", 303 | "labels_url": "https://api.github.com/repos/seemethere/test-repo/labels{/name}", 304 | "releases_url": "https://api.github.com/repos/seemethere/test-repo/releases{/id}", 305 | "deployments_url": "https://api.github.com/repos/seemethere/test-repo/deployments", 306 | "created_at": "2017-08-10T16:18:38Z", 307 | "updated_at": "2020-03-02T21:39:16Z", 308 | "pushed_at": "2020-04-10T17:26:45Z", 309 | "git_url": "git://github.com/seemethere/test-repo.git", 310 | "ssh_url": "git@github.com:seemethere/test-repo.git", 311 | "clone_url": "https://github.com/seemethere/test-repo.git", 312 | "svn_url": "https://github.com/seemethere/test-repo", 313 | "homepage": "", 314 | "size": 19, 315 | "stargazers_count": 0, 316 | "watchers_count": 0, 317 | "language": null, 318 | "has_issues": true, 319 | "has_projects": true, 320 | "has_downloads": true, 321 | "has_wiki": true, 322 | "has_pages": false, 323 | "forks_count": 3, 324 | "mirror_url": null, 325 | "archived": false, 326 | "disabled": false, 327 | "open_issues_count": 11, 328 | "license": null, 329 | "forks": 3, 330 | "open_issues": 11, 331 | "watchers": 0, 332 | "default_branch": "master" 333 | } 334 | }, 335 | "_links": { 336 | "self": { 337 | "href": "https://api.github.com/repos/seemethere/test-repo/pulls/20" 338 | }, 339 | "html": { 340 | "href": "https://github.com/seemethere/test-repo/pull/20" 341 | }, 342 | "issue": { 343 | "href": "https://api.github.com/repos/seemethere/test-repo/issues/20" 344 | }, 345 | "comments": { 346 | "href": "https://api.github.com/repos/seemethere/test-repo/issues/20/comments" 347 | }, 348 | "review_comments": { 349 | "href": "https://api.github.com/repos/seemethere/test-repo/pulls/20/comments" 350 | }, 351 | "review_comment": { 352 | "href": "https://api.github.com/repos/seemethere/test-repo/pulls/comments{/number}" 353 | }, 354 | "commits": { 355 | "href": "https://api.github.com/repos/seemethere/test-repo/pulls/20/commits" 356 | }, 357 | "statuses": { 358 | "href": "https://api.github.com/repos/seemethere/test-repo/statuses/6163ecefc4cbb06c20208b51c617b1e2bfeb4109" 359 | } 360 | }, 361 | "author_association": "OWNER", 362 | "merged": false, 363 | "mergeable": true, 364 | "rebaseable": true, 365 | "mergeable_state": "unstable", 366 | "merged_by": null, 367 | "comments": 0, 368 | "review_comments": 0, 369 | "maintainer_can_modify": false, 370 | "commits": 4, 371 | "additions": 175, 372 | "deletions": 38, 373 | "changed_files": 5 374 | }, 375 | "label": { 376 | "id": 2025194687, 377 | "node_id": "MDU6TGFiZWwyMDI1MTk0Njg3", 378 | "url": "https://api.github.com/repos/seemethere/test-repo/labels/ci/windows", 379 | "name": "ci/windows", 380 | "color": "fcd4ab", 381 | "default": false, 382 | "description": "" 383 | }, 384 | "repository": { 385 | "id": 99942684, 386 | "node_id": "MDEwOlJlcG9zaXRvcnk5OTk0MjY4NA==", 387 | "name": "test-repo", 388 | "full_name": "seemethere/test-repo", 389 | "private": false, 390 | "owner": { 391 | "login": "seemethere", 392 | "id": 1700823, 393 | "node_id": "MDQ6VXNlcjE3MDA4MjM=", 394 | "avatar_url": "https://avatars2.githubusercontent.com/u/1700823?v=4", 395 | "gravatar_id": "", 396 | "url": "https://api.github.com/users/seemethere", 397 | "html_url": "https://github.com/seemethere", 398 | "followers_url": "https://api.github.com/users/seemethere/followers", 399 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 400 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 401 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 402 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 403 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 404 | "repos_url": "https://api.github.com/users/seemethere/repos", 405 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 406 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 407 | "type": "User", 408 | "site_admin": false 409 | }, 410 | "html_url": "https://github.com/seemethere/test-repo", 411 | "description": "this is a playground for github automation", 412 | "fork": false, 413 | "url": "https://api.github.com/repos/seemethere/test-repo", 414 | "forks_url": "https://api.github.com/repos/seemethere/test-repo/forks", 415 | "keys_url": "https://api.github.com/repos/seemethere/test-repo/keys{/key_id}", 416 | "collaborators_url": "https://api.github.com/repos/seemethere/test-repo/collaborators{/collaborator}", 417 | "teams_url": "https://api.github.com/repos/seemethere/test-repo/teams", 418 | "hooks_url": "https://api.github.com/repos/seemethere/test-repo/hooks", 419 | "issue_events_url": "https://api.github.com/repos/seemethere/test-repo/issues/events{/number}", 420 | "events_url": "https://api.github.com/repos/seemethere/test-repo/events", 421 | "assignees_url": "https://api.github.com/repos/seemethere/test-repo/assignees{/user}", 422 | "branches_url": "https://api.github.com/repos/seemethere/test-repo/branches{/branch}", 423 | "tags_url": "https://api.github.com/repos/seemethere/test-repo/tags", 424 | "blobs_url": "https://api.github.com/repos/seemethere/test-repo/git/blobs{/sha}", 425 | "git_tags_url": "https://api.github.com/repos/seemethere/test-repo/git/tags{/sha}", 426 | "git_refs_url": "https://api.github.com/repos/seemethere/test-repo/git/refs{/sha}", 427 | "trees_url": "https://api.github.com/repos/seemethere/test-repo/git/trees{/sha}", 428 | "statuses_url": "https://api.github.com/repos/seemethere/test-repo/statuses/{sha}", 429 | "languages_url": "https://api.github.com/repos/seemethere/test-repo/languages", 430 | "stargazers_url": "https://api.github.com/repos/seemethere/test-repo/stargazers", 431 | "contributors_url": "https://api.github.com/repos/seemethere/test-repo/contributors", 432 | "subscribers_url": "https://api.github.com/repos/seemethere/test-repo/subscribers", 433 | "subscription_url": "https://api.github.com/repos/seemethere/test-repo/subscription", 434 | "commits_url": "https://api.github.com/repos/seemethere/test-repo/commits{/sha}", 435 | "git_commits_url": "https://api.github.com/repos/seemethere/test-repo/git/commits{/sha}", 436 | "comments_url": "https://api.github.com/repos/seemethere/test-repo/comments{/number}", 437 | "issue_comment_url": "https://api.github.com/repos/seemethere/test-repo/issues/comments{/number}", 438 | "contents_url": "https://api.github.com/repos/seemethere/test-repo/contents/{+path}", 439 | "compare_url": "https://api.github.com/repos/seemethere/test-repo/compare/{base}...{head}", 440 | "merges_url": "https://api.github.com/repos/seemethere/test-repo/merges", 441 | "archive_url": "https://api.github.com/repos/seemethere/test-repo/{archive_format}{/ref}", 442 | "downloads_url": "https://api.github.com/repos/seemethere/test-repo/downloads", 443 | "issues_url": "https://api.github.com/repos/seemethere/test-repo/issues{/number}", 444 | "pulls_url": "https://api.github.com/repos/seemethere/test-repo/pulls{/number}", 445 | "milestones_url": "https://api.github.com/repos/seemethere/test-repo/milestones{/number}", 446 | "notifications_url": "https://api.github.com/repos/seemethere/test-repo/notifications{?since,all,participating}", 447 | "labels_url": "https://api.github.com/repos/seemethere/test-repo/labels{/name}", 448 | "releases_url": "https://api.github.com/repos/seemethere/test-repo/releases{/id}", 449 | "deployments_url": "https://api.github.com/repos/seemethere/test-repo/deployments", 450 | "created_at": "2017-08-10T16:18:38Z", 451 | "updated_at": "2020-03-02T21:39:16Z", 452 | "pushed_at": "2020-04-10T17:26:45Z", 453 | "git_url": "git://github.com/seemethere/test-repo.git", 454 | "ssh_url": "git@github.com:seemethere/test-repo.git", 455 | "clone_url": "https://github.com/seemethere/test-repo.git", 456 | "svn_url": "https://github.com/seemethere/test-repo", 457 | "homepage": "", 458 | "size": 19, 459 | "stargazers_count": 0, 460 | "watchers_count": 0, 461 | "language": null, 462 | "has_issues": true, 463 | "has_projects": true, 464 | "has_downloads": true, 465 | "has_wiki": true, 466 | "has_pages": false, 467 | "forks_count": 3, 468 | "mirror_url": null, 469 | "archived": false, 470 | "disabled": false, 471 | "open_issues_count": 11, 472 | "license": null, 473 | "forks": 3, 474 | "open_issues": 11, 475 | "watchers": 0, 476 | "default_branch": "master" 477 | }, 478 | "sender": { 479 | "login": "seemethere", 480 | "id": 1700823, 481 | "node_id": "MDQ6VXNlcjE3MDA4MjM=", 482 | "avatar_url": "https://avatars2.githubusercontent.com/u/1700823?v=4", 483 | "gravatar_id": "", 484 | "url": "https://api.github.com/users/seemethere", 485 | "html_url": "https://github.com/seemethere", 486 | "followers_url": "https://api.github.com/users/seemethere/followers", 487 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 488 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 489 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 490 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 491 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 492 | "repos_url": "https://api.github.com/users/seemethere/repos", 493 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 494 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 495 | "type": "User", 496 | "site_admin": false 497 | }, 498 | "installation": { 499 | "id": 8476435, 500 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uODQ3NjQzNQ==" 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /test/fixtures/push.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/tags/simple-tag", 3 | "before": "6113728f27ae82c7b1a177c8d03f9e96e0adf246", 4 | "after": "0000000000000000000000000000000000000000", 5 | "created": false, 6 | "deleted": true, 7 | "forced": false, 8 | "base_ref": null, 9 | "compare": "https://github.com/seemethere/test-repo/compare/6113728f27ae...000000000000", 10 | "commits": [ 11 | 12 | ], 13 | "head_commit": null, 14 | "repository": { 15 | "id": 186853002, 16 | "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", 17 | "name": "test-repo", 18 | "full_name": "seemethere/test-repo", 19 | "private": false, 20 | "owner": { 21 | "name": "seemethere", 22 | "email": "21031067+seemethere@users.noreply.github.com", 23 | "login": "seemethere", 24 | "id": 21031067, 25 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 26 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 27 | "gravatar_id": "", 28 | "url": "https://api.github.com/users/seemethere", 29 | "html_url": "https://github.com/seemethere", 30 | "followers_url": "https://api.github.com/users/seemethere/followers", 31 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 32 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 33 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 34 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 35 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 36 | "repos_url": "https://api.github.com/users/seemethere/repos", 37 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 38 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 39 | "type": "User", 40 | "site_admin": false 41 | }, 42 | "html_url": "https://github.com/seemethere/test-repo", 43 | "description": null, 44 | "fork": false, 45 | "url": "https://github.com/seemethere/test-repo", 46 | "forks_url": "https://api.github.com/repos/seemethere/test-repo/forks", 47 | "keys_url": "https://api.github.com/repos/seemethere/test-repo/keys{/key_id}", 48 | "collaborators_url": "https://api.github.com/repos/seemethere/test-repo/collaborators{/collaborator}", 49 | "teams_url": "https://api.github.com/repos/seemethere/test-repo/teams", 50 | "hooks_url": "https://api.github.com/repos/seemethere/test-repo/hooks", 51 | "issue_events_url": "https://api.github.com/repos/seemethere/test-repo/issues/events{/number}", 52 | "events_url": "https://api.github.com/repos/seemethere/test-repo/events", 53 | "assignees_url": "https://api.github.com/repos/seemethere/test-repo/assignees{/user}", 54 | "branches_url": "https://api.github.com/repos/seemethere/test-repo/branches{/branch}", 55 | "tags_url": "https://api.github.com/repos/seemethere/test-repo/tags", 56 | "blobs_url": "https://api.github.com/repos/seemethere/test-repo/git/blobs{/sha}", 57 | "git_tags_url": "https://api.github.com/repos/seemethere/test-repo/git/tags{/sha}", 58 | "git_refs_url": "https://api.github.com/repos/seemethere/test-repo/git/refs{/sha}", 59 | "trees_url": "https://api.github.com/repos/seemethere/test-repo/git/trees{/sha}", 60 | "statuses_url": "https://api.github.com/repos/seemethere/test-repo/statuses/{sha}", 61 | "languages_url": "https://api.github.com/repos/seemethere/test-repo/languages", 62 | "stargazers_url": "https://api.github.com/repos/seemethere/test-repo/stargazers", 63 | "contributors_url": "https://api.github.com/repos/seemethere/test-repo/contributors", 64 | "subscribers_url": "https://api.github.com/repos/seemethere/test-repo/subscribers", 65 | "subscription_url": "https://api.github.com/repos/seemethere/test-repo/subscription", 66 | "commits_url": "https://api.github.com/repos/seemethere/test-repo/commits{/sha}", 67 | "git_commits_url": "https://api.github.com/repos/seemethere/test-repo/git/commits{/sha}", 68 | "comments_url": "https://api.github.com/repos/seemethere/test-repo/comments{/number}", 69 | "issue_comment_url": "https://api.github.com/repos/seemethere/test-repo/issues/comments{/number}", 70 | "contents_url": "https://api.github.com/repos/seemethere/test-repo/contents/{+path}", 71 | "compare_url": "https://api.github.com/repos/seemethere/test-repo/compare/{base}...{head}", 72 | "merges_url": "https://api.github.com/repos/seemethere/test-repo/merges", 73 | "archive_url": "https://api.github.com/repos/seemethere/test-repo/{archive_format}{/ref}", 74 | "downloads_url": "https://api.github.com/repos/seemethere/test-repo/downloads", 75 | "issues_url": "https://api.github.com/repos/seemethere/test-repo/issues{/number}", 76 | "pulls_url": "https://api.github.com/repos/seemethere/test-repo/pulls{/number}", 77 | "milestones_url": "https://api.github.com/repos/seemethere/test-repo/milestones{/number}", 78 | "notifications_url": "https://api.github.com/repos/seemethere/test-repo/notifications{?since,all,participating}", 79 | "labels_url": "https://api.github.com/repos/seemethere/test-repo/labels{/name}", 80 | "releases_url": "https://api.github.com/repos/seemethere/test-repo/releases{/id}", 81 | "deployments_url": "https://api.github.com/repos/seemethere/test-repo/deployments", 82 | "created_at": 1557933565, 83 | "updated_at": "2019-05-15T15:20:41Z", 84 | "pushed_at": 1557933657, 85 | "git_url": "git://github.com/seemethere/test-repo.git", 86 | "ssh_url": "git@github.com:seemethere/test-repo.git", 87 | "clone_url": "https://github.com/seemethere/test-repo.git", 88 | "svn_url": "https://github.com/seemethere/test-repo", 89 | "homepage": null, 90 | "size": 0, 91 | "stargazers_count": 0, 92 | "watchers_count": 0, 93 | "language": "Ruby", 94 | "has_issues": true, 95 | "has_projects": true, 96 | "has_downloads": true, 97 | "has_wiki": true, 98 | "has_pages": true, 99 | "forks_count": 1, 100 | "mirror_url": null, 101 | "archived": false, 102 | "disabled": false, 103 | "open_issues_count": 2, 104 | "license": null, 105 | "forks": 1, 106 | "open_issues": 2, 107 | "watchers": 0, 108 | "default_branch": "master", 109 | "stargazers": 0, 110 | "master_branch": "master" 111 | }, 112 | "pusher": { 113 | "name": "seemethere", 114 | "email": "21031067+seemethere@users.noreply.github.com" 115 | }, 116 | "sender": { 117 | "login": "seemethere", 118 | "id": 21031067, 119 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 120 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 121 | "gravatar_id": "", 122 | "url": "https://api.github.com/users/seemethere", 123 | "html_url": "https://github.com/seemethere", 124 | "followers_url": "https://api.github.com/users/seemethere/followers", 125 | "following_url": "https://api.github.com/users/seemethere/following{/other_user}", 126 | "gists_url": "https://api.github.com/users/seemethere/gists{/gist_id}", 127 | "starred_url": "https://api.github.com/users/seemethere/starred{/owner}{/repo}", 128 | "subscriptions_url": "https://api.github.com/users/seemethere/subscriptions", 129 | "organizations_url": "https://api.github.com/users/seemethere/orgs", 130 | "repos_url": "https://api.github.com/users/seemethere/repos", 131 | "events_url": "https://api.github.com/users/seemethere/events{/privacy}", 132 | "received_events_url": "https://api.github.com/users/seemethere/received_events", 133 | "type": "User", 134 | "site_admin": false 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock'; 2 | import {nockTracker} from './common'; 3 | import myProbotApp from '../src/'; 4 | import {Probot} from 'probot'; 5 | import * as utils from './utils'; 6 | 7 | nock.disableNetConnect(); 8 | 9 | describe('index (integration test for all actions)', () => { 10 | let probot: Probot; 11 | 12 | beforeEach(() => { 13 | probot = utils.testProbot(); 14 | probot.load(myProbotApp); 15 | }); 16 | 17 | test('when issue is labeled', async () => { 18 | nock('https://api.github.com') 19 | .post('/app/installations/2/access_tokens') 20 | .reply(200, {token: 'test'}); 21 | 22 | nockTracker(` 23 | Some header text 24 | 25 | * high priority @ezyang 26 | `); 27 | 28 | const payload = require('./fixtures/issues.labeled'); 29 | payload['label'] = {name: 'high priority'}; 30 | payload['issue']['labels'] = [{name: 'high priority'}]; 31 | payload['issue']['body'] = 'Arf arf'; 32 | 33 | const scope = nock('https://api.github.com') 34 | .patch('/repos/ezyang/testing-ideal-computing-machine/issues/5', body => { 35 | expect(body).toMatchObject({ 36 | body: 'Arf arf\n\ncc @ezyang' 37 | }); 38 | return true; 39 | }) 40 | .reply(200) 41 | .post( 42 | '/repos/ezyang/testing-ideal-computing-machine/issues/5/labels', 43 | body => { 44 | expect(body).toMatchObject(['triage review']); 45 | return true; 46 | } 47 | ) 48 | .reply(200); 49 | 50 | await probot.receive({name: 'issues', payload, id: '2'}); 51 | 52 | scope.done(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/subscriptions.test.ts: -------------------------------------------------------------------------------- 1 | import {parseSubscriptions} from '../src/subscriptions'; 2 | 3 | describe('subscriptions', () => { 4 | test('complicated subscriptions', () => { 5 | expect( 6 | parseSubscriptions(` 7 | This issue is used by [pytorch-probot](https://github.com/pytorch/pytorch-probot) to manage subscriptions to labels. To subscribe yourself to a label, add a line \`* label @yourusername\`, or add your username to an existing line (space separated) in the body of this issue. **DO NOT COMMENT, COMMENTS ARE NOT RESPECTED BY THE BOT.** 8 | 9 | As a courtesy to others, please do not edit the subscriptions of users who are not you. 10 | 11 | * high priority @ezyang 12 | * critical @ezyang 13 | * module: binaries @ezyang 14 | * module: autograd @ezyang 15 | * module: complex @ezyang 16 | * module: doc infra @ezyang 17 | * module: ci @ezyang 18 | * module: typing @ezyang 19 | * module: dataloader @SsnL 20 | * topic: bc-breaking @ezyang @SsnL 21 | * topic: quansight @ezyang 22 | * module: quantization @pytorch/quantization 23 | `) 24 | ).toStrictEqual({ 25 | critical: ['ezyang'], 26 | 'high priority': ['ezyang'], 27 | 'module: autograd': ['ezyang'], 28 | 'module: binaries': ['ezyang'], 29 | 'module: ci': ['ezyang'], 30 | 'module: complex': ['ezyang'], 31 | 'module: dataloader': ['SsnL'], 32 | 'module: doc infra': ['ezyang'], 33 | 'module: typing': ['ezyang'], 34 | 'topic: bc-breaking': ['ezyang', 'SsnL'], 35 | 'topic: quansight': ['ezyang'], 36 | 'module: quantization': ['pytorch/quantization'] 37 | }); 38 | }); 39 | test('malformed subscriptions', () => { 40 | expect( 41 | parseSubscriptions(` 42 | This issue is used by [pytorch-probot](https://github.com/pytorch/pytorch-probot) to manage subscriptions to labels. To subscribe yourself to a label, add a line \`* label @yourusername\`, or add your username to an existing line (space separated) in the body of this issue. **DO NOT COMMENT, COMMENTS ARE NOT RESPECTED BY THE BOT.** 43 | 44 | As a courtesy to others, please do not edit the subscriptions of users who are not you. 45 | 46 | * high priority @ezyang 47 | * critical @ezyang 48 | * module: binaries 49 | * module: autograd @ezyang 50 | `) 51 | ).toStrictEqual({ 52 | critical: ['ezyang'], 53 | 'high priority': ['ezyang'], 54 | 'module: autograd': ['ezyang'] 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/trigger-circleci-workflows.test.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from 'fs'; 2 | import nock from 'nock'; 3 | import {Probot} from 'probot'; 4 | 5 | import * as utils from './utils'; 6 | import * as triggerCircleBot from '../src/trigger-circleci-workflows'; 7 | 8 | nock.disableNetConnect(); 9 | 10 | const EXAMPLE_CONFIG = ` 11 | labels_to_circle_params: 12 | ci/binaries: 13 | parameter: run_binaries_tests 14 | default_true_on: 15 | branches: 16 | - nightly 17 | - ci-all/.* 18 | tags: 19 | - v[0-9]+(\.[0-9]+)*-rc[0-9]+ 20 | ci/default: 21 | parameter: default 22 | default_true_on: 23 | branches: 24 | - master 25 | pull_request: 26 | ci/bleh: 27 | parameter: run_bleh_tests 28 | ci/foo: 29 | parameter: run_foo_tests 30 | `; 31 | 32 | interface Example { 33 | payload: object; 34 | endpoint: string; 35 | } 36 | 37 | // Prior to the existence of this `prepare` function, the tests in this suite 38 | // were failing in very strange ways when run together (but not when run in 39 | // isolation). This seemed to be caused by the fact that all the tests were 40 | // `nock`ing the same endpoint of the same CircleCI URL, so one test would 41 | // receive the CircleCI parameters that corresponded to a different test. No 42 | // idea how CI was previously passing on `master`. Anyway, this fixes the issue 43 | // by enforcing that every test rename the example repo to a unique name, 44 | // resulting in a unique CircleCI endpoint. 45 | const usedNames: Set = new Set(); 46 | async function prepare(fixture: string, repoName: string): Promise { 47 | expect(usedNames.has(repoName)).toBe(false); 48 | usedNames.add(repoName); 49 | const repoFullName = `seemethere/${repoName}`; 50 | utils.mockConfig(triggerCircleBot.configName, EXAMPLE_CONFIG, repoFullName); 51 | const payload = JSON.parse( 52 | (await fs.readFile(`test/fixtures/${fixture}.json`, 'utf8')).replace( 53 | /test-repo/g, 54 | repoName 55 | ) 56 | ); 57 | const endpoint = triggerCircleBot.circlePipelineEndpoint(repoFullName); 58 | return {payload, endpoint}; 59 | } 60 | 61 | describe('trigger-circleci-workflows', () => { 62 | let probot: Probot; 63 | let payload: object; 64 | 65 | beforeEach(() => { 66 | probot = utils.testProbot(); 67 | probot.load(triggerCircleBot.myBot); 68 | process.env.CIRCLE_TOKEN = 'dummy_token'; 69 | utils.mockAccessToken(); 70 | }); 71 | 72 | afterEach(() => { 73 | // Cleanup environment variables after the fact 74 | delete process.env.CIRCLE_TOKEN; 75 | }); 76 | 77 | test('test with pull_request.labeled (specific labels)', async () => { 78 | const {payload, endpoint} = await prepare( 79 | 'pull_request.labeled', 80 | 'pr-labeled-specific' 81 | ); 82 | payload['pull_request']['number'] = 1; 83 | payload['pull_request']['head']['ref'] = 'test_branch'; 84 | payload['pull_request']['labels'] = [ 85 | {name: 'ci/binaries'}, 86 | {name: 'ci/bleh'} 87 | ]; 88 | const scope = nock(`${triggerCircleBot.circleAPIUrl}`) 89 | .post(endpoint, (body: any) => { 90 | expect(body).toStrictEqual({ 91 | branch: 'test_branch', 92 | parameters: { 93 | run_binaries_tests: true, 94 | run_bleh_tests: true, 95 | default: true 96 | } 97 | }); 98 | return true; 99 | }) 100 | .reply(201); 101 | 102 | await probot.receive({name: 'pull_request', payload, id: '2'}); 103 | 104 | expect(scope.isDone()).toBe(true); 105 | }); 106 | 107 | test('test with pull_request.labeled (fork) (specific labels)', async () => { 108 | const {payload, endpoint} = await prepare( 109 | 'pull_request.labeled', 110 | 'pr-labeled-fork-specific' 111 | ); 112 | payload['pull_request']['head']['repo']['fork'] = true; 113 | payload['pull_request']['number'] = 1; 114 | payload['pull_request']['head']['ref'] = 'test_branch'; 115 | payload['pull_request']['labels'] = [ 116 | {name: 'ci/binaries'}, 117 | {name: 'ci/no-default'}, 118 | {name: 'ci/bleh'} 119 | ]; 120 | const scope = nock(`${triggerCircleBot.circleAPIUrl}`) 121 | .post(endpoint, (body: any) => { 122 | expect(body).toStrictEqual({ 123 | branch: 'pull/1/head', 124 | parameters: { 125 | run_binaries_tests: true, 126 | run_bleh_tests: true 127 | } 128 | }); 129 | return true; 130 | }) 131 | .reply(201); 132 | 133 | await probot.receive({name: 'pull_request', payload, id: '2'}); 134 | 135 | expect(scope.isDone()).toBe(true); 136 | }); 137 | 138 | test('test with push (refs/heads/nightly)', async () => { 139 | const {payload, endpoint} = await prepare('push', 'push-nightly'); 140 | payload['ref'] = 'refs/heads/nightly'; 141 | const scope = nock(`${triggerCircleBot.circleAPIUrl}`) 142 | .post(endpoint, (body: any) => { 143 | expect(body).toStrictEqual({ 144 | branch: 'nightly', 145 | parameters: { 146 | run_binaries_tests: true 147 | } 148 | }); 149 | return true; 150 | }) 151 | .reply(201); 152 | 153 | await probot.receive({name: 'push', payload, id: '2'}); 154 | 155 | scope.done(); 156 | }); 157 | 158 | test('test with push (refs/heads/ci-all/bleh)', async () => { 159 | const {payload, endpoint} = await prepare('push', 'push-all-bleh'); 160 | payload['ref'] = 'refs/heads/ci-all/bleh'; 161 | const scope = nock(`${triggerCircleBot.circleAPIUrl}`) 162 | .post(endpoint, (body: any) => { 163 | expect(body).toStrictEqual({ 164 | branch: 'ci-all/bleh', 165 | parameters: { 166 | run_binaries_tests: true 167 | } 168 | }); 169 | return true; 170 | }) 171 | .reply(201); 172 | 173 | await probot.receive({name: 'push', payload, id: '2'}); 174 | 175 | scope.done(); 176 | }); 177 | 178 | test('test with push (/refs/tags/v1.5.0-rc1)', async () => { 179 | const {payload, endpoint} = await prepare('push', 'push-tag-rc'); 180 | payload['ref'] = 'refs/tags/v1.5.0-rc1'; 181 | const scope = nock(`${triggerCircleBot.circleAPIUrl}`) 182 | .post(endpoint, (body: any) => { 183 | expect(body).toStrictEqual({ 184 | tag: 'v1.5.0-rc1', 185 | parameters: { 186 | run_binaries_tests: true 187 | } 188 | }); 189 | return true; 190 | }) 191 | .reply(201); 192 | 193 | await probot.receive({name: 'push', payload, id: '2'}); 194 | 195 | scope.done(); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | // @format 2 | import nock from 'nock'; 3 | import {Probot} from 'probot'; 4 | 5 | export function testProbot(): Probot { 6 | return new Probot({ 7 | id: 1, 8 | cert: 'test', 9 | githubToken: 'test' 10 | }); 11 | } 12 | 13 | export function mockConfig( 14 | fileName: string, 15 | content: string, 16 | repoKey: string 17 | ): void { 18 | const configPayload = require('./fixtures/config.json'); 19 | configPayload['content'] = Buffer.from(content).toString('base64'); 20 | configPayload['name'] = fileName; 21 | configPayload['path'] = `.github/${fileName}`; 22 | nock('https://api.github.com') 23 | .get(`/repos/${repoKey}/contents/.github/${fileName}`) 24 | .reply(200, configPayload); 25 | } 26 | 27 | export function mockAccessToken(): void { 28 | nock('https://api.github.com') 29 | .post('/app/installations/2/access_tokens') 30 | .reply(200, {token: 'test'}); 31 | } 32 | -------------------------------------------------------------------------------- /test/verify-disable-test-issue.test.ts: -------------------------------------------------------------------------------- 1 | import {Probot} from 'probot'; 2 | import * as utils from './utils'; 3 | import myProbotApp, * as botUtils from '../src/verify-disable-test-issue'; 4 | 5 | describe('verify-disable-test-issue', () => { 6 | let probot: Probot; 7 | 8 | beforeEach(() => { 9 | probot = utils.testProbot(); 10 | probot.load(myProbotApp); 11 | }); 12 | 13 | test('issue opened with title starts w/ DISABLED: disable for win', async () => { 14 | const title = 'DISABLED testMethodName (testClass.TestSuite)'; 15 | const body = 'whatever\nPlatforms:win\nyay'; 16 | 17 | const platforms = botUtils.parseBody(body); 18 | const testName = botUtils.parseTitle(title); 19 | expect(platforms).toMatchObject([new Set(['win']), new Set()]); 20 | expect(testName).toEqual('testMethodName (testClass.TestSuite)'); 21 | 22 | const comment = botUtils.formValidationComment(testName, platforms); 23 | expect(comment.includes('')).toBeTruthy(); 24 | expect( 25 | comment.includes( 26 | '~15 minutes, `testMethodName (testClass.TestSuite)` will be disabled' 27 | ) 28 | ).toBeTruthy(); 29 | expect(comment.includes('these platforms: win.')).toBeTruthy(); 30 | expect(comment.includes('ERROR')).toBeFalsy(); 31 | expect(comment.includes('WARNING')).toBeFalsy(); 32 | }); 33 | 34 | test('issue opened with title starts w/ DISABLED: disable for windows, rocm, asan', async () => { 35 | const title = 'DISABLED testMethodName (testClass.TestSuite)'; 36 | const body = 'whatever\nPlatforms:windows, ROCm, ASAN\nyay'; 37 | 38 | const platforms = botUtils.parseBody(body); 39 | const testName = botUtils.parseTitle(title); 40 | expect(platforms).toMatchObject([ 41 | new Set(['windows', 'rocm', 'asan']), 42 | new Set() 43 | ]); 44 | expect(testName).toEqual('testMethodName (testClass.TestSuite)'); 45 | 46 | const comment = botUtils.formValidationComment(testName, platforms); 47 | expect(comment.includes('')).toBeTruthy(); 48 | expect( 49 | comment.includes( 50 | '~15 minutes, `testMethodName (testClass.TestSuite)` will be disabled' 51 | ) 52 | ).toBeTruthy(); 53 | expect( 54 | comment.includes('these platforms: asan, rocm, windows.') 55 | ).toBeTruthy(); 56 | expect(comment.includes('ERROR')).toBeFalsy(); 57 | expect(comment.includes('WARNING')).toBeFalsy(); 58 | }); 59 | 60 | test('issue opened with title starts w/ DISABLED: disable for all', async () => { 61 | const title = 'DISABLED testMethodName (testClass.TestSuite)'; 62 | const body = 'whatever yay'; 63 | 64 | const platforms = botUtils.parseBody(body); 65 | const testName = botUtils.parseTitle(title); 66 | expect(platforms).toMatchObject([new Set(), new Set()]); 67 | expect(testName).toEqual('testMethodName (testClass.TestSuite)'); 68 | 69 | const comment = botUtils.formValidationComment(testName, platforms); 70 | expect(comment.includes('')).toBeTruthy(); 71 | expect( 72 | comment.includes( 73 | '~15 minutes, `testMethodName (testClass.TestSuite)` will be disabled' 74 | ) 75 | ).toBeTruthy(); 76 | expect(comment.includes('all platforms.')).toBeTruthy(); 77 | expect(comment.includes('ERROR')).toBeFalsy(); 78 | expect(comment.includes('WARNING')).toBeFalsy(); 79 | }); 80 | 81 | test('issue opened with title starts w/ DISABLED: disable unknown platform', async () => { 82 | const title = 'DISABLED testMethodName (testClass.TestSuite)'; 83 | const body = 'whatever\nPlatforms:everything\nyay'; 84 | 85 | const platforms = botUtils.parseBody(body); 86 | const testName = botUtils.parseTitle(title); 87 | expect(platforms).toMatchObject([new Set(), new Set(['everything'])]); 88 | expect(testName).toEqual('testMethodName (testClass.TestSuite)'); 89 | 90 | const comment = botUtils.formValidationComment(testName, platforms); 91 | expect(comment.includes('')).toBeTruthy(); 92 | expect( 93 | comment.includes( 94 | '~15 minutes, `testMethodName (testClass.TestSuite)` will be disabled' 95 | ) 96 | ).toBeTruthy(); 97 | expect(comment.includes('all platforms.')).toBeTruthy(); 98 | expect(comment.includes('ERROR')).toBeFalsy(); 99 | expect(comment.includes('WARNING')).toBeTruthy(); 100 | expect( 101 | comment.includes( 102 | 'invalid inputs as platforms for which the test will be disabled: everything.' 103 | ) 104 | ).toBeTruthy(); 105 | }); 106 | 107 | test('issue opened with title starts w/ DISABLED: cannot parse test', async () => { 108 | const title = 'DISABLED testMethodName cuz it borked '; 109 | const body = 'whatever\nPlatforms:\nyay'; 110 | 111 | const platforms = botUtils.parseBody(body); 112 | const testName = botUtils.parseTitle(title); 113 | expect(platforms).toMatchObject([new Set(), new Set()]); 114 | expect(testName).toEqual('testMethodName cuz it borked'); 115 | 116 | const comment = botUtils.formValidationComment(testName, platforms); 117 | expect(comment.includes('')).toBeTruthy(); 118 | expect(comment.includes('~15 minutes')).toBeFalsy(); 119 | expect(comment.includes('ERROR')).toBeTruthy(); 120 | expect(comment.includes('WARNING')).toBeFalsy(); 121 | }); 122 | 123 | test('issue opened with title starts w/ DISABLED: cannot parse test nor platforms', async () => { 124 | const title = 'DISABLED testMethodName cuz it borked '; 125 | const body = 'whatever\nPlatforms:all of them\nyay'; 126 | 127 | const platforms = botUtils.parseBody(body); 128 | const testName = botUtils.parseTitle(title); 129 | expect(platforms).toMatchObject([new Set(), new Set(['all of them'])]); 130 | expect(testName).toEqual('testMethodName cuz it borked'); 131 | 132 | const comment = botUtils.formValidationComment(testName, platforms); 133 | expect(comment.includes('')).toBeTruthy(); 134 | expect(comment.includes('~15 minutes')).toBeFalsy(); 135 | expect(comment.includes('ERROR')).toBeTruthy(); 136 | expect(comment.includes('WARNING')).toBeTruthy(); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "8.3" 5 | notifications: 6 | disabled: true 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "allowJs": true, 5 | "target": "es5", 6 | "esModuleInterop": true 7 | }, 8 | "include": [ 9 | "./src/**/*" 10 | ] 11 | } 12 | --------------------------------------------------------------------------------