diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 63f98700..66abe205 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,42 +7,47 @@ Always reference these instructions first and fallback to search or bash command ## Working Effectively ### Bootstrap and Setup + - Clone the repository - `npm install` -- takes 1-30 seconds depending on npm cache state. Works with Node.js 20.x but shows engine warnings (expects Node.js 22.x) - Create `.env` file for local development: - ```bash - echo 'APP_ID=12345 - PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- - [valid PEM private key content] - -----END RSA PRIVATE KEY-----" - WEBHOOK_SECRET=test_webhook_secret_123 - PORT=3000' > .env - ``` + ```bash + echo 'APP_ID=12345 + PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- + [valid PEM private key content] + -----END RSA PRIVATE KEY-----" + WEBHOOK_SECRET=test_webhook_secret_123 + PORT=3000' > .env + ``` ### Build and Test + - `npm run lint` -- takes ~1.5 seconds. NEVER CANCEL. Uses ESLint with eslint-config-eslint - `npm test` -- takes ~3 seconds. NEVER CANCEL. Runs Jest with 284 tests achieving 98.31% coverage - Run the web server: `npm start` -- starts immediately on port 3000 - Health check: `curl http://localhost:3000/ping` returns "PONG" in ~1.6ms ### Environment Requirements + - Node.js 22.x preferred (works on 20.x with warnings) - npm 10.x - Environment variables for server operation: - - `APP_ID`: GitHub app ID (can be dummy value like 12345 for local testing) - - `PRIVATE_KEY`: Valid PEM private key (required format, can be test key for local development) - - `WEBHOOK_SECRET`: Webhook secret (can be dummy value for local testing) - - `PORT`: Server port (optional, defaults to 3000) + - `APP_ID`: GitHub app ID (can be dummy value like 12345 for local testing) + - `PRIVATE_KEY`: Valid PEM private key (required format, can be test key for local development) + - `WEBHOOK_SECRET`: Webhook secret (can be dummy value for local testing) + - `PORT`: Server port (optional, defaults to 3000) ## Validation ### Manual Testing Requirements + - ALWAYS run the full test suite after making changes: `npm test` - ALWAYS run linting before committing: `npm run lint` - Test server startup: `npm start` and verify health check responds: `curl http://localhost:3000/ping` - For plugin changes, run relevant test files: `npm test tests/plugins/[plugin-name]/index.js` ### Critical Timing Requirements + - **NEVER CANCEL** any commands - all operations complete quickly - npm install: 1-30 seconds (set timeout to 60+ seconds) - npm test: ~3 seconds (set timeout to 30+ seconds) @@ -52,6 +57,7 @@ Always reference these instructions first and fallback to search or bash command ## Common Tasks ### Plugin Development + The bot uses a plugin architecture with 6 core plugins in `src/plugins/`: 1. **auto-assign** (`src/plugins/auto-assign/index.js`): Auto-assigns issues to users who indicate willingness to submit PRs @@ -62,6 +68,7 @@ The bot uses a plugin architecture with 6 core plugins in `src/plugins/`: 6. **wip** (`src/plugins/wip/index.js`): Handles work-in-progress PR status based on title/labels ### Adding New Plugins + 1. Create plugin file in `src/plugins/[plugin-name]/index.js` 2. Add plugin to exports in `src/plugins/index.js` 3. Add plugin to enabled list in `src/app.js` @@ -69,6 +76,7 @@ The bot uses a plugin architecture with 6 core plugins in `src/plugins/`: 5. Follow existing plugin patterns using Probot event handlers ### File Structure Reference + ``` src/ ├── app.js # Main application entry point @@ -98,15 +106,17 @@ docs/ ``` ### Key Configuration Files + - `package.json`: Dependencies, scripts, Jest config - `eslint.config.js`: ESLint configuration using eslint-config-eslint - `.editorconfig`: Code formatting rules - `Procfile`: Production deployment config for Dokku -- `.gitignore`: Excludes node_modules, coverage, .env, *.pem files +- `.gitignore`: Excludes node_modules, coverage, .env, \*.pem files ### Common Command Outputs #### Repository Root Files + ```bash $ ls -la .editorconfig @@ -127,18 +137,20 @@ tests/ ``` #### Package.json Scripts + ```json { - "scripts": { - "lint": "eslint .", - "lint:fix": "npm run lint -- --fix", - "start": "node ./src/app.js", - "test": "jest --colors --verbose --coverage" - } + "scripts": { + "lint": "eslint .", + "lint:fix": "npm run lint -- --fix", + "start": "node ./src/app.js", + "test": "jest --colors --verbose --coverage" + } } ``` #### Test Coverage Summary + ``` All files | 98.31 | 93.1 | 98.36 | 98.21 | Test Suites: 6 passed, 6 total @@ -149,21 +161,25 @@ Time: ~3 seconds ## Troubleshooting ### Node.js Version Warnings + - Repository expects Node.js 22.x but works on 20.x with warnings - Engine warnings are normal and do not prevent functionality - All commands and tests work correctly despite version mismatch ### Server Won't Start + - Ensure `.env` file exists with required environment variables - `PRIVATE_KEY` must be valid PEM format (can be test key for local development) - Server defaults to port 3000, check for port conflicts ### Test Failures + - Run `npm install` to ensure dependencies are current - Check that changes don't break existing plugin functionality - Verify new tests follow existing patterns in `tests/plugins/` structure ### Deployment Notes + - Production deployment uses Dokku to github-bot.eslint.org - Health check endpoint: https://github-bot.eslint.org/ping - Webhook URL: /api/github/webhooks (Probot default) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e20d5a95..93a64461 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,38 +1,53 @@ name: CI on: - push: - branches: - - main - pull_request: - branches: - - main + push: + branches: + - main + pull_request: + branches: + - main jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22.x - - name: Install dependencies - run: npm install - - name: Lint files - run: npm run lint - test: - name: Test - strategy: - matrix: - os: [ubuntu-latest] - node: [22.x] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node }} - - name: Install dependencies - run: npm install - - name: Run tests - run: npm test + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.x + - name: Install dependencies + run: npm install + - name: Lint files + run: npm run lint + + format: + name: File Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.x + - name: Install dependencies + run: npm install + - name: Prettier Check + run: npm run fmt:check + + test: + name: Test + strategy: + matrix: + os: [ubuntu-latest] + node: [22.x] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + - name: Install dependencies + run: npm install + - name: Run tests + run: npm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fcefb770..d57628c2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,22 +1,22 @@ name: deploy on: - push: - branches: - - main + push: + branches: + - main jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Cloning repo - uses: actions/checkout@v6 - with: - fetch-depth: 0 + deploy: + runs-on: ubuntu-latest + steps: + - name: Cloning repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 - - name: Push to dokku - uses: dokku/github-action@master - with: - branch: main - git_remote_url: 'ssh://dokku@github-bot.eslint.org/eslint-github-bot' - ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} + - name: Push to dokku + uses: dokku/github-action@master + with: + branch: main + git_remote_url: "ssh://dokku@github-bot.eslint.org/eslint-github-bot" + ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..404abb22 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +coverage/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bce4136..8a7dcf43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,9 +8,9 @@ This project adheres to the [JS Foundation Code of Conduct](https://js.foundatio ## Filing Issues -* To report a security vulnerability in `eslint-github-bot`, please use our [HackerOne program](https://hackerone.com/eslint). -* To report an issue that does not have security impact, please [create an issue on GitHub](https://github.com/eslint/eslint-github-bot/issues/new). -* To create a feature request, [create an issue on GitHub](https://github.com/eslint/eslint-github-bot/issues/new). +- To report a security vulnerability in `eslint-github-bot`, please use our [HackerOne program](https://hackerone.com/eslint). +- To report an issue that does not have security impact, please [create an issue on GitHub](https://github.com/eslint/eslint-github-bot/issues/new). +- To create a feature request, [create an issue on GitHub](https://github.com/eslint/eslint-github-bot/issues/new). Please keep in mind that `eslint-github-bot` is primarily intended for the ESLint team's use cases. You're welcome to use the code for your own purposes, but we are unlikely to accept a feature request unless we would use the feature for the ESLint team's repositories. diff --git a/README.md b/README.md index 91ee2f1e..d117b4a8 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,21 @@ ## Environment Variables: -* `APP_ID` (required): The numeric GitHub app ID -* `PRIVATE_KEY` (required): the contents of the private key you downloaded after creating the app. -* `WEBHOOK_SECRET` (required): Secret setup for GitHub webhook or you generated when you created the app. -* `PORT`: Port for web server _(optional, defaults to 8000)_. +- `APP_ID` (required): The numeric GitHub app ID +- `PRIVATE_KEY` (required): the contents of the private key you downloaded after creating the app. +- `WEBHOOK_SECRET` (required): Secret setup for GitHub webhook or you generated when you created the app. +- `PORT`: Port for web server _(optional, defaults to 8000)_. ## :wrench: Setup -* Clone this repo -* `npm install` -* `npm test` +- Clone this repo +- `npm install` +- `npm test` To start the server locally, you'll need: -* A PEM file -* A `.env` file that specifies the required environment variables +- A PEM file +- A `.env` file that specifies the required environment variables The `APP_ID` and `WEBHOOK_SECRET` need to be present but need not be the registered application ID or webhook secret to start the server. `PRIVATE_KEY` must be a valid PEM private key. diff --git a/eslint.config.js b/eslint.config.js index f669edc0..f0432c86 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,28 +5,28 @@ const eslintConfigESLint = require("eslint-config-eslint/cjs"); const globals = require("globals"); module.exports = defineConfig([ - globalIgnores(["coverage/"]), - eslintConfigESLint, - { - rules: { - camelcase: ["error", { properties: "never" }], - } - }, - { - files: ["eslint.config.js"], - rules: { - "n/no-unpublished-require": "off" - } - }, - { - files: ["tests/**/*.test.js"], - languageOptions: { - globals: { - ...globals.jest - } - }, - rules: { - "n/no-unpublished-require": "off" - } - } + globalIgnores(["coverage/"]), + eslintConfigESLint, + { + rules: { + camelcase: ["error", { properties: "never" }], + }, + }, + { + files: ["eslint.config.js"], + rules: { + "n/no-unpublished-require": "off", + }, + }, + { + files: ["tests/**/*.test.js"], + languageOptions: { + globals: { + ...globals.jest, + }, + }, + rules: { + "n/no-unpublished-require": "off", + }, + }, ]); diff --git a/package-lock.json b/package-lock.json index d0570887..bc80055b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "globals": "^17.0.0", "jest": "^30.2.0", "lint-staged": "^16.2.7", + "prettier": "3.8.1", "yorkie": "^2.0.0" }, "engines": { @@ -8014,6 +8015,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", diff --git a/package.json b/package.json index 2a6af29f..097a8cbb 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "scripts": { "lint": "eslint", "lint:fix": "eslint --fix", + "fmt": "prettier --write .", + "fmt:check": "prettier --check .", "start": "node ./src/app.js", "test": "jest --colors --verbose --coverage" }, @@ -19,7 +21,11 @@ "pre-commit": "lint-staged" }, "lint-staged": { - "*.js": "eslint --fix" + "*.js": [ + "eslint --fix", + "prettier --write" + ], + "!(*.js)": "prettier --write --ignore-unknown" }, "dependencies": { "moment": "^2.30.1", @@ -33,6 +39,7 @@ "globals": "^17.0.0", "jest": "^30.2.0", "lint-staged": "^16.2.7", + "prettier": "3.8.1", "yorkie": "^2.0.0" }, "keywords": [ diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 00000000..5a8ffa75 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = { + useTabs: true, + tabWidth: 4, + arrowParens: "avoid", + + overrides: [ + { + files: ["*.json", "*.jsonc", "*.json5"], + options: { + tabWidth: 2, + useTabs: false, + }, + }, + ], +}; diff --git a/src/app.js b/src/app.js index 7d56410a..4baa994b 100644 --- a/src/app.js +++ b/src/app.js @@ -23,13 +23,13 @@ const plugins = require("./plugins"); //----------------------------------------------------------------------------- const enabledPlugins = new Set([ - "autoAssign", - "commitMessage", - "issuePrLink", - "needsInfo", - "recurringIssues", - "releaseMonitor", - "wip" + "autoAssign", + "commitMessage", + "issuePrLink", + "needsInfo", + "recurringIssues", + "releaseMonitor", + "wip", ]); /** @@ -38,9 +38,9 @@ const enabledPlugins = new Set([ * @returns {void} */ function appFn(robot) { - Object.keys(plugins) - .filter(pluginId => enabledPlugins.has(pluginId)) - .forEach(pluginId => plugins[pluginId](robot)); + Object.keys(plugins) + .filter(pluginId => enabledPlugins.has(pluginId)) + .forEach(pluginId => plugins[pluginId](robot)); } // start the server diff --git a/src/plugins/auto-assign/index.js b/src/plugins/auto-assign/index.js index ed71ba21..be083e5f 100644 --- a/src/plugins/auto-assign/index.js +++ b/src/plugins/auto-assign/index.js @@ -22,11 +22,9 @@ * @private */ function isWillingToSubmitPR(body) { - return body - .toLowerCase() - .includes( - "- [x] i am willing to submit a pull request" - ); + return body + .toLowerCase() + .includes("- [x] i am willing to submit a pull request"); } /** @@ -36,19 +34,19 @@ function isWillingToSubmitPR(body) { * @private */ async function issueOpenedHandler(context) { - const { payload } = context; + const { payload } = context; - if (!isWillingToSubmitPR(payload.issue.body)) { - return; - } + if (!isWillingToSubmitPR(payload.issue.body)) { + return; + } - await context.octokit.issues.addAssignees( - context.issue({ - assignees: [payload.issue.user.login], - }) - ); + await context.octokit.issues.addAssignees( + context.issue({ + assignees: [payload.issue.user.login], + }), + ); } -module.exports = (robot) => { - robot.on("issues.opened", issueOpenedHandler); +module.exports = robot => { + robot.on("issues.opened", issueOpenedHandler); }; diff --git a/src/plugins/commit-message/createMessage.js b/src/plugins/commit-message/createMessage.js index d434195d..8aa607c5 100644 --- a/src/plugins/commit-message/createMessage.js +++ b/src/plugins/commit-message/createMessage.js @@ -8,10 +8,13 @@ const MESSAGE_LENGTH_LIMIT = 72; const ERROR_MESSAGES = { - SPACE_AFTER_TAG_COLON: "- There should be a space following the initial tag and colon, for example 'feat: Message'.", - NON_LOWERCASE_FIRST_LETTER_TAG: "- The first letter of the tag should be in lowercase", - NON_MATCHED_TAG: "- The commit message tag wasn't recognized. Did you mean \"docs\", \"fix\", or \"feat\"?", - LONG_MESSAGE: `- The length of the commit message must be less than or equal to ${MESSAGE_LENGTH_LIMIT}` + SPACE_AFTER_TAG_COLON: + "- There should be a space following the initial tag and colon, for example 'feat: Message'.", + NON_LOWERCASE_FIRST_LETTER_TAG: + "- The first letter of the tag should be in lowercase", + NON_MATCHED_TAG: + '- The commit message tag wasn\'t recognized. Did you mean "docs", "fix", or "feat"?', + LONG_MESSAGE: `- The length of the commit message must be less than or equal to ${MESSAGE_LENGTH_LIMIT}`, }; /** @@ -22,15 +25,15 @@ const ERROR_MESSAGES = { * @private */ module.exports = function commentMessage(errors, username) { - const errorMessages = []; + const errorMessages = []; - errors.forEach(err => { - if (ERROR_MESSAGES[err]) { - errorMessages.push(ERROR_MESSAGES[err]); - } - }); + errors.forEach(err => { + if (ERROR_MESSAGES[err]) { + errorMessages.push(ERROR_MESSAGES[err]); + } + }); - return `Hi @${username}!, thanks for the Pull Request + return `Hi @${username}!, thanks for the Pull Request The **pull request title** isn't properly formatted. We ask that you update the pull request title to match this format, as we use it to generate changelogs and automate releases. @@ -40,5 +43,4 @@ ${errorMessages.join("\n")} Read more about contributing to ESLint [here](https://eslint.org/docs/developer-guide/contributing/) `; - }; diff --git a/src/plugins/commit-message/index.js b/src/plugins/commit-message/index.js index d52cf071..e5293a72 100644 --- a/src/plugins/commit-message/index.js +++ b/src/plugins/commit-message/index.js @@ -31,10 +31,7 @@ const LOWERCASE_TAG_REGEX = /^[a-z]/u; const MESSAGE_LENGTH_LIMIT = 72; -const EXCLUDED_REPOSITORY_NAMES = new Set([ - "eslint.github.io", - "tsc-meetings" -]); +const EXCLUDED_REPOSITORY_NAMES = new Set(["eslint.github.io", "tsc-meetings"]); //----------------------------------------------------------------------------- // Functions @@ -47,32 +44,32 @@ const EXCLUDED_REPOSITORY_NAMES = new Set([ * @private */ function getCommitMessageErrors(message) { - const commitTitle = message.split(/\r?\n/u)[0]; - const errors = []; + const commitTitle = message.split(/\r?\n/u)[0]; + const errors = []; - if (message.startsWith("Revert \"")) { - return errors; - } + if (message.startsWith('Revert "')) { + return errors; + } - // First, check tag and summary length - if (!TAG_REGEX.test(commitTitle)) { - errors.push("NON_MATCHED_TAG"); - } + // First, check tag and summary length + if (!TAG_REGEX.test(commitTitle)) { + errors.push("NON_MATCHED_TAG"); + } - // Check if there is any whitespace after the : - if (!TAG_SPACE_REGEX.test(commitTitle)) { - errors.push("SPACE_AFTER_TAG_COLON"); - } + // Check if there is any whitespace after the : + if (!TAG_SPACE_REGEX.test(commitTitle)) { + errors.push("SPACE_AFTER_TAG_COLON"); + } - if (!LOWERCASE_TAG_REGEX.test(commitTitle)) { - errors.push("NON_LOWERCASE_FIRST_LETTER_TAG"); - } + if (!LOWERCASE_TAG_REGEX.test(commitTitle)) { + errors.push("NON_LOWERCASE_FIRST_LETTER_TAG"); + } - if (!(commitTitle.length <= MESSAGE_LENGTH_LIMIT)) { - errors.push("LONG_MESSAGE"); - } + if (!(commitTitle.length <= MESSAGE_LENGTH_LIMIT)) { + errors.push("LONG_MESSAGE"); + } - return errors; + return errors; } /** @@ -82,10 +79,10 @@ function getCommitMessageErrors(message) { * @private */ function getCommitMessageLabels(message) { - const commitTitle = message.split(/\r?\n/u)[0]; - const [tag] = commitTitle.match(TAG_REGEX) || [""]; + const commitTitle = message.split(/\r?\n/u)[0]; + const [tag] = commitTitle.match(TAG_REGEX) || [""]; - return TAG_LABELS.get(tag.trim()); + return TAG_LABELS.get(tag.trim()); } /** @@ -95,57 +92,57 @@ function getCommitMessageLabels(message) { * @private */ async function processCommitMessage(context) { - - /* - * We care about the default commit message that will appear when the - * PR is merged. If the PR has exactly one commit, this is the commit - * message of that commit. If the PR has more than one commit, this - * is the title of the PR. - */ - const { payload, octokit } = context; - - if (EXCLUDED_REPOSITORY_NAMES.has(payload.repository.name)) { - return; - } - - const allCommits = await octokit.pulls.listCommits(context.pullRequest()); - const messageToCheck = payload.pull_request.title; - const errors = getCommitMessageErrors(messageToCheck); - let description; - let state; - - if (errors.length === 0) { - state = "success"; - description = "PR title follows commit message guidelines"; - - const labels = getCommitMessageLabels(messageToCheck); - - if (labels) { - await context.octokit.issues.addLabels(context.issue({ labels })); - } - - } else { - state = "failure"; - description = "PR title doesn't follow commit message guidelines"; - } - - // create status on the last commit - await octokit.repos.createCommitStatus( - context.repo({ - sha: allCommits.data.at(-1).sha, - state, - target_url: "https://github.com/eslint/eslint-github-bot/blob/main/docs/commit-message-check.md", - description, - context: "commit-message" - }) - ); - - if (state === "failure") { - await octokit.issues.createComment(context.issue({ - body: commentMessage(errors, payload.pull_request.user.login) - })); - } - + /* + * We care about the default commit message that will appear when the + * PR is merged. If the PR has exactly one commit, this is the commit + * message of that commit. If the PR has more than one commit, this + * is the title of the PR. + */ + const { payload, octokit } = context; + + if (EXCLUDED_REPOSITORY_NAMES.has(payload.repository.name)) { + return; + } + + const allCommits = await octokit.pulls.listCommits(context.pullRequest()); + const messageToCheck = payload.pull_request.title; + const errors = getCommitMessageErrors(messageToCheck); + let description; + let state; + + if (errors.length === 0) { + state = "success"; + description = "PR title follows commit message guidelines"; + + const labels = getCommitMessageLabels(messageToCheck); + + if (labels) { + await context.octokit.issues.addLabels(context.issue({ labels })); + } + } else { + state = "failure"; + description = "PR title doesn't follow commit message guidelines"; + } + + // create status on the last commit + await octokit.repos.createCommitStatus( + context.repo({ + sha: allCommits.data.at(-1).sha, + state, + target_url: + "https://github.com/eslint/eslint-github-bot/blob/main/docs/commit-message-check.md", + description, + context: "commit-message", + }), + ); + + if (state === "failure") { + await octokit.issues.createComment( + context.issue({ + body: commentMessage(errors, payload.pull_request.user.login), + }), + ); + } } /** @@ -153,8 +150,8 @@ async function processCommitMessage(context) { */ module.exports = robot => { - robot.on("pull_request.opened", processCommitMessage); - robot.on("pull_request.reopened", processCommitMessage); - robot.on("pull_request.synchronize", processCommitMessage); - robot.on("pull_request.edited", processCommitMessage); + robot.on("pull_request.opened", processCommitMessage); + robot.on("pull_request.reopened", processCommitMessage); + robot.on("pull_request.synchronize", processCommitMessage); + robot.on("pull_request.edited", processCommitMessage); }; diff --git a/src/plugins/commit-message/util.js b/src/plugins/commit-message/util.js index 8d760da0..607efec3 100644 --- a/src/plugins/commit-message/util.js +++ b/src/plugins/commit-message/util.js @@ -6,15 +6,15 @@ "use strict"; exports.TAG_LABELS = new Map([ - ["feat:", ["feature"]], - ["feat!:", ["feature", "breaking"]], - ["build:", ["build"]], - ["chore:", ["chore"]], - ["docs:", ["documentation"]], - ["fix:", ["bug"]], - ["fix!:", ["bug", "breaking"]], - ["refactor:", ["chore"]], - ["test:", ["chore"]], - ["ci:", ["build"]], - ["perf:", ["chore"]] + ["feat:", ["feature"]], + ["feat!:", ["feature", "breaking"]], + ["build:", ["build"]], + ["chore:", ["chore"]], + ["docs:", ["documentation"]], + ["fix:", ["bug"]], + ["fix!:", ["bug", "breaking"]], + ["refactor:", ["chore"]], + ["test:", ["chore"]], + ["ci:", ["build"]], + ["perf:", ["chore"]], ]); diff --git a/src/plugins/index.js b/src/plugins/index.js index d56b496f..68699c3b 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -13,11 +13,11 @@ */ module.exports = { - autoAssign: require("./auto-assign"), - commitMessage: require("./commit-message"), - issuePrLink: require("./issue-pr-link"), - needsInfo: require("./needs-info"), - recurringIssues: require("./recurring-issues"), - releaseMonitor: require("./release-monitor"), - wip: require("./wip") + autoAssign: require("./auto-assign"), + commitMessage: require("./commit-message"), + issuePrLink: require("./issue-pr-link"), + needsInfo: require("./needs-info"), + recurringIssues: require("./recurring-issues"), + releaseMonitor: require("./release-monitor"), + wip: require("./wip"), }; diff --git a/src/plugins/issue-pr-link/index.js b/src/plugins/issue-pr-link/index.js index 1e204884..495b522b 100644 --- a/src/plugins/issue-pr-link/index.js +++ b/src/plugins/issue-pr-link/index.js @@ -19,7 +19,8 @@ * Regex to find issue references in PR bodies * Matches patterns like: "Fix #123", "Fixes #123", "Closes #123", "Resolves #123", etc. */ -const ISSUE_REFERENCE_REGEX = /\b(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved):?[ \t]+#(?\d+)/giu; +const ISSUE_REFERENCE_REGEX = + /\b(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved):?[ \t]+#(?\d+)/giu; /** * Maximum number of issues to comment on per PR to prevent abuse @@ -33,20 +34,23 @@ const MAX_ISSUES_PER_PR = 3; * @private */ function extractIssueNumbers(body) { - const matches = []; - let match; - - // Reset regex lastIndex to ensure we start from the beginning - ISSUE_REFERENCE_REGEX.lastIndex = 0; - - while ((match = ISSUE_REFERENCE_REGEX.exec(body)) !== null && matches.length < MAX_ISSUES_PER_PR) { - const issueNumber = parseInt(match.groups.issueNumber, 10); - if (!matches.includes(issueNumber)) { - matches.push(issueNumber); - } - } - - return matches; + const matches = []; + let match; + + // Reset regex lastIndex to ensure we start from the beginning + ISSUE_REFERENCE_REGEX.lastIndex = 0; + + while ( + (match = ISSUE_REFERENCE_REGEX.exec(body)) !== null && + matches.length < MAX_ISSUES_PER_PR + ) { + const issueNumber = parseInt(match.groups.issueNumber, 10); + if (!matches.includes(issueNumber)) { + matches.push(issueNumber); + } + } + + return matches; } /** @@ -57,7 +61,7 @@ function extractIssueNumbers(body) { * @private */ function createCommentMessage(prUrl, prAuthor) { - return `👋 Hi! This issue is being addressed in pull request ${prUrl}. Thanks, @${prAuthor}! + return `👋 Hi! This issue is being addressed in pull request ${prUrl}. Thanks, @${prAuthor}! [//]: # (issue-pr-link)`; } @@ -70,15 +74,15 @@ function createCommentMessage(prUrl, prAuthor) { * @private */ async function isIssueOpenAndExists(context, issueNumber) { - try { - const { data: issue } = await context.octokit.issues.get( - context.repo({ issue_number: issueNumber }) - ); - return issue.state === "open"; - } catch { - // Issue doesn't exist or we don't have access - return false; - } + try { + const { data: issue } = await context.octokit.issues.get( + context.repo({ issue_number: issueNumber }), + ); + return issue.state === "open"; + } catch { + // Issue doesn't exist or we don't have access + return false; + } } /** @@ -90,22 +94,23 @@ async function isIssueOpenAndExists(context, issueNumber) { * @private */ async function hasExistingComment(context, issueNumber, prNumber) { - try { - const { data: comments } = await context.octokit.issues.listComments( - context.repo({ issue_number: issueNumber }) - ); - - const botComments = comments.filter(comment => - comment.user.type === "Bot" && - comment.body.includes("[//]: # (issue-pr-link)") && - comment.body.includes(`/pull/${prNumber}`) - ); - - return botComments.length > 0; - } catch { - // If we can't check comments, assume we haven't commented - return false; - } + try { + const { data: comments } = await context.octokit.issues.listComments( + context.repo({ issue_number: issueNumber }), + ); + + const botComments = comments.filter( + comment => + comment.user.type === "Bot" && + comment.body.includes("[//]: # (issue-pr-link)") && + comment.body.includes(`/pull/${prNumber}`), + ); + + return botComments.length > 0; + } catch { + // If we can't check comments, assume we haven't commented + return false; + } } /** @@ -115,49 +120,49 @@ async function hasExistingComment(context, issueNumber, prNumber) { * @private */ async function commentOnReferencedIssues(context) { - const { payload } = context; - const pr = payload.pull_request; - - if (!pr || !pr.body) { - return; - } - - const issueNumbers = extractIssueNumbers(pr.body); - - if (issueNumbers.length === 0) { - return; - } - - const prUrl = pr.html_url; - const prAuthor = pr.user.login; - const prNumber = pr.number; - - // Comment on each referenced issue - for (const issueNumber of issueNumbers) { - try { - // Check if issue exists and is open - if (!(await isIssueOpenAndExists(context, issueNumber))) { - continue; - } - - // Check if we already commented on this issue for this PR - if (await hasExistingComment(context, issueNumber, prNumber)) { - continue; - } - - // Create the comment - await context.octokit.issues.createComment( - context.repo({ - issue_number: issueNumber, - body: createCommentMessage(prUrl, prAuthor) - }) - ); - } catch (error) { - // Log error but continue with other issues - // eslint-disable-next-line no-console -- Logging errors is intentional - console.error(`Failed to comment on issue #${issueNumber}:`, error); - } - } + const { payload } = context; + const pr = payload.pull_request; + + if (!pr || !pr.body) { + return; + } + + const issueNumbers = extractIssueNumbers(pr.body); + + if (issueNumbers.length === 0) { + return; + } + + const prUrl = pr.html_url; + const prAuthor = pr.user.login; + const prNumber = pr.number; + + // Comment on each referenced issue + for (const issueNumber of issueNumbers) { + try { + // Check if issue exists and is open + if (!(await isIssueOpenAndExists(context, issueNumber))) { + continue; + } + + // Check if we already commented on this issue for this PR + if (await hasExistingComment(context, issueNumber, prNumber)) { + continue; + } + + // Create the comment + await context.octokit.issues.createComment( + context.repo({ + issue_number: issueNumber, + body: createCommentMessage(prUrl, prAuthor), + }), + ); + } catch (error) { + // Log error but continue with other issues + // eslint-disable-next-line no-console -- Logging errors is intentional + console.error(`Failed to comment on issue #${issueNumber}:`, error); + } + } } //----------------------------------------------------------------------------- @@ -165,5 +170,8 @@ async function commentOnReferencedIssues(context) { //----------------------------------------------------------------------------- module.exports = robot => { - robot.on(["pull_request.opened", "pull_request.edited"], commentOnReferencedIssues); + robot.on( + ["pull_request.opened", "pull_request.edited"], + commentOnReferencedIssues, + ); }; diff --git a/src/plugins/needs-info/index.js b/src/plugins/needs-info/index.js index fb26540b..0bc36ed7 100644 --- a/src/plugins/needs-info/index.js +++ b/src/plugins/needs-info/index.js @@ -13,7 +13,7 @@ const needInfoLabel = "needs info"; * @private */ function commentMessage() { - return ` + return ` It looks like there wasn't enough information for us to know how to help you, so we're closing the issue. Thanks for your understanding. @@ -29,7 +29,7 @@ Thanks for your understanding. * @private */ function hasNeedInfoLabel(label) { - return label.name === needInfoLabel; + return label.name === needInfoLabel; } /** @@ -39,13 +39,15 @@ function hasNeedInfoLabel(label) { * @private */ async function check(context) { - const { payload, octokit } = context; - - if (payload.issue.labels.some(hasNeedInfoLabel)) { - await octokit.issues.createComment(context.issue({ - body: commentMessage() - })); - } + const { payload, octokit } = context; + + if (payload.issue.labels.some(hasNeedInfoLabel)) { + await octokit.issues.createComment( + context.issue({ + body: commentMessage(), + }), + ); + } } /** @@ -53,5 +55,5 @@ async function check(context) { */ module.exports = robot => { - robot.on("issues.closed", check); + robot.on("issues.closed", check); }; diff --git a/src/plugins/recurring-issues/index.js b/src/plugins/recurring-issues/index.js index 16568724..90543d55 100644 --- a/src/plugins/recurring-issues/index.js +++ b/src/plugins/recurring-issues/index.js @@ -28,7 +28,7 @@ const moment = require("moment-timezone"); * @returns {Promise} The text of the issue */ async function getReleaseIssueBody(releaseDate) { - return ` + return ` The next scheduled release will occur on ${releaseDate.format("dddd, MMMM Do, YYYY")}. @@ -94,25 +94,36 @@ Resources: * @returns {{login: string, name: (string|null)}} A list of team member login names and full names */ async function getTeamMembers({ octokit, organizationName, teamName }) { - - /* - * NOTE: This will fail if the organization contains more than 100 teams. This isn't - * close to being a problem right now, so it hasn't been worth figuring out a good - * way to paginate yet, but that would be a good enhancement in the future. - */ - const teams = await octokit.teams.list({ org: organizationName, per_page: 100 }).then(res => res.data); - const desiredTeam = teams.find(team => team.slug === teamName); - - if (!desiredTeam) { - throw new Error(`No team with name ${teamName} found`); - } - - const teamMembers = await octokit.teams.listMembersInOrg({ team_slug: desiredTeam.slug, org: organizationName, per_page: 100 }).then(res => res.data); - - return Promise.all(teamMembers.map(async member => ({ - login: member.login, - name: await octokit.users.getByUsername({ username: member.login }).then(res => res.data.name) - }))); + /* + * NOTE: This will fail if the organization contains more than 100 teams. This isn't + * close to being a problem right now, so it hasn't been worth figuring out a good + * way to paginate yet, but that would be a good enhancement in the future. + */ + const teams = await octokit.teams + .list({ org: organizationName, per_page: 100 }) + .then(res => res.data); + const desiredTeam = teams.find(team => team.slug === teamName); + + if (!desiredTeam) { + throw new Error(`No team with name ${teamName} found`); + } + + const teamMembers = await octokit.teams + .listMembersInOrg({ + team_slug: desiredTeam.slug, + org: organizationName, + per_page: 100, + }) + .then(res => res.data); + + return Promise.all( + teamMembers.map(async member => ({ + login: member.login, + name: await octokit.users + .getByUsername({ username: member.login }) + .then(res => res.data.name), + })), + ); } /** @@ -121,7 +132,9 @@ async function getTeamMembers({ octokit, organizationName, teamName }) { * @returns {string} Markdown text containing a bulleted list of names */ function formatTeamMembers(teamMembers) { - return teamMembers.map(({ login, name }) => `- ${name || login} (@${login}) - TSC`).join("\n"); + return teamMembers + .map(({ login, name }) => `- ${name || login} (@${login}) - TSC`) + .join("\n"); } /** @@ -133,10 +146,15 @@ function formatTeamMembers(teamMembers) { * @param {string} options.tscTeamName The name of the TSC team * @returns {Promise} The text of the issue */ -async function getTscMeetingIssueBody({ meetingDate, octokit, organizationName, tscTeamName }) { - const timeFormatString = "ddd DD-MMM-YYYY HH:mm"; +async function getTscMeetingIssueBody({ + meetingDate, + octokit, + organizationName, + tscTeamName, +}) { + const timeFormatString = "ddd DD-MMM-YYYY HH:mm"; - return ` + return ` # Time @@ -181,15 +199,22 @@ Anyone is welcome to attend the meeting as observers. We ask that you refrain fr * @param {number} issueInfo.issue_number The issue number on GitHub * @returns {Promise} A Promise that fulfills with `true` if the issue was closed multiple times */ -async function issueWasClosedMultipleTimes(octokit, { owner, repo, issue_number }) { - const issueEvents = await octokit.issues.listEvents({ - owner, - repo, - issue_number, - per_page: 100 - }).then(res => res.data); - - return issueEvents.filter(eventObj => eventObj.event === "closed").length > 1; +async function issueWasClosedMultipleTimes( + octokit, + { owner, repo, issue_number }, +) { + const issueEvents = await octokit.issues + .listEvents({ + owner, + repo, + issue_number, + per_page: 100, + }) + .then(res => res.data); + + return ( + issueEvents.filter(eventObj => eventObj.event === "closed").length > 1 + ); } /* eslint-enable camelcase -- issue_number is part of the GitHub API response */ @@ -211,92 +236,106 @@ async function issueWasClosedMultipleTimes(octokit, { owner, repo, issue_number * for an object with `title` and `body` properties for the new issue. * @returns {function(probot.Context): Promise} A Probot event listener */ -function createIssueHandler({ labelTrigger, newLabels, shouldCreateNewIssue, getNewIssueInfo }) { - - /** - * A Probot event listener that creates a new issue when an old issue is closed. - * @param {ProbotContext} context A Probot context object - * @returns {Promise} A Promise that fulfills when the new issue is created. - */ - return async context => { - const { title: oldTitle, body: oldBody, labels: oldLabels } = context.payload.issue; - - // If the issue does not have the correct label, skip it. - if (!oldLabels.some(label => label.name === labelTrigger)) { - return; - } - - // If the issue was previously closed and then reopened, skip it. - if (await issueWasClosedMultipleTimes(context.octokit, context.issue())) { - return; - } - - if (!await shouldCreateNewIssue({ title: oldTitle, body: oldBody })) { - return; - } - - const { title: newTitle, body: newBody } = await getNewIssueInfo({ - title: oldTitle, - body: oldBody, - octokit: context.octokit, - organizationName: context.repo().owner - }); - - // Create a new issue. - await context.octokit.issues.create( - context.repo({ - title: newTitle, - body: newBody, - labels: newLabels - }) - ); - }; +function createIssueHandler({ + labelTrigger, + newLabels, + shouldCreateNewIssue, + getNewIssueInfo, +}) { + /** + * A Probot event listener that creates a new issue when an old issue is closed. + * @param {ProbotContext} context A Probot context object + * @returns {Promise} A Promise that fulfills when the new issue is created. + */ + return async context => { + const { + title: oldTitle, + body: oldBody, + labels: oldLabels, + } = context.payload.issue; + + // If the issue does not have the correct label, skip it. + if (!oldLabels.some(label => label.name === labelTrigger)) { + return; + } + + // If the issue was previously closed and then reopened, skip it. + if ( + await issueWasClosedMultipleTimes(context.octokit, context.issue()) + ) { + return; + } + + if (!(await shouldCreateNewIssue({ title: oldTitle, body: oldBody }))) { + return; + } + + const { title: newTitle, body: newBody } = await getNewIssueInfo({ + title: oldTitle, + body: oldBody, + octokit: context.octokit, + organizationName: context.repo().owner, + }); + + // Create a new issue. + await context.octokit.issues.create( + context.repo({ + title: newTitle, + body: newBody, + labels: newLabels, + }), + ); + }; } const RELEASE_ISSUE_TITLE_FORMAT = "[Scheduled release for ]MMMM Do, YYYY"; const releaseIssueHandler = createIssueHandler({ - labelTrigger: "release", - newLabels: ["release", "tsc agenda", "triage:no"], - async shouldCreateNewIssue({ title }) { - return moment.utc(title, RELEASE_ISSUE_TITLE_FORMAT, true).isValid(); - }, - async getNewIssueInfo({ title }) { - const oldReleaseDate = moment.utc(title, RELEASE_ISSUE_TITLE_FORMAT, true); - const newReleaseDate = oldReleaseDate.clone().add({ weeks: 2 }); - - return { - title: newReleaseDate.format(RELEASE_ISSUE_TITLE_FORMAT), - body: await getReleaseIssueBody(newReleaseDate) - }; - } + labelTrigger: "release", + newLabels: ["release", "tsc agenda", "triage:no"], + async shouldCreateNewIssue({ title }) { + return moment.utc(title, RELEASE_ISSUE_TITLE_FORMAT, true).isValid(); + }, + async getNewIssueInfo({ title }) { + const oldReleaseDate = moment.utc( + title, + RELEASE_ISSUE_TITLE_FORMAT, + true, + ); + const newReleaseDate = oldReleaseDate.clone().add({ weeks: 2 }); + + return { + title: newReleaseDate.format(RELEASE_ISSUE_TITLE_FORMAT), + body: await getReleaseIssueBody(newReleaseDate), + }; + }, }); const TSC_MEETING_TITLE_FORMAT = "[TSC meeting ]DD-MMMM-YYYY"; const tscMeetingIssueHandler = createIssueHandler({ - labelTrigger: "tsc meeting", - newLabels: ["tsc meeting", "triage:no"], - async shouldCreateNewIssue({ title }) { - return moment.utc(title, TSC_MEETING_TITLE_FORMAT, true).isValid(); - }, - async getNewIssueInfo({ title, octokit, organizationName }) { - const meetingDate = moment.tz(title, TSC_MEETING_TITLE_FORMAT, "America/New_York") - .hour(16) - .add({ weeks: 2 }); - - - const newTitle = meetingDate.format(TSC_MEETING_TITLE_FORMAT); - const newBody = await getTscMeetingIssueBody({ - meetingDate, - octokit, - organizationName, - tscTeamName: "eslint-tsc" - }); - - return { title: newTitle, body: newBody }; - } + labelTrigger: "tsc meeting", + newLabels: ["tsc meeting", "triage:no"], + async shouldCreateNewIssue({ title }) { + return moment.utc(title, TSC_MEETING_TITLE_FORMAT, true).isValid(); + }, + async getNewIssueInfo({ title, octokit, organizationName }) { + const meetingDate = moment + .tz(title, TSC_MEETING_TITLE_FORMAT, "America/New_York") + .hour(16) + .add({ weeks: 2 }); + + const newTitle = meetingDate.format(TSC_MEETING_TITLE_FORMAT); + const newBody = await getTscMeetingIssueBody({ + meetingDate, + octokit, + organizationName, + tscTeamName: "eslint-tsc", + }); + + return { title: newTitle, body: newBody }; + }, }); module.exports = robot => { - robot.on("issues.closed", releaseIssueHandler); - robot.on("issues.closed", tscMeetingIssueHandler); + robot.on("issues.closed", releaseIssueHandler); + robot.on("issues.closed", tscMeetingIssueHandler); }; diff --git a/src/plugins/release-monitor/index.js b/src/plugins/release-monitor/index.js index 859992fc..9de75cb1 100644 --- a/src/plugins/release-monitor/index.js +++ b/src/plugins/release-monitor/index.js @@ -26,7 +26,7 @@ const RELEASE_LABEL = "release"; * @private */ function isMessageValidForPatchRelease(message) { - return PATCH_COMMIT_MESSAGE_REGEX.test(message); + return PATCH_COMMIT_MESSAGE_REGEX.test(message); } /** @@ -36,7 +36,7 @@ function isMessageValidForPatchRelease(message) { * @private */ function pluckLatestCommitSha(allCommits) { - return allCommits.at(-1).sha; + return allCommits.at(-1).sha; } /** @@ -46,12 +46,12 @@ function pluckLatestCommitSha(allCommits) { * @private */ function getAllOpenPRs(context) { - return context.octokit.paginate( - context.octokit.pulls.list, - context.repo({ - state: "open" - }) - ); + return context.octokit.paginate( + context.octokit.pulls.list, + context.repo({ + state: "open", + }), + ); } /** @@ -66,15 +66,15 @@ function getAllOpenPRs(context) { * @private */ function createStatusOnPR({ context, state, sha, description, targetUrl }) { - return context.octokit.repos.createCommitStatus( - context.repo({ - sha, - state, - target_url: targetUrl || "", - description, - context: "release-monitor" - }) - ); + return context.octokit.repos.createCommitStatus( + context.repo({ + sha, + state, + target_url: targetUrl || "", + description, + context: "release-monitor", + }), + ); } /** @@ -86,11 +86,11 @@ function createStatusOnPR({ context, state, sha, description, targetUrl }) { * @private */ async function getAllCommitsForPR({ context, pr }) { - const commits = await context.octokit.pulls.listCommits( - context.repo({ pull_number: pr.number }) - ); + const commits = await context.octokit.pulls.listCommits( + context.repo({ pull_number: pr.number }), + ); - return commits.data; + return commits.data; } /** @@ -107,34 +107,38 @@ async function getAllCommitsForPR({ context, pr }) { * release issue. Otherwise, this is null. * @returns {Promise} A Promise that fulfills when the status check has been created */ -async function createAppropriateStatusForPR({ context, pr, pendingReleaseIssueUrl }) { - const allCommits = await getAllCommitsForPR({ context, pr }); - const sha = pluckLatestCommitSha(allCommits); - - if (pendingReleaseIssueUrl === null) { - await createStatusOnPR({ - context, - sha, - state: "success", - description: "No patch release is pending" - }); - } else if (isMessageValidForPatchRelease(pr.title)) { - await createStatusOnPR({ - context, - sha, - state: "success", - description: "This change is semver-patch", - targetUrl: pendingReleaseIssueUrl - }); - } else { - await createStatusOnPR({ - context, - sha, - state: "pending", - description: "A patch release is pending", - targetUrl: pendingReleaseIssueUrl - }); - } +async function createAppropriateStatusForPR({ + context, + pr, + pendingReleaseIssueUrl, +}) { + const allCommits = await getAllCommitsForPR({ context, pr }); + const sha = pluckLatestCommitSha(allCommits); + + if (pendingReleaseIssueUrl === null) { + await createStatusOnPR({ + context, + sha, + state: "success", + description: "No patch release is pending", + }); + } else if (isMessageValidForPatchRelease(pr.title)) { + await createStatusOnPR({ + context, + sha, + state: "success", + description: "This change is semver-patch", + targetUrl: pendingReleaseIssueUrl, + }); + } else { + await createStatusOnPR({ + context, + sha, + state: "pending", + description: "A patch release is pending", + targetUrl: pendingReleaseIssueUrl, + }); + } } /** @@ -146,14 +150,17 @@ async function createAppropriateStatusForPR({ context, pr, pendingReleaseIssueUr * @private */ async function createStatusOnAllPRs({ context, pendingReleaseIssueUrl }) { - const allOpenPrs = await getAllOpenPRs(context); - - return Promise.all(allOpenPrs.map(pr => - createAppropriateStatusForPR({ - context, - pr, - pendingReleaseIssueUrl - }))); + const allOpenPrs = await getAllOpenPRs(context); + + return Promise.all( + allOpenPrs.map(pr => + createAppropriateStatusForPR({ + context, + pr, + pendingReleaseIssueUrl, + }), + ), + ); } /** @@ -163,7 +170,7 @@ async function createStatusOnAllPRs({ context, pendingReleaseIssueUrl }) { * @private */ function hasReleaseLabel(labels) { - return labels.some(({ name }) => name === RELEASE_LABEL); + return labels.some(({ name }) => name === RELEASE_LABEL); } /** @@ -174,7 +181,7 @@ function hasReleaseLabel(labels) { * @private */ function isPostReleaseLabel({ name }) { - return name === POST_RELEASE_LABEL; + return name === POST_RELEASE_LABEL; } /** @@ -184,14 +191,16 @@ function isPostReleaseLabel({ name }) { * @private */ async function issueLabeledHandler(context) { - - // check if the label is post-release and the same issue has release label - if (isPostReleaseLabel(context.payload.label) && hasReleaseLabel(context.payload.issue.labels)) { - await createStatusOnAllPRs({ - context, - pendingReleaseIssueUrl: context.payload.issue.html_url - }); - } + // check if the label is post-release and the same issue has release label + if ( + isPostReleaseLabel(context.payload.label) && + hasReleaseLabel(context.payload.issue.labels) + ) { + await createStatusOnAllPRs({ + context, + pendingReleaseIssueUrl: context.payload.issue.html_url, + }); + } } /** @@ -201,14 +210,13 @@ async function issueLabeledHandler(context) { * @private */ async function issueCloseHandler(context) { - - // check if the closed issue is a release issue - if (hasReleaseLabel(context.payload.issue.labels)) { - await createStatusOnAllPRs({ - context, - pendingReleaseIssueUrl: null - }); - } + // check if the closed issue is a release issue + if (hasReleaseLabel(context.payload.issue.labels)) { + await createStatusOnAllPRs({ + context, + pendingReleaseIssueUrl: null, + }); + } } /** @@ -218,35 +226,36 @@ async function issueCloseHandler(context) { * @private */ async function prOpenHandler(context) { + /** + * check if the release issue has the label for no semver minor merge please + * false: add success status to pr + * true: add failure message if its not a fix or doc pr else success + */ + const { data: releaseIssues } = await context.octokit.issues.listForRepo( + context.repo({ + labels: `${RELEASE_LABEL},${POST_RELEASE_LABEL}`, + }), + ); - /** - * check if the release issue has the label for no semver minor merge please - * false: add success status to pr - * true: add failure message if its not a fix or doc pr else success - */ - const { data: releaseIssues } = await context.octokit.issues.listForRepo( - context.repo({ - labels: `${RELEASE_LABEL},${POST_RELEASE_LABEL}` - }) - ); - - await createAppropriateStatusForPR({ - context, - pr: context.payload.pull_request, - pendingReleaseIssueUrl: releaseIssues.length ? releaseIssues[0].html_url : null - }); + await createAppropriateStatusForPR({ + context, + pr: context.payload.pull_request, + pendingReleaseIssueUrl: releaseIssues.length + ? releaseIssues[0].html_url + : null, + }); } module.exports = robot => { - robot.on("issues.labeled", issueLabeledHandler); - robot.on("issues.closed", issueCloseHandler); - robot.on( - [ - "pull_request.opened", - "pull_request.reopened", - "pull_request.synchronize", - "pull_request.edited" - ], - prOpenHandler - ); + robot.on("issues.labeled", issueLabeledHandler); + robot.on("issues.closed", issueCloseHandler); + robot.on( + [ + "pull_request.opened", + "pull_request.reopened", + "pull_request.synchronize", + "pull_request.edited", + ], + prOpenHandler, + ); }; diff --git a/src/plugins/wip/index.js b/src/plugins/wip/index.js index ec1d16ad..05e140ab 100644 --- a/src/plugins/wip/index.js +++ b/src/plugins/wip/index.js @@ -30,15 +30,15 @@ const DO_NOT_MERGE_LABEL = "do not merge"; * @private */ function createStatusOnPR({ context, state, sha, description, targetUrl }) { - return context.octokit.repos.createCommitStatus( - context.repo({ - sha, - state, - target_url: targetUrl || "", - description, - context: "wip" - }) - ); + return context.octokit.repos.createCommitStatus( + context.repo({ + sha, + state, + target_url: targetUrl || "", + description, + context: "wip", + }), + ); } /** @@ -48,12 +48,12 @@ function createStatusOnPR({ context, state, sha, description, targetUrl }) { * @returns {Promise} A Promise that will fulfill when the status check is created */ function createPendingWipStatusOnPR(context, sha) { - return createStatusOnPR({ - context, - sha, - state: "pending", - description: "This PR appears to be a work in progress" - }); + return createStatusOnPR({ + context, + sha, + state: "pending", + description: "This PR appears to be a work in progress", + }); } /** @@ -63,12 +63,12 @@ function createPendingWipStatusOnPR(context, sha) { * @returns {Promise} A Promise that will fulfill when the status check is created */ function createSuccessWipStatusOnPR(context, sha) { - return createStatusOnPR({ - context, - sha, - state: "success", - description: "This PR is no longer a work in progress" - }); + return createStatusOnPR({ + context, + sha, + state: "success", + description: "This PR is no longer a work in progress", + }); } /** @@ -81,19 +81,22 @@ function createSuccessWipStatusOnPR(context, sha) { * is needed. */ async function maybeResolveWipStatusOnPR(context, sha) { - const repoAndRef = context.repo({ - ref: sha - }); - - const { octokit } = context; - const statuses = await octokit.paginate(octokit.repos.getCombinedStatusForRef, repoAndRef); - const statusCheckExists = statuses.some(status => status.context === "wip"); - - if (statusCheckExists) { - return createSuccessWipStatusOnPR(context, sha); - } - - return null; + const repoAndRef = context.repo({ + ref: sha, + }); + + const { octokit } = context; + const statuses = await octokit.paginate( + octokit.repos.getCombinedStatusForRef, + repoAndRef, + ); + const statusCheckExists = statuses.some(status => status.context === "wip"); + + if (statusCheckExists) { + return createSuccessWipStatusOnPR(context, sha); + } + + return null; } /** @@ -105,11 +108,11 @@ async function maybeResolveWipStatusOnPR(context, sha) { * @private */ async function getAllCommitsForPR({ context, pr }) { - const { data: commitList } = await context.octokit.pulls.listCommits( - context.repo({ pull_number: pr.number }) - ); + const { data: commitList } = await context.octokit.pulls.listCommits( + context.repo({ pull_number: pr.number }), + ); - return commitList; + return commitList; } /** @@ -119,7 +122,7 @@ async function getAllCommitsForPR({ context, pr }) { * @private */ function hasDoNotMergeLabel(labels) { - return labels.some(({ name }) => name === DO_NOT_MERGE_LABEL); + return labels.some(({ name }) => name === DO_NOT_MERGE_LABEL); } /** @@ -129,7 +132,7 @@ function hasDoNotMergeLabel(labels) { * @private */ function pluckLatestCommitSha(allCommits) { - return allCommits.at(-1).sha; + return allCommits.at(-1).sha; } /** @@ -139,7 +142,7 @@ function pluckLatestCommitSha(allCommits) { * @private */ function prHasWipTitle(pr) { - return WIP_IN_TITLE_REGEX.test(pr.title); + return WIP_IN_TITLE_REGEX.test(pr.title); } /** @@ -150,22 +153,22 @@ function prHasWipTitle(pr) { * @private */ async function prChangedHandler(context) { - const pr = context.payload.pull_request; + const pr = context.payload.pull_request; - const allCommits = await getAllCommitsForPR({ - context, - pr - }); + const allCommits = await getAllCommitsForPR({ + context, + pr, + }); - const sha = pluckLatestCommitSha(allCommits); + const sha = pluckLatestCommitSha(allCommits); - const isWip = prHasWipTitle(pr) || hasDoNotMergeLabel(pr.labels); + const isWip = prHasWipTitle(pr) || hasDoNotMergeLabel(pr.labels); - if (isWip) { - return createPendingWipStatusOnPR(context, sha); - } + if (isWip) { + return createPendingWipStatusOnPR(context, sha); + } - return maybeResolveWipStatusOnPR(context, sha); + return maybeResolveWipStatusOnPR(context, sha); } //----------------------------------------------------------------------------- @@ -173,15 +176,15 @@ async function prChangedHandler(context) { //----------------------------------------------------------------------------- module.exports = robot => { - robot.on( - [ - "pull_request.opened", - "pull_request.reopened", - "pull_request.edited", - "pull_request.labeled", - "pull_request.unlabeled", - "pull_request.synchronize" - ], - prChangedHandler - ); + robot.on( + [ + "pull_request.opened", + "pull_request.reopened", + "pull_request.edited", + "pull_request.labeled", + "pull_request.unlabeled", + "pull_request.synchronize", + ], + prChangedHandler, + ); }; diff --git a/tests/__mocks__/probot-scheduler.js b/tests/__mocks__/probot-scheduler.js index cd63eac3..432e18b5 100644 --- a/tests/__mocks__/probot-scheduler.js +++ b/tests/__mocks__/probot-scheduler.js @@ -1,2 +1,2 @@ "use strict"; -module.exports = () => { }; +module.exports = () => {}; diff --git a/tests/plugins/auto-assign/index.test.js b/tests/plugins/auto-assign/index.test.js index 88f6f29d..429bcda1 100644 --- a/tests/plugins/auto-assign/index.test.js +++ b/tests/plugins/auto-assign/index.test.js @@ -16,7 +16,8 @@ const autoAssign = require("../../../src/plugins/auto-assign"); // Helpers //----------------------------------------------------------------------------- -const API_URL = "https://api.github.com/repos/test/repo-test/issues/1/assignees"; +const API_URL = + "https://api.github.com/repos/test/repo-test/issues/1/assignees"; /** * Returns an array of strings representing the issue body. @@ -24,12 +25,12 @@ const API_URL = "https://api.github.com/repos/test/repo-test/issues/1/assignees" * @returns {string[]} Array of strings representing the issue body. */ function issueBodies(checkMark) { - return [ - `- [${checkMark}] I am willing to submit a pull request for this issue.`, - `- [${checkMark}] I am willing to submit a pull request for this change.`, - `- [${checkMark}] I am willing to submit a pull request to implement this rule.`, - `- [${checkMark}] I am willing to submit a pull request to implement this change.` - ] + return [ + `- [${checkMark}] I am willing to submit a pull request for this issue.`, + `- [${checkMark}] I am willing to submit a pull request for this change.`, + `- [${checkMark}] I am willing to submit a pull request to implement this rule.`, + `- [${checkMark}] I am willing to submit a pull request to implement this change.`, + ]; } //----------------------------------------------------------------------------- @@ -37,90 +38,87 @@ function issueBodies(checkMark) { //----------------------------------------------------------------------------- describe("auto-assign", () => { - let bot = null; - - beforeEach(() => { - bot = new Probot({ - appId: 1, - githubToken: "test", - Octokit: ProbotOctokit.defaults(instanceOptions => ({ - ...instanceOptions, - throttle: { enabled: false }, - retry: { enabled: false } - })) - }); - - autoAssign(bot); - - fetchMock.mockGlobal().post( - API_URL, - { status: 200 } - ); - }); - - afterEach(() => { - fetchMock.unmockGlobal(); - fetchMock.removeRoutes(); - fetchMock.clearHistory(); - }); - - describe("issue opened", () => { - test("assigns issue to author when they indicate willingness to submit PR", async () => { - for (const body of issueBodies("x")) { - await bot.receive({ - name: "issues", - payload: { - action: "opened", - installation: { - id: 1 - }, - issue: { - number: 1, - body, - user: { - login: "user-a" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(API_URL)).toBeTruthy(); - } - }); - - test("does not assign issue when author does not indicate willingness to submit PR", async () => { - for (const body of issueBodies("")) { - await bot.receive({ - name: "issues", - payload: { - action: "opened", - installation: { - id: 1 - }, - issue: { - number: 1, - body, - user: { - login: "user-a" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(API_URL)).toBe(false); - } - }); - }); + let bot = null; + + beforeEach(() => { + bot = new Probot({ + appId: 1, + githubToken: "test", + Octokit: ProbotOctokit.defaults(instanceOptions => ({ + ...instanceOptions, + throttle: { enabled: false }, + retry: { enabled: false }, + })), + }); + + autoAssign(bot); + + fetchMock.mockGlobal().post(API_URL, { status: 200 }); + }); + + afterEach(() => { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + }); + + describe("issue opened", () => { + test("assigns issue to author when they indicate willingness to submit PR", async () => { + for (const body of issueBodies("x")) { + await bot.receive({ + name: "issues", + payload: { + action: "opened", + installation: { + id: 1, + }, + issue: { + number: 1, + body, + user: { + login: "user-a", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called(API_URL)).toBeTruthy(); + } + }); + + test("does not assign issue when author does not indicate willingness to submit PR", async () => { + for (const body of issueBodies("")) { + await bot.receive({ + name: "issues", + payload: { + action: "opened", + installation: { + id: 1, + }, + issue: { + number: 1, + body, + user: { + login: "user-a", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called(API_URL)).toBe(false); + } + }); + }); }); diff --git a/tests/plugins/commit-message/index.test.js b/tests/plugins/commit-message/index.test.js index ea307f58..8a4adf90 100644 --- a/tests/plugins/commit-message/index.test.js +++ b/tests/plugins/commit-message/index.test.js @@ -68,14 +68,16 @@ const API_ROOT = "https://api.github.com"; * @returns {void} */ function mockSingleCommitWithMessage(message) { - fetchMock.mockGlobal().get(`${API_ROOT}/repos/test/repo-test/pulls/1/commits`, [ - { - commit: { - message - }, - sha: "first-sha" - } - ]); + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/pulls/1/commits`, [ + { + commit: { + message, + }, + sha: "first-sha", + }, + ]); } /** @@ -84,11 +86,14 @@ function mockSingleCommitWithMessage(message) { * @returns {void} */ function mockLabels(labels) { - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/issues/1/labels`, - body: { labels }, - matchPartialBody: true - }, 200); + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/issues/1/labels`, + body: { labels }, + matchPartialBody: true, + }, + 200, + ); } /** @@ -96,20 +101,22 @@ function mockLabels(labels) { * @returns {void} */ function mockMultipleCommits() { - fetchMock.mockGlobal().get(`${API_ROOT}/repos/test/repo-test/pulls/1/commits`, [ - { - commit: { - message: "foo" - }, - sha: "first-sha" - }, - { - commit: { - message: "bar" - }, - sha: "second-sha" - } - ]); + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/pulls/1/commits`, [ + { + commit: { + message: "foo", + }, + sha: "first-sha", + }, + { + commit: { + message: "bar", + }, + sha: "second-sha", + }, + ]); } /** @@ -119,444 +126,656 @@ function mockMultipleCommits() { * @returns {Promise} A Promise that fulfills when the action is complete */ function emitBotEvent(bot, payload = {}) { - return bot.receive({ - name: "pull_request", - payload: Object.assign({ - installation: { - id: 1 - }, - pull_request: { - number: 1, - user: { - login: "user-a" - } - }, - sender: { - login: "user-a" - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - }, payload) - }); + return bot.receive({ + name: "pull_request", + payload: Object.assign( + { + installation: { + id: 1, + }, + pull_request: { + number: 1, + user: { + login: "user-a", + }, + }, + sender: { + login: "user-a", + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + payload, + ), + }); } describe("commit-message", () => { - let bot = null; - - beforeAll(() => { - bot = new Probot({ - - appId: 1, - githubToken: "test", - - Octokit: ProbotOctokit.defaults(instanceOptions => ({ - ...instanceOptions, - throttle: { enabled: false }, - retry: { enabled: false } - })) - }); - commitMessage(bot); - }); - - afterEach(() => { - fetchMock.unmockGlobal(); - fetchMock.removeRoutes(); - fetchMock.clearHistory(); - }); - - ["opened", "reopened", "synchronize", "edited"].forEach(action => { - describe(`pull request ${action}`, () => { - test("Posts failure status if PR title is not correct", async () => { - mockSingleCommitWithMessage("non standard commit message"); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "failure" - }, - matchPartialBody: true - }, 201); - - fetchMock.mockGlobal().post(({ args: [url, opts] }) => { - if (url !== `${API_ROOT}/repos/test/repo-test/issues/1/comments`) { - return false; - } - const body = JSON.parse(opts.body).body; - - expect(body).toMatchSnapshot(); - - return true; - }, 200); - - await emitBotEvent(bot, { - action, - pull_request: { - number: 1, - title: "non standard commit message", - user: { login: "user-a" } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/first-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/comments`)).toBeTruthy(); - }); - - test("Posts failure status if PR title is not correct even when the first commit message is correct", async () => { - mockSingleCommitWithMessage("feat: standard commit message"); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "failure" - }, - matchPartialBody: true - }, 201); - - fetchMock.mockGlobal().post(({ args: [url, opts] }) => { - if (url !== `${API_ROOT}/repos/test/repo-test/issues/1/comments`) { - return false; - } - const body = JSON.parse(opts.body).body; - - expect(body).toMatchSnapshot(); - - return true; - }, 200); - - await emitBotEvent(bot, { - action, - pull_request: { - number: 1, - title: "non standard commit message", - user: { login: "user-a" } - } - }); - }); - - test("Posts success status if PR title is correct", async () => { - mockSingleCommitWithMessage("feat: standard commit message"); - mockLabels(["feature"]); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "success" - }, - matchPartialBody: true - }, 201); - - await emitBotEvent(bot, { - action, - pull_request: { - number: 1, - title: "feat: standard commit message", - user: { login: "user-a" } - } - }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/first-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/labels`)).toBeTruthy(); - }); - - test("Posts success status if PR title is correct even when the first commit message is not correct", async () => { - mockSingleCommitWithMessage("non standard commit message"); - mockLabels(["feature"]); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "success" - }, - matchPartialBody: true - }, 201); - - await emitBotEvent(bot, { - action, - pull_request: { - number: 1, - title: "feat: standard commit message", - user: { login: "user-a" } - } - }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/first-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/labels`)).toBeTruthy(); - }); - - test("Posts success status if PR title begins with `Revert`", async () => { - mockSingleCommitWithMessage("Revert \"chore: add test for commit tag Revert\""); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "success" - }, - matchPartialBody: true - }, 201); - - await emitBotEvent(bot, { - action, - pull_request: { - number: 1, - title: "Revert \"chore: add test for commit tag Revert\"", - user: { login: "user-a" } - } - }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/first-sha`)).toBeTruthy(); - }); - - test("Posts failure status if the PR title is longer than 72 chars and don't set labels", async () => { - mockSingleCommitWithMessage("feat!: standard commit message very very very long message and its beyond 72"); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "failure" - }, - matchPartialBody: true - }, 201); - - fetchMock.mockGlobal().post(({ args: [url, opts] }) => { - if (url !== `${API_ROOT}/repos/test/repo-test/issues/1/comments`) { - return false; - } - const body = JSON.parse(opts.body).body; - - expect(body).toMatchSnapshot(); - - return true; - }, 200); - - await emitBotEvent(bot, { - action, - pull_request: { - number: 1, - title: "feat!: standard commit message very very very long message and its beyond 72", - user: { login: "user-a" } - } - }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/first-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/comments`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/labels`)).toBeFalsy(); - }); - - test("Posts success status if there are multiple commit messages and the title is valid", async () => { - mockMultipleCommits(); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, - body: { - state: "success" - }, - matchPartialBody: true - }, 201); - - mockLabels(["feature"]); - - await emitBotEvent(bot, { action, pull_request: { number: 1, title: "feat: foo" } }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/second-sha`)).toBeTruthy(); - }); - - test("Posts failure status if there are multiple commit messages and the title is invalid", async () => { - mockMultipleCommits(); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, - body: { - state: "failure" - }, - matchPartialBody: true - }, 201); - - fetchMock.mockGlobal().post(({ args: [url, opts] }) => { - if (url !== `${API_ROOT}/repos/test/repo-test/issues/1/comments`) { - return false; - } - const body = JSON.parse(opts.body).body; - - expect(body).toMatchSnapshot(); - - return true; - }, 200); - - await emitBotEvent(bot, { action, pull_request: { number: 1, title: "foo", user: { login: "user-a" } } }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/second-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/comments`)).toBeTruthy(); - }); - - test("Posts failure status if there are multiple commit messages and the title is too long", async () => { - mockMultipleCommits(); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, - body: { - state: "failure" - }, - matchPartialBody: true - }, 201); - - fetchMock.mockGlobal().post(({ args: [url, opts] }) => { - if (url !== `${API_ROOT}/repos/test/repo-test/issues/1/comments`) { - return false; - } - const body = JSON.parse(opts.body).body; - - expect(body).toMatchSnapshot(); - - return true; - }, 200); - - await emitBotEvent(bot, { action, pull_request: { number: 1, title: `feat: ${"A".repeat(72)}`, user: { login: "user-a" } } }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/second-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/comments`)).toBeTruthy(); - }); - - // Tests for invalid or malformed tag prefixes - [ - ": ", - "Foo: ", - "Revert: ", - "Neww: ", - "nNew: ", - " New: ", - "new: ", - "New:", - "New : ", - "New ", - "feat" - ].forEach(prefix => { - const message = `${prefix}foo`; - - test(`Posts failure status if the PR title has invalid tag prefix: "${prefix}"`, async () => { - mockSingleCommitWithMessage(message); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "failure" - }, - matchPartialBody: true - }, 201); - - fetchMock.mockGlobal().post(({ args: [url, opts] }) => { - if (url !== `${API_ROOT}/repos/test/repo-test/issues/1/comments`) { - return false; - } - const body = JSON.parse(opts.body).body; - - expect(body).toMatchSnapshot(); - - return true; - }, 200); - - await emitBotEvent(bot, { action, pull_request: { number: 1, title: message, user: { login: "user-a" } } }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/first-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/comments`)).toBeTruthy(); - }); - - test(`Posts failure status if PR with multiple commits has invalid tag prefix in the title: "${prefix}"`, async () => { - mockMultipleCommits(); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, - body: { - state: "failure" - }, - matchPartialBody: true - }, 201); - - fetchMock.mockGlobal().post(({ args: [url, opts] }) => { - if (url !== `${API_ROOT}/repos/test/repo-test/issues/1/comments`) { - return false; - } - const body = JSON.parse(opts.body).body; - - expect(body).toMatchSnapshot(); - - return true; - }, 200); - - await emitBotEvent(bot, { action, pull_request: { number: 1, title: message, user: { login: "user-a" } } }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/second-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/comments`)).toBeTruthy(); - }); - }); - - // Tests for valid tag prefixes - TAG_LABELS.forEach((labels, prefix) => { - const message = `${prefix} foo`; - - test(`Posts success status if the PR title has valid tag prefix: "${prefix}"`, async () => { - mockSingleCommitWithMessage(message); - - mockLabels(labels); - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "success" - }, - matchPartialBody: true - }, 201); - - await emitBotEvent(bot, { action, pull_request: { number: 1, title: message, user: { login: "user-a" } } }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/first-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/labels`)).toBeTruthy(); - }); - - test(`Posts success status if PR with multiple commits has valid tag prefix in the title: "${prefix}"`, async () => { - mockMultipleCommits(); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, - body: { - state: "success" - }, - matchPartialBody: true - }, 201); - - mockLabels(labels); - - await emitBotEvent(bot, { action, pull_request: { number: 1, title: message } }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/second-sha`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/labels`)).toBeTruthy(); - }); - }); - - test("Does not post a status if the repository is excluded", async () => { - await emitBotEvent(bot, { - action: "opened", - repository: { - name: "tsc-meetings", - owner: { - login: "test" - } - } - }); - }); - - // Tests for commit messages starting with 'Revert "' - [ - "Revert \"feat: do something (#123)\"", - "Revert \"Very long commit message with lots and lots of characters (more than 72!)\"", - "Revert \"blah\"\n\nbaz" - ].forEach(message => { - test("Posts a success status", async () => { - mockSingleCommitWithMessage(message); - - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, - body: { - state: "success" - }, - matchPartialBody: true - }, 201); - - await emitBotEvent(bot, { action, pull_request: { number: 1, title: message.replace(/\n[\s\S]*/u, "") } }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/first-sha`)).toBeTruthy(); - }); - }); - }); - }); + let bot = null; + + beforeAll(() => { + bot = new Probot({ + appId: 1, + githubToken: "test", + + Octokit: ProbotOctokit.defaults(instanceOptions => ({ + ...instanceOptions, + throttle: { enabled: false }, + retry: { enabled: false }, + })), + }); + commitMessage(bot); + }); + + afterEach(() => { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + }); + + ["opened", "reopened", "synchronize", "edited"].forEach(action => { + describe(`pull request ${action}`, () => { + test("Posts failure status if PR title is not correct", async () => { + mockSingleCommitWithMessage("non standard commit message"); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "failure", + }, + matchPartialBody: true, + }, + 201, + ); + + fetchMock.mockGlobal().post(({ args: [url, opts] }) => { + if ( + url !== + `${API_ROOT}/repos/test/repo-test/issues/1/comments` + ) { + return false; + } + const body = JSON.parse(opts.body).body; + + expect(body).toMatchSnapshot(); + + return true; + }, 200); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: "non standard commit message", + user: { login: "user-a" }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/comments`, + ), + ).toBeTruthy(); + }); + + test("Posts failure status if PR title is not correct even when the first commit message is correct", async () => { + mockSingleCommitWithMessage("feat: standard commit message"); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "failure", + }, + matchPartialBody: true, + }, + 201, + ); + + fetchMock.mockGlobal().post(({ args: [url, opts] }) => { + if ( + url !== + `${API_ROOT}/repos/test/repo-test/issues/1/comments` + ) { + return false; + } + const body = JSON.parse(opts.body).body; + + expect(body).toMatchSnapshot(); + + return true; + }, 200); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: "non standard commit message", + user: { login: "user-a" }, + }, + }); + }); + + test("Posts success status if PR title is correct", async () => { + mockSingleCommitWithMessage("feat: standard commit message"); + mockLabels(["feature"]); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "success", + }, + matchPartialBody: true, + }, + 201, + ); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: "feat: standard commit message", + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/labels`, + ), + ).toBeTruthy(); + }); + + test("Posts success status if PR title is correct even when the first commit message is not correct", async () => { + mockSingleCommitWithMessage("non standard commit message"); + mockLabels(["feature"]); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "success", + }, + matchPartialBody: true, + }, + 201, + ); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: "feat: standard commit message", + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/labels`, + ), + ).toBeTruthy(); + }); + + test("Posts success status if PR title begins with `Revert`", async () => { + mockSingleCommitWithMessage( + 'Revert "chore: add test for commit tag Revert"', + ); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "success", + }, + matchPartialBody: true, + }, + 201, + ); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: 'Revert "chore: add test for commit tag Revert"', + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + ), + ).toBeTruthy(); + }); + + test("Posts failure status if the PR title is longer than 72 chars and don't set labels", async () => { + mockSingleCommitWithMessage( + "feat!: standard commit message very very very long message and its beyond 72", + ); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "failure", + }, + matchPartialBody: true, + }, + 201, + ); + + fetchMock.mockGlobal().post(({ args: [url, opts] }) => { + if ( + url !== + `${API_ROOT}/repos/test/repo-test/issues/1/comments` + ) { + return false; + } + const body = JSON.parse(opts.body).body; + + expect(body).toMatchSnapshot(); + + return true; + }, 200); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: "feat!: standard commit message very very very long message and its beyond 72", + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/comments`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/labels`, + ), + ).toBeFalsy(); + }); + + test("Posts success status if there are multiple commit messages and the title is valid", async () => { + mockMultipleCommits(); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + body: { + state: "success", + }, + matchPartialBody: true, + }, + 201, + ); + + mockLabels(["feature"]); + + await emitBotEvent(bot, { + action, + pull_request: { number: 1, title: "feat: foo" }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + ), + ).toBeTruthy(); + }); + + test("Posts failure status if there are multiple commit messages and the title is invalid", async () => { + mockMultipleCommits(); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + body: { + state: "failure", + }, + matchPartialBody: true, + }, + 201, + ); + + fetchMock.mockGlobal().post(({ args: [url, opts] }) => { + if ( + url !== + `${API_ROOT}/repos/test/repo-test/issues/1/comments` + ) { + return false; + } + const body = JSON.parse(opts.body).body; + + expect(body).toMatchSnapshot(); + + return true; + }, 200); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: "foo", + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/comments`, + ), + ).toBeTruthy(); + }); + + test("Posts failure status if there are multiple commit messages and the title is too long", async () => { + mockMultipleCommits(); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + body: { + state: "failure", + }, + matchPartialBody: true, + }, + 201, + ); + + fetchMock.mockGlobal().post(({ args: [url, opts] }) => { + if ( + url !== + `${API_ROOT}/repos/test/repo-test/issues/1/comments` + ) { + return false; + } + const body = JSON.parse(opts.body).body; + + expect(body).toMatchSnapshot(); + + return true; + }, 200); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: `feat: ${"A".repeat(72)}`, + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/comments`, + ), + ).toBeTruthy(); + }); + + // Tests for invalid or malformed tag prefixes + [ + ": ", + "Foo: ", + "Revert: ", + "Neww: ", + "nNew: ", + " New: ", + "new: ", + "New:", + "New : ", + "New ", + "feat", + ].forEach(prefix => { + const message = `${prefix}foo`; + + test(`Posts failure status if the PR title has invalid tag prefix: "${prefix}"`, async () => { + mockSingleCommitWithMessage(message); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "failure", + }, + matchPartialBody: true, + }, + 201, + ); + + fetchMock.mockGlobal().post(({ args: [url, opts] }) => { + if ( + url !== + `${API_ROOT}/repos/test/repo-test/issues/1/comments` + ) { + return false; + } + const body = JSON.parse(opts.body).body; + + expect(body).toMatchSnapshot(); + + return true; + }, 200); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: message, + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/comments`, + ), + ).toBeTruthy(); + }); + + test(`Posts failure status if PR with multiple commits has invalid tag prefix in the title: "${prefix}"`, async () => { + mockMultipleCommits(); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + body: { + state: "failure", + }, + matchPartialBody: true, + }, + 201, + ); + + fetchMock.mockGlobal().post(({ args: [url, opts] }) => { + if ( + url !== + `${API_ROOT}/repos/test/repo-test/issues/1/comments` + ) { + return false; + } + const body = JSON.parse(opts.body).body; + + expect(body).toMatchSnapshot(); + + return true; + }, 200); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: message, + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/comments`, + ), + ).toBeTruthy(); + }); + }); + + // Tests for valid tag prefixes + TAG_LABELS.forEach((labels, prefix) => { + const message = `${prefix} foo`; + + test(`Posts success status if the PR title has valid tag prefix: "${prefix}"`, async () => { + mockSingleCommitWithMessage(message); + + mockLabels(labels); + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "success", + }, + matchPartialBody: true, + }, + 201, + ); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: message, + user: { login: "user-a" }, + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/labels`, + ), + ).toBeTruthy(); + }); + + test(`Posts success status if PR with multiple commits has valid tag prefix in the title: "${prefix}"`, async () => { + mockMultipleCommits(); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + body: { + state: "success", + }, + matchPartialBody: true, + }, + 201, + ); + + mockLabels(labels); + + await emitBotEvent(bot, { + action, + pull_request: { number: 1, title: message }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/second-sha`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/labels`, + ), + ).toBeTruthy(); + }); + }); + + test("Does not post a status if the repository is excluded", async () => { + await emitBotEvent(bot, { + action: "opened", + repository: { + name: "tsc-meetings", + owner: { + login: "test", + }, + }, + }); + }); + + // Tests for commit messages starting with 'Revert "' + [ + 'Revert "feat: do something (#123)"', + 'Revert "Very long commit message with lots and lots of characters (more than 72!)"', + 'Revert "blah"\n\nbaz', + ].forEach(message => { + test("Posts a success status", async () => { + mockSingleCommitWithMessage(message); + + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + body: { + state: "success", + }, + matchPartialBody: true, + }, + 201, + ); + + await emitBotEvent(bot, { + action, + pull_request: { + number: 1, + title: message.replace(/\n[\s\S]*/u, ""), + }, + }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/first-sha`, + ), + ).toBeTruthy(); + }); + }); + }); + }); }); diff --git a/tests/plugins/issue-pr-link/index.test.js b/tests/plugins/issue-pr-link/index.test.js index ed027cea..f23bb697 100644 --- a/tests/plugins/issue-pr-link/index.test.js +++ b/tests/plugins/issue-pr-link/index.test.js @@ -7,516 +7,601 @@ const { default: fetchMock } = require("fetch-mock"); const API_ROOT = "https://api.github.com"; describe("issue-pr-link", () => { - let bot = null; - - beforeEach(() => { - bot = new Probot({ - appId: 1, - githubToken: "test", - Octokit: ProbotOctokit.defaults(instanceOptions => ({ - ...instanceOptions, - throttle: { enabled: false }, - retry: { enabled: false } - })) - }); - issuePrLink(bot); - }); - - afterEach(() => { - fetchMock.unmockGlobal(); - fetchMock.removeRoutes(); - fetchMock.clearHistory(); - }); - - describe("pull request opened", () => { - test("comments on issue when PR body references an issue", async () => { - // Mock the issue exists and is open - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { - status: 200, - body: { state: "open", number: 123 } - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200, - body: [] - }) - .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200 - }); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Resolve the bug", - body: "Fix #123: resolve the bug", - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, "POST")).toBeTruthy(); - }); - - test("does not comment when PR body has no issue references", async () => { - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Add new feature", - body: "This PR adds a new feature to the application", - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called()).toBe(false); - }); - - test("does not comment when issue is closed", async () => { - // Mock the issue exists but is closed - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { - status: 200, - body: { state: "closed", number: 123 } - }); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Resolve the bug", - body: "Fix #123: resolve the bug", - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, "POST")).toBe(false); - }); - - test("does not comment when issue does not exist", async () => { - // Mock the issue does not exist - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { - status: 404 - }); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Resolve the bug", - body: "Fix #123: resolve the bug", - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, "POST")).toBe(false); - }); - - test("does not comment when there is already a comment for this PR", async () => { - // Mock the issue exists and is open, but we already commented - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { - status: 200, - body: { state: "open", number: 123 } - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200, - body: [{ - user: { type: "Bot" }, - body: "👋 Hi! This issue is being addressed in pull request https://github.com/test/repo-test/pull/456. Thanks, @contributor!\n\n[//]: # (issue-pr-link)" - }] - }); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Resolve the bug", - body: "Fix #123: resolve the bug", - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - const wasPostCalled = fetchMock.callHistory.calls().some(call => - call.url === `${API_ROOT}/repos/test/repo-test/issues/123/comments` && - call.options.method === "post" - ); - - expect(wasPostCalled).toBe(false); - }); - - test("handles multiple issue references correctly", async () => { - // Mock multiple issues exist and are open - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { - status: 200, - body: { state: "open", number: 123 } - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/456`, { - status: 200, - body: { state: "open", number: 456 } - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200, - body: [] - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/456/comments`, { - status: 200, - body: [] - }) - .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200 - }) - .post(`${API_ROOT}/repos/test/repo-test/issues/456/comments`, { - status: 200 - }); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 789, - title: "Multiple fixes", - body: "Fix #123 and closes #456", - html_url: "https://github.com/test/repo-test/pull/789", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, "POST")).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/456/comments`, "POST")).toBeTruthy(); - }); - - test("respects maximum issues limit to prevent abuse", async () => { - // Mock 5 issues but only first 3 should be processed - for (let i = 1; i <= 5; i++) { - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/${i}`, { - status: 200, - body: { state: "open", number: i } - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/${i}/comments`, { - status: 200, - body: [] - }) - .post(`${API_ROOT}/repos/test/repo-test/issues/${i}/comments`, { - status: 200 - }); - } - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 789, - title: "Multiple fixes test", - body: "Fix #1 and fix #2 and fix #3 and fix #4 and fix #5", - html_url: "https://github.com/test/repo-test/pull/789", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - // Only first 3 issues should be commented on - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/comments`, "POST")).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/2/comments`, "POST")).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/3/comments`, "POST")).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/4/comments`, "POST")).toBe(false); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/5/comments`, "POST")).toBe(false); - }); - - test("handles optional colon after keywords", async () => { - // Mock the issue exists and is open - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { - status: 200, - body: { state: "open", number: 123 } - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200, - body: [] - }) - .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200 - }); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Resolve the bug", - body: "resolve: #123", - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, "POST")).toBeTruthy(); - }); - - test("handles duplicate issue references correctly", async () => { - // Mock the issue exists and is open - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { - status: 200, - body: { state: "open", number: 123 } - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200, - body: [] - }) - .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200 - }); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Resolve the bug", - body: "resolve #123\nresolve #123", // Same issue referenced twice - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - // Should only comment once despite duplicate references - const postCalls = fetchMock.callHistory.calls().filter(call => - call.url === `${API_ROOT}/repos/test/repo-test/issues/123/comments` && - call.options.method === "post" - ); - expect(postCalls.length).toBe(1); - }); - - test("does not match keywords that are part of other words", async () => { - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Update documentation", - body: "This prefix #123 should not match", // 'prefix' contains 'fix' but shouldn't match - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called()).toBe(false); - }); - - test("does not match when newline separates keyword and issue number", async () => { - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Update documentation", - body: "resolve\n#123", // Newline between keyword and issue number should not match - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called()).toBe(false); - }); - }); - - describe("pull request edited", () => { - test("comments on issue when PR body is edited to reference an issue", async () => { - // Mock the issue exists and is open - fetchMock.mockGlobal() - .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { - status: 200, - body: { state: "open", number: 123 } - }) - .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200, - body: [] - }) - .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { - status: 200 - }); - - await bot.receive({ - name: "pull_request", - payload: { - action: "edited", - installation: { - id: 1 - }, - pull_request: { - number: 456, - title: "Resolve the bug", - body: "Fix #123: resolve the bug", - html_url: "https://github.com/test/repo-test/pull/456", - user: { - login: "contributor" - } - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, "POST")).toBeTruthy(); - }); - }); + let bot = null; + + beforeEach(() => { + bot = new Probot({ + appId: 1, + githubToken: "test", + Octokit: ProbotOctokit.defaults(instanceOptions => ({ + ...instanceOptions, + throttle: { enabled: false }, + retry: { enabled: false }, + })), + }); + issuePrLink(bot); + }); + + afterEach(() => { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + }); + + describe("pull request opened", () => { + test("comments on issue when PR body references an issue", async () => { + // Mock the issue exists and is open + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { + status: 200, + body: { state: "open", number: 123 }, + }) + .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + body: [], + }) + .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + }); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Resolve the bug", + body: "Fix #123: resolve the bug", + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/123/comments`, + "POST", + ), + ).toBeTruthy(); + }); + + test("does not comment when PR body has no issue references", async () => { + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Add new feature", + body: "This PR adds a new feature to the application", + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called()).toBe(false); + }); + + test("does not comment when issue is closed", async () => { + // Mock the issue exists but is closed + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { + status: 200, + body: { state: "closed", number: 123 }, + }); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Resolve the bug", + body: "Fix #123: resolve the bug", + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/123/comments`, + "POST", + ), + ).toBe(false); + }); + + test("does not comment when issue does not exist", async () => { + // Mock the issue does not exist + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { + status: 404, + }); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Resolve the bug", + body: "Fix #123: resolve the bug", + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/123/comments`, + "POST", + ), + ).toBe(false); + }); + + test("does not comment when there is already a comment for this PR", async () => { + // Mock the issue exists and is open, but we already commented + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { + status: 200, + body: { state: "open", number: 123 }, + }) + .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + body: [ + { + user: { type: "Bot" }, + body: "👋 Hi! This issue is being addressed in pull request https://github.com/test/repo-test/pull/456. Thanks, @contributor!\n\n[//]: # (issue-pr-link)", + }, + ], + }); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Resolve the bug", + body: "Fix #123: resolve the bug", + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + const wasPostCalled = fetchMock.callHistory + .calls() + .some( + call => + call.url === + `${API_ROOT}/repos/test/repo-test/issues/123/comments` && + call.options.method === "post", + ); + + expect(wasPostCalled).toBe(false); + }); + + test("handles multiple issue references correctly", async () => { + // Mock multiple issues exist and are open + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { + status: 200, + body: { state: "open", number: 123 }, + }) + .get(`${API_ROOT}/repos/test/repo-test/issues/456`, { + status: 200, + body: { state: "open", number: 456 }, + }) + .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + body: [], + }) + .get(`${API_ROOT}/repos/test/repo-test/issues/456/comments`, { + status: 200, + body: [], + }) + .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + }) + .post(`${API_ROOT}/repos/test/repo-test/issues/456/comments`, { + status: 200, + }); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 789, + title: "Multiple fixes", + body: "Fix #123 and closes #456", + html_url: "https://github.com/test/repo-test/pull/789", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/123/comments`, + "POST", + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/456/comments`, + "POST", + ), + ).toBeTruthy(); + }); + + test("respects maximum issues limit to prevent abuse", async () => { + // Mock 5 issues but only first 3 should be processed + for (let i = 1; i <= 5; i++) { + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/${i}`, { + status: 200, + body: { state: "open", number: i }, + }) + .get( + `${API_ROOT}/repos/test/repo-test/issues/${i}/comments`, + { + status: 200, + body: [], + }, + ) + .post( + `${API_ROOT}/repos/test/repo-test/issues/${i}/comments`, + { + status: 200, + }, + ); + } + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 789, + title: "Multiple fixes test", + body: "Fix #1 and fix #2 and fix #3 and fix #4 and fix #5", + html_url: "https://github.com/test/repo-test/pull/789", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + // Only first 3 issues should be commented on + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/comments`, + "POST", + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/2/comments`, + "POST", + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/3/comments`, + "POST", + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/4/comments`, + "POST", + ), + ).toBe(false); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/5/comments`, + "POST", + ), + ).toBe(false); + }); + + test("handles optional colon after keywords", async () => { + // Mock the issue exists and is open + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { + status: 200, + body: { state: "open", number: 123 }, + }) + .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + body: [], + }) + .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + }); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Resolve the bug", + body: "resolve: #123", + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/123/comments`, + "POST", + ), + ).toBeTruthy(); + }); + + test("handles duplicate issue references correctly", async () => { + // Mock the issue exists and is open + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { + status: 200, + body: { state: "open", number: 123 }, + }) + .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + body: [], + }) + .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + }); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Resolve the bug", + body: "resolve #123\nresolve #123", // Same issue referenced twice + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + // Should only comment once despite duplicate references + const postCalls = fetchMock.callHistory + .calls() + .filter( + call => + call.url === + `${API_ROOT}/repos/test/repo-test/issues/123/comments` && + call.options.method === "post", + ); + expect(postCalls.length).toBe(1); + }); + + test("does not match keywords that are part of other words", async () => { + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Update documentation", + body: "This prefix #123 should not match", // 'prefix' contains 'fix' but shouldn't match + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called()).toBe(false); + }); + + test("does not match when newline separates keyword and issue number", async () => { + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Update documentation", + body: "resolve\n#123", // Newline between keyword and issue number should not match + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called()).toBe(false); + }); + }); + + describe("pull request edited", () => { + test("comments on issue when PR body is edited to reference an issue", async () => { + // Mock the issue exists and is open + fetchMock + .mockGlobal() + .get(`${API_ROOT}/repos/test/repo-test/issues/123`, { + status: 200, + body: { state: "open", number: 123 }, + }) + .get(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + body: [], + }) + .post(`${API_ROOT}/repos/test/repo-test/issues/123/comments`, { + status: 200, + }); + + await bot.receive({ + name: "pull_request", + payload: { + action: "edited", + installation: { + id: 1, + }, + pull_request: { + number: 456, + title: "Resolve the bug", + body: "Fix #123: resolve the bug", + html_url: "https://github.com/test/repo-test/pull/456", + user: { + login: "contributor", + }, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/123/comments`, + "POST", + ), + ).toBeTruthy(); + }); + }); }); diff --git a/tests/plugins/needs-info/index.test.js b/tests/plugins/needs-info/index.test.js index 9bc8844c..4291d631 100644 --- a/tests/plugins/needs-info/index.test.js +++ b/tests/plugins/needs-info/index.test.js @@ -7,93 +7,98 @@ const { default: fetchMock } = require("fetch-mock"); const API_ROOT = "https://api.github.com"; describe("needs-info", () => { - let bot = null; + let bot = null; - beforeEach(() => { - bot = new Probot({ - appId: 1, - githubToken: "test", - Octokit: ProbotOctokit.defaults(instanceOptions => ({ - ...instanceOptions, - throttle: { enabled: false }, - retry: { enabled: false } - })) - }); - needsInfo(bot); - }); + beforeEach(() => { + bot = new Probot({ + appId: 1, + githubToken: "test", + Octokit: ProbotOctokit.defaults(instanceOptions => ({ + ...instanceOptions, + throttle: { enabled: false }, + retry: { enabled: false }, + })), + }); + needsInfo(bot); + }); - afterEach(() => { - fetchMock.unmockGlobal(); - fetchMock.removeRoutes(); - fetchMock.clearHistory(); - }); + afterEach(() => { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + }); - describe("issue labeled", () => { - test("Adds the comment if there needs info is added", async () => { - fetchMock.mockGlobal().post( - `${API_ROOT}/repos/test/repo-test/issues/1/comments`, - { status: 200 } - ); + describe("issue labeled", () => { + test("Adds the comment if there needs info is added", async () => { + fetchMock + .mockGlobal() + .post(`${API_ROOT}/repos/test/repo-test/issues/1/comments`, { + status: 200, + }); - await bot.receive({ - name: "issues", - payload: { - action: "closed", - installation: { - id: 1 - }, - issue: { - labels: [ - { - name: "needs info" - } - ], - user: { - login: "user-a" - }, - number: 1 - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); + await bot.receive({ + name: "issues", + payload: { + action: "closed", + installation: { + id: 1, + }, + issue: { + labels: [ + { + name: "needs info", + }, + ], + user: { + login: "user-a", + }, + number: 1, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/issues/1/comments`)).toBeTruthy(); - }); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/issues/1/comments`, + ), + ).toBeTruthy(); + }); - test("Do not add the comment if needs label label is not present", async () => { - await bot.receive({ - name: "issues", - payload: { - action: "closed", - installation: { - id: 1 - }, - issue: { - labels: [ - { - name: "triage" - } - ], - user: { - login: "user-a" - }, - number: 1 - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); + test("Do not add the comment if needs label label is not present", async () => { + await bot.receive({ + name: "issues", + payload: { + action: "closed", + installation: { + id: 1, + }, + issue: { + labels: [ + { + name: "triage", + }, + ], + user: { + login: "user-a", + }, + number: 1, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); - expect(fetchMock.callHistory.called()).toBe(false); - }); - }); + expect(fetchMock.callHistory.called()).toBe(false); + }); + }); }); diff --git a/tests/plugins/recurring-issues/index.test.js b/tests/plugins/recurring-issues/index.test.js index de8c00de..72e7e57e 100644 --- a/tests/plugins/recurring-issues/index.test.js +++ b/tests/plugins/recurring-issues/index.test.js @@ -23,197 +23,220 @@ const API_ROOT = "https://api.github.com"; //----------------------------------------------------------------------------- describe("recurring-issues", () => { - let issueWasCreated; - let issue; - - /** - * Runs the bot with the given arguments, setting up fixtures and running the webhook listener - * @param {Object} options Configure API responses the bot will see - * @param {string} options.issueTitle The title of the existing issue which was closed - * @param {string[]} options.labelNames The labels of the issue which was closed - * @param {string[]} options.eventTypes The events that have occurred for the issue - * @returns {Promise} A Promise that fulfills after the webhook action is complete - */ - function runBot({ issueTitle, labelNames, eventTypes }) { - issueWasCreated = false; - - const bot = new Probot({ - appId: 1, - githubToken: "test", - Octokit: ProbotOctokit.defaults(instanceOptions => ({ - ...instanceOptions, - throttle: { enabled: false }, - retry: { enabled: false } - })) - }); - - recurringIssues(bot); - - const ORGANIZATION_NAME = "test"; - const TEAM_ID = 55; - const TEAM_SLUG = "eslint-tsc"; - - fetchMock.mockGlobal().get(`${API_ROOT}/repos/${ORGANIZATION_NAME}/repo-test/issues/1/events?per_page=100`, eventTypes.map(type => ({ event: type }))); - - fetchMock.mockGlobal().post(`${API_ROOT}/repos/${ORGANIZATION_NAME}/repo-test/issues`, ({ options }) => { - issueWasCreated = true; - issue = JSON.parse(options.body); - return { - status: 200, - body: { - issue_number: 2 - } - }; - }); - - fetchMock.mockGlobal().get(`${API_ROOT}/orgs/${ORGANIZATION_NAME}/teams?per_page=100`, [ - { - id: TEAM_ID, - slug: "eslint-tsc" - } - ]); - - fetchMock.mockGlobal().get(`${API_ROOT}/orgs/${ORGANIZATION_NAME}/teams/${TEAM_SLUG}/members?per_page=100`, [ - { - id: 1, - login: "user1" - }, - { - id: 2, - login: "user2" - } - ]); - - fetchMock.mockGlobal().get(`${API_ROOT}/users/user1`, { - login: "user1", - name: "User One" - }); - - fetchMock.mockGlobal().get(`${API_ROOT}/users/user2`, { - login: "user2", - name: "User Two" - }); - - return bot.receive({ - name: "issues", - payload: { - installation: { - id: 1 - }, - action: "closed", - issue: { - number: 1, - title: issueTitle, - labels: labelNames.map(name => ({ name })) - }, - repository: { - owner: { - login: "test" - }, - name: "repo-test" - } - } - }); - } - - afterEach(() => { - fetchMock.unmockGlobal(); - fetchMock.removeRoutes(); - fetchMock.clearHistory(); - }); - - describe("when an issue does not have the release label", () => { - test("ignores the issue", async () => { - await runBot({ - issueTitle: "Scheduled release for October 27th, 2017", - labelNames: ["foo"], - eventTypes: ["closed"] - }); - - expect(issueWasCreated).toBe(false); - }); - }); - - describe("when an issue has already been closed and reopened", () => { - test("ignores the issue", async () => { - await runBot({ - issueTitle: "Scheduled release for October 27th, 2017", - labelNames: ["release"], - eventTypes: ["foo", "closed", "reopened", "closed"] - }); - - expect(issueWasCreated).toBe(false); - }); - }); - - describe("when an issue has an invalid title", () => { - test("ignores the issue", async () => { - await runBot({ - issueTitle: "Foo bar!", - labelNames: ["release"], - eventTypes: ["closed"] - }); - - expect(issueWasCreated).toBe(false); - }); - }); - - describe("when an issue has a parseable title, has the release label, and has never been closed", () => { - test("creates a new issue", async () => { - await runBot({ - issueTitle: "Scheduled release for October 27th, 2017", - labelNames: ["release"], - eventTypes: ["closed"] - }); - - expect(issueWasCreated).toBe(true); - expect(issue.title).toBe("Scheduled release for November 10th, 2017"); - expect(issue.body.startsWith("The next scheduled release will occur on Friday, November 10th, 2017")).toBe(true); - }); - }); - - describe("when an issue has a parseable title, has the tsc meeting label, and has never been closed", () => { - test("creates a new issue", async () => { - await runBot({ - issueTitle: "TSC meeting 26-October-2017", - labelNames: ["tsc meeting"], - eventTypes: ["closed"] - }); - - expect(issueWasCreated).toBe(true); - expect(issue.title).toBe("TSC meeting 09-November-2017"); - expect(issue.body).toBe([ - "# Time", - "", - "UTC Thu 09-Nov-2017 21:00:", - "- Los Angeles: Thu 09-Nov-2017 13:00", - "- Chicago: Thu 09-Nov-2017 15:00", - "- New York: Thu 09-Nov-2017 16:00", - "- Madrid: Thu 09-Nov-2017 22:00", - "- Moscow: Fri 10-Nov-2017 00:00", - "- Tokyo: Fri 10-Nov-2017 06:00", - "- Sydney: Fri 10-Nov-2017 08:00", - "", - "# Location", - "", - "https://eslint.org/chat/tsc-meetings", - "", - "# Agenda", - "", - "Extracted from:", - "", - "* Issues and pull requests from the ESLint organization with the [\"tsc agenda\" label](https://github.com/issues?utf8=%E2%9C%93&q=org%3Aeslint+label%3A%22tsc+agenda%22)", - "* Comments on this issue", - "", - "# Invited", - "", - "- User One (@user1) - TSC", - "- User Two (@user2) - TSC", - "", - "# Public participation", - "", - "Anyone is welcome to attend the meeting as observers. We ask that you refrain from interrupting the meeting once it begins and only participate if invited to do so." - ].join("\n")); - }); - }); + let issueWasCreated; + let issue; + + /** + * Runs the bot with the given arguments, setting up fixtures and running the webhook listener + * @param {Object} options Configure API responses the bot will see + * @param {string} options.issueTitle The title of the existing issue which was closed + * @param {string[]} options.labelNames The labels of the issue which was closed + * @param {string[]} options.eventTypes The events that have occurred for the issue + * @returns {Promise} A Promise that fulfills after the webhook action is complete + */ + function runBot({ issueTitle, labelNames, eventTypes }) { + issueWasCreated = false; + + const bot = new Probot({ + appId: 1, + githubToken: "test", + Octokit: ProbotOctokit.defaults(instanceOptions => ({ + ...instanceOptions, + throttle: { enabled: false }, + retry: { enabled: false }, + })), + }); + + recurringIssues(bot); + + const ORGANIZATION_NAME = "test"; + const TEAM_ID = 55; + const TEAM_SLUG = "eslint-tsc"; + + fetchMock.mockGlobal().get( + `${API_ROOT}/repos/${ORGANIZATION_NAME}/repo-test/issues/1/events?per_page=100`, + eventTypes.map(type => ({ event: type })), + ); + + fetchMock + .mockGlobal() + .post( + `${API_ROOT}/repos/${ORGANIZATION_NAME}/repo-test/issues`, + ({ options }) => { + issueWasCreated = true; + issue = JSON.parse(options.body); + return { + status: 200, + body: { + issue_number: 2, + }, + }; + }, + ); + + fetchMock + .mockGlobal() + .get(`${API_ROOT}/orgs/${ORGANIZATION_NAME}/teams?per_page=100`, [ + { + id: TEAM_ID, + slug: "eslint-tsc", + }, + ]); + + fetchMock + .mockGlobal() + .get( + `${API_ROOT}/orgs/${ORGANIZATION_NAME}/teams/${TEAM_SLUG}/members?per_page=100`, + [ + { + id: 1, + login: "user1", + }, + { + id: 2, + login: "user2", + }, + ], + ); + + fetchMock.mockGlobal().get(`${API_ROOT}/users/user1`, { + login: "user1", + name: "User One", + }); + + fetchMock.mockGlobal().get(`${API_ROOT}/users/user2`, { + login: "user2", + name: "User Two", + }); + + return bot.receive({ + name: "issues", + payload: { + installation: { + id: 1, + }, + action: "closed", + issue: { + number: 1, + title: issueTitle, + labels: labelNames.map(name => ({ name })), + }, + repository: { + owner: { + login: "test", + }, + name: "repo-test", + }, + }, + }); + } + + afterEach(() => { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + }); + + describe("when an issue does not have the release label", () => { + test("ignores the issue", async () => { + await runBot({ + issueTitle: "Scheduled release for October 27th, 2017", + labelNames: ["foo"], + eventTypes: ["closed"], + }); + + expect(issueWasCreated).toBe(false); + }); + }); + + describe("when an issue has already been closed and reopened", () => { + test("ignores the issue", async () => { + await runBot({ + issueTitle: "Scheduled release for October 27th, 2017", + labelNames: ["release"], + eventTypes: ["foo", "closed", "reopened", "closed"], + }); + + expect(issueWasCreated).toBe(false); + }); + }); + + describe("when an issue has an invalid title", () => { + test("ignores the issue", async () => { + await runBot({ + issueTitle: "Foo bar!", + labelNames: ["release"], + eventTypes: ["closed"], + }); + + expect(issueWasCreated).toBe(false); + }); + }); + + describe("when an issue has a parseable title, has the release label, and has never been closed", () => { + test("creates a new issue", async () => { + await runBot({ + issueTitle: "Scheduled release for October 27th, 2017", + labelNames: ["release"], + eventTypes: ["closed"], + }); + + expect(issueWasCreated).toBe(true); + expect(issue.title).toBe( + "Scheduled release for November 10th, 2017", + ); + expect( + issue.body.startsWith( + "The next scheduled release will occur on Friday, November 10th, 2017", + ), + ).toBe(true); + }); + }); + + describe("when an issue has a parseable title, has the tsc meeting label, and has never been closed", () => { + test("creates a new issue", async () => { + await runBot({ + issueTitle: "TSC meeting 26-October-2017", + labelNames: ["tsc meeting"], + eventTypes: ["closed"], + }); + + expect(issueWasCreated).toBe(true); + expect(issue.title).toBe("TSC meeting 09-November-2017"); + expect(issue.body).toBe( + [ + "# Time", + "", + "UTC Thu 09-Nov-2017 21:00:", + "- Los Angeles: Thu 09-Nov-2017 13:00", + "- Chicago: Thu 09-Nov-2017 15:00", + "- New York: Thu 09-Nov-2017 16:00", + "- Madrid: Thu 09-Nov-2017 22:00", + "- Moscow: Fri 10-Nov-2017 00:00", + "- Tokyo: Fri 10-Nov-2017 06:00", + "- Sydney: Fri 10-Nov-2017 08:00", + "", + "# Location", + "", + "https://eslint.org/chat/tsc-meetings", + "", + "# Agenda", + "", + "Extracted from:", + "", + '* Issues and pull requests from the ESLint organization with the ["tsc agenda" label](https://github.com/issues?utf8=%E2%9C%93&q=org%3Aeslint+label%3A%22tsc+agenda%22)', + "* Comments on this issue", + "", + "# Invited", + "", + "- User One (@user1) - TSC", + "- User Two (@user2) - TSC", + "", + "# Public participation", + "", + "Anyone is welcome to attend the meeting as observers. We ask that you refrain from interrupting the meeting once it begins and only participate if invited to do so.", + ].join("\n"), + ); + }); + }); }); diff --git a/tests/plugins/release-monitor/index.test.js b/tests/plugins/release-monitor/index.test.js index bba1fa58..66142b4b 100644 --- a/tests/plugins/release-monitor/index.test.js +++ b/tests/plugins/release-monitor/index.test.js @@ -43,31 +43,41 @@ const API_ROOT = "https://api.github.com"; * @private */ function mockAllOpenPrWithCommits(mockData = []) { - mockData.forEach((pullRequest, index) => { - const apiPath = `/repos/test/repo-test/pulls?state=open${index === 0 ? "" : `&page=${index + 1}`}`; - const linkHeaders = []; - - if (index !== mockData.length - 1) { - linkHeaders.push(`<${API_ROOT}/repos/test/repo-test/pulls?state=open&page=${index + 2}>; rel="next"`); - linkHeaders.push(`<${API_ROOT}/repos/test/repo-test/pulls?state=open&page=${mockData.length}>; rel="last"`); - } - - if (index !== 0) { - linkHeaders.push(`<${API_ROOT}/repos/test/repo-test/pulls?state=open&page=${index}>; rel="prev"`); - linkHeaders.push(`<${API_ROOT}/repos/test/repo-test/pulls?state=open&page=1>; rel="first"`); - } - - fetchMock.mockGlobal().get(`${API_ROOT}${apiPath}`, { - status: 200, - body: [pullRequest], - headers: linkHeaders.length ? { Link: linkHeaders.join(", ") } : {} - }); - - fetchMock.mockGlobal().get( - `${API_ROOT}/repos/test/repo-test/pulls/${pullRequest.number}/commits`, - pullRequest.commits - ); - }); + mockData.forEach((pullRequest, index) => { + const apiPath = `/repos/test/repo-test/pulls?state=open${index === 0 ? "" : `&page=${index + 1}`}`; + const linkHeaders = []; + + if (index !== mockData.length - 1) { + linkHeaders.push( + `<${API_ROOT}/repos/test/repo-test/pulls?state=open&page=${index + 2}>; rel="next"`, + ); + linkHeaders.push( + `<${API_ROOT}/repos/test/repo-test/pulls?state=open&page=${mockData.length}>; rel="last"`, + ); + } + + if (index !== 0) { + linkHeaders.push( + `<${API_ROOT}/repos/test/repo-test/pulls?state=open&page=${index}>; rel="prev"`, + ); + linkHeaders.push( + `<${API_ROOT}/repos/test/repo-test/pulls?state=open&page=1>; rel="first"`, + ); + } + + fetchMock.mockGlobal().get(`${API_ROOT}${apiPath}`, { + status: 200, + body: [pullRequest], + headers: linkHeaders.length ? { Link: linkHeaders.join(", ") } : {}, + }); + + fetchMock + .mockGlobal() + .get( + `${API_ROOT}/repos/test/repo-test/pulls/${pullRequest.number}/commits`, + pullRequest.commits, + ); + }); } /** @@ -76,15 +86,18 @@ function mockAllOpenPrWithCommits(mockData = []) { * @returns {void} */ function mockStatusPending(statusId) { - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/${statusId}`, - body: { - state: "pending", - description: "A patch release is pending", - target_url: "https://github.com/test/repo-test/issues/1" - }, - matchPartialBody: true - }, 200); + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/${statusId}`, + body: { + state: "pending", + description: "A patch release is pending", + target_url: "https://github.com/test/repo-test/issues/1", + }, + matchPartialBody: true, + }, + 200, + ); } /** @@ -93,14 +106,17 @@ function mockStatusPending(statusId) { * @returns {void} */ function mockStatusSuccessWithPatch(statusId) { - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/${statusId}`, - body: { - state: "success", - description: "This change is semver-patch" - }, - matchPartialBody: true - }, 200); + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/${statusId}`, + body: { + state: "success", + description: "This change is semver-patch", + }, + matchPartialBody: true, + }, + 200, + ); } /** @@ -109,631 +125,682 @@ function mockStatusSuccessWithPatch(statusId) { * @returns {void} */ function mockStatusSuccessNoPending(statusId) { - fetchMock.mockGlobal().post({ - url: `${API_ROOT}/repos/test/repo-test/statuses/${statusId}`, - body: { - state: "success", - description: "No patch release is pending" - }, - matchPartialBody: true - }, 200); + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/${statusId}`, + body: { + state: "success", + description: "No patch release is pending", + }, + matchPartialBody: true, + }, + 200, + ); } describe("release-monitor", () => { - let bot = null; - - beforeEach(() => { - bot = new Probot({ - appId: 1, - githubToken: "test", - Octokit: ProbotOctokit.defaults(instanceOptions => ({ - ...instanceOptions, - throttle: { enabled: false }, - retry: { enabled: false } - })) - }); - releaseMonitor(bot); - }); - - afterEach(() => { - fetchMock.unmockGlobal(); - fetchMock.removeRoutes(); - fetchMock.clearHistory(); - }); - - describe("issue labeled", () => { - test("in post release phase then add appropriate status check to all PRs", async () => { - mockAllOpenPrWithCommits([ - { - number: 1, - title: "New: add 1", - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }, - { - number: 2, - title: "Fix: fix 2", - commits: [ - { - sha: "222", - commit: { - message: "Fix: fix 2" - } - } - ] - }, - { - number: 3, - title: "Update: add 3", - commits: [ - { - sha: "333", - commit: { - message: "Update: add 3" - } - } - ] - }, - { - number: 4, - title: "Breaking: add 4", - commits: [ - { - sha: "444", - commit: { - message: "Breaking: add 4" - } - } - ] - }, - { - number: 5, - title: "random message", - commits: [ - { - sha: "555", - commit: { - message: "random message" - } - } - ] - }, - { - number: 6, - title: "Docs: message", - commits: [ - { - sha: "666", - commit: { - message: "Docs: message" - } - } - ] - }, - { - number: 7, - title: "Upgrade: message", - commits: [ - { - sha: "777", - commit: { - message: "Upgrade: message" - } - } - ] - } - ]); - - // Mock status API calls with fetchMock - mockStatusPending(111); - mockStatusSuccessWithPatch(222); - mockStatusPending(333); - mockStatusPending(444); - mockStatusPending(555); - mockStatusSuccessWithPatch(666); - mockStatusSuccessWithPatch(777); - - await bot.receive({ - name: "issues", - payload: { - action: "labeled", - installation: { - id: 1 - }, - issue: { - labels: [ - { - name: RELEASE_LABEL - }, - { - name: POST_RELEASE_LABEL - } - ], - number: 1, - html_url: "https://github.com/test/repo-test/issues/1" - }, - label: { - name: POST_RELEASE_LABEL - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/111`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/222`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/333`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/444`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/555`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/666`)).toBeTruthy(); - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/777`)).toBeTruthy(); - }, 10000); - - test("with no post release label nothing happens", async () => { - mockAllOpenPrWithCommits([ - { - number: 1, - title: "New: add 1", - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }, - { - number: 2, - title: "Fix: fix 2", - commits: [ - { - sha: "222", - commit: { - message: "Fix: fix 2" - } - } - ] - } - ]); - - await bot.receive({ - name: "issues", - payload: { - action: "labeled", - installation: { - id: 1 - }, - issue: { - labels: [ - { - name: RELEASE_LABEL - } - ], - number: 5 - }, - label: { - name: "something" - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called()).toBe(false); - }); - - test("with post release label on non release issue, nothing happens", async () => { - mockAllOpenPrWithCommits([ - { - number: 1, - title: "New: add 1", - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }, - { - number: 2, - title: "Fix: fix 2", - commits: [ - { - sha: "222", - commit: { - message: "Fix: fix 2" - } - } - ] - } - ]); - - await bot.receive({ - name: "issues", - payload: { - action: "labeled", - installation: { - id: 1 - }, - issue: { - labels: [ - { - name: POST_RELEASE_LABEL - }, - { - name: "bug" - } - ], - number: 5 - }, - label: { - name: POST_RELEASE_LABEL - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called()).toBe(false); - }); - }); - - describe("issue closed", () => { - test("is release then update status on all PR", async () => { - mockAllOpenPrWithCommits([ - { - number: 1, - title: "New: add 1", - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }, - { - number: 2, - title: "Fix: fix 2", - commits: [ - { - sha: "222", - commit: { - message: "Fix: fix 2" - } - } - ] - }, - { - number: 3, - title: "Update: add 3", - commits: [ - { - sha: "333", - commit: { - message: "Update: add 3" - } - } - ] - }, - { - number: 4, - title: "Breaking: add 4", - commits: [ - { - sha: "444", - commit: { - message: "Breaking: add 4" - } - } - ] - }, - { - number: 5, - title: "random message", - commits: [ - { - sha: "555", - commit: { - message: "random message" - } - } - ] - }, - { - number: 6, - title: "Docs: message", - commits: [ - { - sha: "666", - commit: { - message: "Docs: message" - } - } - ] - }, - { - number: 7, - title: "Upgrade: message", - commits: [ - { - sha: "777", - commit: { - message: "Upgrade: message" - } - } - ] - } - ]); - - mockStatusSuccessNoPending(111); - mockStatusSuccessNoPending(222); - mockStatusSuccessNoPending(333); - mockStatusSuccessNoPending(444); - mockStatusSuccessNoPending(555); - mockStatusSuccessNoPending(666); - mockStatusSuccessNoPending(777); - - await bot.receive({ - name: "issues", - payload: { - action: "closed", - installation: { - id: 1 - }, - issue: { - labels: [ - { - name: RELEASE_LABEL - } - ], - number: 5, - html_url: "https://github.com/test/repo-test/issues/1" - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called()).toBe(true); - }, 10000); - - test("is not a release issue", async () => { - mockAllOpenPrWithCommits([ - { - number: 1, - title: "New: add 1", - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }, - { - number: 2, - title: "Fix: fix 2", - commits: [ - { - sha: "222", - commit: { - message: "Fix: fix 2" - } - } - ] - } - ]); - - await bot.receive({ - name: "issues", - payload: { - action: "closed", - installation: { - id: 1 - }, - issue: { - labels: [ - { - name: "test" - } - ], - number: 5, - html_url: "https://github.com/test/repo-test/issues/5" - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called()).toBe(false); - }); - }); - - ["opened", "reopened", "synchronize", "edited"].forEach(action => { - describe(`pull request ${action}`, () => { - test("put pending for non semver patch PR under post release phase", async () => { - mockAllOpenPrWithCommits([ - { - number: 1, - title: "New: add 1", - commits: [ - { - sha: "111old", - commit: { - message: "New: add 1" - } - }, - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - } - ]); - - fetchMock.mockGlobal().get( - `${API_ROOT}/repos/test/repo-test/issues?labels=release%2C${encodeURIComponent(POST_RELEASE_LABEL)}`, - [ - { - html_url: "https://github.com/test/repo-test/issues/1" - } - ] - ); - - mockStatusPending(111); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 1, - title: "New: add 1" - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/111`)).toBe(true); - }); - - test("put success for semver patch PR under post release phase", async () => { - mockAllOpenPrWithCommits([ - { - number: 1, - title: "Fix: add 1", - commits: [ - { - sha: "111", - commit: { - message: "Fix: add 1" - } - } - ] - } - ]); - - fetchMock.mockGlobal().get( - `${API_ROOT}/repos/test/repo-test/issues?labels=release%2C${encodeURIComponent(POST_RELEASE_LABEL)}`, - [ - { - html_url: "https://github.com/test/repo-test/issues/1" - } - ] - ); - - mockStatusSuccessWithPatch(111); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 1, - title: "Fix: add 1" - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/111`)).toBe(true); - }); - - test("put success for all PR under outside release phase", async () => { - mockAllOpenPrWithCommits([ - { - number: 1, - title: "New: add 1", - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - } - ]); - - fetchMock.mockGlobal().get( - `${API_ROOT}/repos/test/repo-test/issues?labels=release%2C${encodeURIComponent(POST_RELEASE_LABEL)}`, - [] - ); - - mockStatusSuccessNoPending(111); - - await bot.receive({ - name: "pull_request", - payload: { - action: "opened", - installation: { - id: 1 - }, - pull_request: { - number: 1, - title: "New: add 1" - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - expect(fetchMock.callHistory.called(`${API_ROOT}/repos/test/repo-test/statuses/111`)).toBe(true); - }); - }); - }); + let bot = null; + + beforeEach(() => { + bot = new Probot({ + appId: 1, + githubToken: "test", + Octokit: ProbotOctokit.defaults(instanceOptions => ({ + ...instanceOptions, + throttle: { enabled: false }, + retry: { enabled: false }, + })), + }); + releaseMonitor(bot); + }); + + afterEach(() => { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + }); + + describe("issue labeled", () => { + test("in post release phase then add appropriate status check to all PRs", async () => { + mockAllOpenPrWithCommits([ + { + number: 1, + title: "New: add 1", + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }, + { + number: 2, + title: "Fix: fix 2", + commits: [ + { + sha: "222", + commit: { + message: "Fix: fix 2", + }, + }, + ], + }, + { + number: 3, + title: "Update: add 3", + commits: [ + { + sha: "333", + commit: { + message: "Update: add 3", + }, + }, + ], + }, + { + number: 4, + title: "Breaking: add 4", + commits: [ + { + sha: "444", + commit: { + message: "Breaking: add 4", + }, + }, + ], + }, + { + number: 5, + title: "random message", + commits: [ + { + sha: "555", + commit: { + message: "random message", + }, + }, + ], + }, + { + number: 6, + title: "Docs: message", + commits: [ + { + sha: "666", + commit: { + message: "Docs: message", + }, + }, + ], + }, + { + number: 7, + title: "Upgrade: message", + commits: [ + { + sha: "777", + commit: { + message: "Upgrade: message", + }, + }, + ], + }, + ]); + + // Mock status API calls with fetchMock + mockStatusPending(111); + mockStatusSuccessWithPatch(222); + mockStatusPending(333); + mockStatusPending(444); + mockStatusPending(555); + mockStatusSuccessWithPatch(666); + mockStatusSuccessWithPatch(777); + + await bot.receive({ + name: "issues", + payload: { + action: "labeled", + installation: { + id: 1, + }, + issue: { + labels: [ + { + name: RELEASE_LABEL, + }, + { + name: POST_RELEASE_LABEL, + }, + ], + number: 1, + html_url: "https://github.com/test/repo-test/issues/1", + }, + label: { + name: POST_RELEASE_LABEL, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/111`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/222`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/333`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/444`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/555`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/666`, + ), + ).toBeTruthy(); + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/777`, + ), + ).toBeTruthy(); + }, 10000); + + test("with no post release label nothing happens", async () => { + mockAllOpenPrWithCommits([ + { + number: 1, + title: "New: add 1", + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }, + { + number: 2, + title: "Fix: fix 2", + commits: [ + { + sha: "222", + commit: { + message: "Fix: fix 2", + }, + }, + ], + }, + ]); + + await bot.receive({ + name: "issues", + payload: { + action: "labeled", + installation: { + id: 1, + }, + issue: { + labels: [ + { + name: RELEASE_LABEL, + }, + ], + number: 5, + }, + label: { + name: "something", + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called()).toBe(false); + }); + + test("with post release label on non release issue, nothing happens", async () => { + mockAllOpenPrWithCommits([ + { + number: 1, + title: "New: add 1", + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }, + { + number: 2, + title: "Fix: fix 2", + commits: [ + { + sha: "222", + commit: { + message: "Fix: fix 2", + }, + }, + ], + }, + ]); + + await bot.receive({ + name: "issues", + payload: { + action: "labeled", + installation: { + id: 1, + }, + issue: { + labels: [ + { + name: POST_RELEASE_LABEL, + }, + { + name: "bug", + }, + ], + number: 5, + }, + label: { + name: POST_RELEASE_LABEL, + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called()).toBe(false); + }); + }); + + describe("issue closed", () => { + test("is release then update status on all PR", async () => { + mockAllOpenPrWithCommits([ + { + number: 1, + title: "New: add 1", + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }, + { + number: 2, + title: "Fix: fix 2", + commits: [ + { + sha: "222", + commit: { + message: "Fix: fix 2", + }, + }, + ], + }, + { + number: 3, + title: "Update: add 3", + commits: [ + { + sha: "333", + commit: { + message: "Update: add 3", + }, + }, + ], + }, + { + number: 4, + title: "Breaking: add 4", + commits: [ + { + sha: "444", + commit: { + message: "Breaking: add 4", + }, + }, + ], + }, + { + number: 5, + title: "random message", + commits: [ + { + sha: "555", + commit: { + message: "random message", + }, + }, + ], + }, + { + number: 6, + title: "Docs: message", + commits: [ + { + sha: "666", + commit: { + message: "Docs: message", + }, + }, + ], + }, + { + number: 7, + title: "Upgrade: message", + commits: [ + { + sha: "777", + commit: { + message: "Upgrade: message", + }, + }, + ], + }, + ]); + + mockStatusSuccessNoPending(111); + mockStatusSuccessNoPending(222); + mockStatusSuccessNoPending(333); + mockStatusSuccessNoPending(444); + mockStatusSuccessNoPending(555); + mockStatusSuccessNoPending(666); + mockStatusSuccessNoPending(777); + + await bot.receive({ + name: "issues", + payload: { + action: "closed", + installation: { + id: 1, + }, + issue: { + labels: [ + { + name: RELEASE_LABEL, + }, + ], + number: 5, + html_url: "https://github.com/test/repo-test/issues/1", + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called()).toBe(true); + }, 10000); + + test("is not a release issue", async () => { + mockAllOpenPrWithCommits([ + { + number: 1, + title: "New: add 1", + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }, + { + number: 2, + title: "Fix: fix 2", + commits: [ + { + sha: "222", + commit: { + message: "Fix: fix 2", + }, + }, + ], + }, + ]); + + await bot.receive({ + name: "issues", + payload: { + action: "closed", + installation: { + id: 1, + }, + issue: { + labels: [ + { + name: "test", + }, + ], + number: 5, + html_url: "https://github.com/test/repo-test/issues/5", + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect(fetchMock.callHistory.called()).toBe(false); + }); + }); + + ["opened", "reopened", "synchronize", "edited"].forEach(action => { + describe(`pull request ${action}`, () => { + test("put pending for non semver patch PR under post release phase", async () => { + mockAllOpenPrWithCommits([ + { + number: 1, + title: "New: add 1", + commits: [ + { + sha: "111old", + commit: { + message: "New: add 1", + }, + }, + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }, + ]); + + fetchMock + .mockGlobal() + .get( + `${API_ROOT}/repos/test/repo-test/issues?labels=release%2C${encodeURIComponent(POST_RELEASE_LABEL)}`, + [ + { + html_url: + "https://github.com/test/repo-test/issues/1", + }, + ], + ); + + mockStatusPending(111); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 1, + title: "New: add 1", + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/111`, + ), + ).toBe(true); + }); + + test("put success for semver patch PR under post release phase", async () => { + mockAllOpenPrWithCommits([ + { + number: 1, + title: "Fix: add 1", + commits: [ + { + sha: "111", + commit: { + message: "Fix: add 1", + }, + }, + ], + }, + ]); + + fetchMock + .mockGlobal() + .get( + `${API_ROOT}/repos/test/repo-test/issues?labels=release%2C${encodeURIComponent(POST_RELEASE_LABEL)}`, + [ + { + html_url: + "https://github.com/test/repo-test/issues/1", + }, + ], + ); + + mockStatusSuccessWithPatch(111); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 1, + title: "Fix: add 1", + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/111`, + ), + ).toBe(true); + }); + + test("put success for all PR under outside release phase", async () => { + mockAllOpenPrWithCommits([ + { + number: 1, + title: "New: add 1", + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }, + ]); + + fetchMock + .mockGlobal() + .get( + `${API_ROOT}/repos/test/repo-test/issues?labels=release%2C${encodeURIComponent(POST_RELEASE_LABEL)}`, + [], + ); + + mockStatusSuccessNoPending(111); + + await bot.receive({ + name: "pull_request", + payload: { + action: "opened", + installation: { + id: 1, + }, + pull_request: { + number: 1, + title: "New: add 1", + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + expect( + fetchMock.callHistory.called( + `${API_ROOT}/repos/test/repo-test/statuses/111`, + ), + ).toBe(true); + }); + }); + }); }); diff --git a/tests/plugins/wip/index.test.js b/tests/plugins/wip/index.test.js index d9d03b07..d3c51b56 100644 --- a/tests/plugins/wip/index.test.js +++ b/tests/plugins/wip/index.test.js @@ -30,19 +30,15 @@ const API_ROOT = "https://api.github.com"; * @private */ function mockGetAllCommitsForPR({ number, commits }) { - - const url = `${API_ROOT}/repos/test/repo-test/pulls/${number}/commits`; - - fetchMock.mockGlobal().get( - url, - { - status: 200, - headers: { - "content-type": "application/json" - }, - body: JSON.stringify(commits) - } - ); + const url = `${API_ROOT}/repos/test/repo-test/pulls/${number}/commits`; + + fetchMock.mockGlobal().get(url, { + status: 200, + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(commits), + }); } /** @@ -54,23 +50,18 @@ function mockGetAllCommitsForPR({ number, commits }) { * @private */ function mockStatusChecksForCommit({ sha, statuses }) { - - const url = `${API_ROOT}/repos/test/repo-test/commits/${sha}/status`; - - fetchMock.mockGlobal().get( - url, - { - status: 200, - headers: { - "content-type": "application/json" - }, - body: JSON.stringify({ - sha, - statuses - }) - } - ); - + const url = `${API_ROOT}/repos/test/repo-test/commits/${sha}/status`; + + fetchMock.mockGlobal().get(url, { + status: 200, + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + sha, + statuses, + }), + }); } /** @@ -79,21 +70,20 @@ function mockStatusChecksForCommit({ sha, statuses }) { * @returns {void} */ function mockPendingStatusForWip() { - - fetchMock.mockGlobal().post( - { - url: `${API_ROOT}/repos/test/repo-test/statuses/111`, - body: { - context: "wip", - state: "pending" - }, - matchPartialBody: true - }, - { - status: 200, - body: JSON.stringify({}) - } - ); + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/111`, + body: { + context: "wip", + state: "pending", + }, + matchPartialBody: true, + }, + { + status: 200, + body: JSON.stringify({}), + }, + ); } /** @@ -102,20 +92,20 @@ function mockPendingStatusForWip() { * @returns {void} */ function mockSuccessStatusForWip() { - fetchMock.mockGlobal().post( - { - url: `${API_ROOT}/repos/test/repo-test/statuses/111`, - body: { - context: "wip", - state: "success" - }, - matchPartialBody: true - }, - { - status: 200, - body: JSON.stringify({}) - } - ); + fetchMock.mockGlobal().post( + { + url: `${API_ROOT}/repos/test/repo-test/statuses/111`, + body: { + context: "wip", + state: "success", + }, + matchPartialBody: true, + }, + { + status: 200, + body: JSON.stringify({}), + }, + ); } /** @@ -123,7 +113,11 @@ function mockSuccessStatusForWip() { * @returns {void} */ function assertNoStatusChecksCreated() { - expect(fetchMock.callHistory.calls(`${API_ROOT}/repos/test/repo-test/statuses/111`)).toHaveLength(0); + expect( + fetchMock.callHistory.calls( + `${API_ROOT}/repos/test/repo-test/statuses/111`, + ), + ).toHaveLength(0); } //----------------------------------------------------------------------------- @@ -131,233 +125,236 @@ function assertNoStatusChecksCreated() { //----------------------------------------------------------------------------- describe("wip", () => { - let bot = null; - - beforeEach(() => { - bot = new Probot({ - - appId: 1, - githubToken: "test", - - Octokit: ProbotOctokit.defaults(instanceOptions => ({ - ...instanceOptions, - throttle: { enabled: false }, - retry: { enabled: false } - })) - }); - wip(bot); - }); - - afterEach(() => { - fetchMock.unmockGlobal(); - fetchMock.removeRoutes(); - fetchMock.clearHistory(); - }); - - ["opened", "reopened", "edited", "labeled", "unlabeled", "synchronize"].forEach(action => { - describe(`pull request ${action}`, () => { - test("create pending status if PR title starts with 'WIP:'", async () => { - mockGetAllCommitsForPR({ - number: 1, - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }); - - mockPendingStatusForWip(); - - await bot.receive({ - name: "pull_request", - payload: { - action, - installation: { - id: 1 - }, - pull_request: { - number: 1, - title: "WIP: Some title", - labels: [] - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - }); - - test("create pending status if PR title contains '(WIP)'", async () => { - mockGetAllCommitsForPR({ - number: 1, - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }); - - mockPendingStatusForWip(); - - await bot.receive({ - name: "pull_request", - payload: { - action, - installation: { - id: 1 - }, - pull_request: { - number: 1, - title: "Some title (WIP)", - labels: [] - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - }); - - test("create pending status if labels contain 'do not merge'", async () => { - mockGetAllCommitsForPR({ - number: 1, - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }); - - mockPendingStatusForWip(); - - await bot.receive({ - name: "pull_request", - payload: { - action, - installation: { - id: 1 - }, - pull_request: { - number: 1, - title: "Some title", - labels: [{ name: DO_NOT_MERGE_LABEL }] - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - }); - - test("does not create status check if PR is not WIP and no wip status exists", async () => { - mockGetAllCommitsForPR({ - number: 1, - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }); - - mockStatusChecksForCommit({ - sha: "111", - statuses: [] - }); - - await bot.receive({ - name: "pull_request", - payload: { - action, - installation: { - id: 1 - }, - pull_request: { - number: 1, - title: "Some title", - labels: [] - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - assertNoStatusChecksCreated(); - - }); - - test("creates success status check if PR is not WIP and wip status exists", async () => { - mockGetAllCommitsForPR({ - number: 1, - commits: [ - { - sha: "111", - commit: { - message: "New: add 1" - } - } - ] - }); - - mockStatusChecksForCommit({ - sha: "111", - statuses: [{ - state: "pending", - context: "wip" - }] - }); - - mockSuccessStatusForWip(); - - await bot.receive({ - name: "pull_request", - payload: { - action, - installation: { - id: 1 - }, - pull_request: { - number: 1, - title: "Some title", - labels: [] - }, - repository: { - name: "repo-test", - owner: { - login: "test" - } - } - } - }); - - }); - }); - }); + let bot = null; + + beforeEach(() => { + bot = new Probot({ + appId: 1, + githubToken: "test", + + Octokit: ProbotOctokit.defaults(instanceOptions => ({ + ...instanceOptions, + throttle: { enabled: false }, + retry: { enabled: false }, + })), + }); + wip(bot); + }); + + afterEach(() => { + fetchMock.unmockGlobal(); + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + }); + + [ + "opened", + "reopened", + "edited", + "labeled", + "unlabeled", + "synchronize", + ].forEach(action => { + describe(`pull request ${action}`, () => { + test("create pending status if PR title starts with 'WIP:'", async () => { + mockGetAllCommitsForPR({ + number: 1, + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }); + + mockPendingStatusForWip(); + + await bot.receive({ + name: "pull_request", + payload: { + action, + installation: { + id: 1, + }, + pull_request: { + number: 1, + title: "WIP: Some title", + labels: [], + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + }); + + test("create pending status if PR title contains '(WIP)'", async () => { + mockGetAllCommitsForPR({ + number: 1, + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }); + + mockPendingStatusForWip(); + + await bot.receive({ + name: "pull_request", + payload: { + action, + installation: { + id: 1, + }, + pull_request: { + number: 1, + title: "Some title (WIP)", + labels: [], + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + }); + + test("create pending status if labels contain 'do not merge'", async () => { + mockGetAllCommitsForPR({ + number: 1, + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }); + + mockPendingStatusForWip(); + + await bot.receive({ + name: "pull_request", + payload: { + action, + installation: { + id: 1, + }, + pull_request: { + number: 1, + title: "Some title", + labels: [{ name: DO_NOT_MERGE_LABEL }], + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + }); + + test("does not create status check if PR is not WIP and no wip status exists", async () => { + mockGetAllCommitsForPR({ + number: 1, + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }); + + mockStatusChecksForCommit({ + sha: "111", + statuses: [], + }); + + await bot.receive({ + name: "pull_request", + payload: { + action, + installation: { + id: 1, + }, + pull_request: { + number: 1, + title: "Some title", + labels: [], + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + + assertNoStatusChecksCreated(); + }); + + test("creates success status check if PR is not WIP and wip status exists", async () => { + mockGetAllCommitsForPR({ + number: 1, + commits: [ + { + sha: "111", + commit: { + message: "New: add 1", + }, + }, + ], + }); + + mockStatusChecksForCommit({ + sha: "111", + statuses: [ + { + state: "pending", + context: "wip", + }, + ], + }); + + mockSuccessStatusForWip(); + + await bot.receive({ + name: "pull_request", + payload: { + action, + installation: { + id: 1, + }, + pull_request: { + number: 1, + title: "Some title", + labels: [], + }, + repository: { + name: "repo-test", + owner: { + login: "test", + }, + }, + }, + }); + }); + }); + }); });