diff --git a/README.md b/README.md index ee3af8e..0f504bd 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,16 @@ Squashes and merges your pull requests with a Conventional Commit message. ## Getting started -This GitHub application uses the description of a pull request to create a merge commit with a custom message. Based on such a commit message, it is possible to automatically determine the correct [SemVer](https://semver.org) version of the next release. +This GitHub application uses the title and description of a pull request to create a merge commit. Based on the commit message, it is possible to automatically determine the correct [SemVer](https://semver.org) version of the next release. -It can set the status of the pull request to `error` if there is not enough information to build the commit message, if wished. - -The commit message follows the pattern defined by [Conventional Commits](https://conventionalcommits.org) as you can see in the following sample: +The generated commit message follows the pattern defined by [Conventional Commits](https://conventionalcommits.org) as you can see in the following sample: ``` feat: Introducing a cool feature. The new feature is awesome! -BREAKING CHANGES: You must update the configuration. +BREAKING CHANGE: You must update the configuration. #1234 ``` @@ -53,7 +51,7 @@ For the sample commit message given above, the complete pull request description ``` # Internal Information -This text will be excluded from the commit message. +This text will be excluded from the commit message because of the unknown heading. # Details @@ -74,7 +72,7 @@ This text will be excluded from the commit message, too. The level of the headings does not matter. You can increase it if you prefer a smaller text size. -### Merging +### Manual Merging Unfortunately, you cannot use the `Merge` button with this bot. To create the custom merge commit, create a new comment with the merge command: @@ -84,17 +82,29 @@ Unfortunately, you cannot use the `Merge` button with this bot. To create the cu All commits are squashed and merged using a conventional commit message. If it is not possible to create such a message, the standard message is used. -### Assigning reviewers +You can define a [branch protection](https://help.github.com/en/github/administering-a-repository/configuring-protected-branches) to allow only this app to merge to the master branch. This disables the `Merge` button for all members. -You can define rules to automatically request reviews from users or teams based on the topics and labels set for the pull request. +### Automatic merging -The rules are provided via environment variables `REVIEW_USERS_RULES` and `REVIEW_TEAMS_RULES`. +By setting the environment variable `AUTOMERGE_BRANCHES`, you can specify branches that will be automatically merged once all checks have been successfully completed. -Example: +### Preventing accidental merges -```bash -REVIEW_TEAMS_RULES = "documentation,release/major=+docs-team documentation,release/minor=-docs-team documentation=-docs-team" -``` +As long as you are still working on a pull request, you can prefix its title with `WIP:`. This prevents the bot from performing a manual or automatic merge. + +### Labels + +The bot creates some labels to inform users about the state of the pull request: + +- The type of release that will be triggered, e.g. `release/major`. The label consists of a prefix and the type of release. + +- The keyword used to specify the kind of changes, e.g. `release/feat`. The label also consists of the prefix and the keyword itself. + +- Pull requests that must not be merged since they are still being worked on are labeled with `work-in-progress`. + +- If the branch of a pull request is included in the list of branches to be merged automatically, the label `automatic-merge` is displayed. + +The text of each label can be changed by setting the appropriate [environment variables](#environment-variables). ## Deployment @@ -106,28 +116,29 @@ You can use [serverless](https://serverless.com) to deploy the application. The ### Environment variables -- `COMMIT_CONFIG`: Config for semantic release analyzer +The bot works out of the box with sensible default settings which can be changed by environment variables. - Config used by default: +- `AUTOMERGE_BRANCHES`: Comma-separated list of branch names that will be merged automatically if all checks are ok + +- `AUTOMERGE_LABEL` (defaults to `automatic-merge`): Label created for branches that will be merged automatically by the bot + +- `CONFIG`: Configuration for [semantic release analyzer](https://github.com/semantic-release/commit-analyzer#configuration) + + Defaults to: ``` { - "preset": "angular", - "releaseRules": [ - { "type": "chore", "release": "patch" } - ], - "parserOpts": { - "noteKeywords": ["BREAKING CHANGES"] - } + "preset": "angular" } ``` -- `LABEL_PREFIX`: Prefix for all created labels -- `LABEL_SUFFIX_MAJOR`: Suffix of label for major release -- `LABEL_SUFFIX_MINOR`: Suffix of label for minor release -- `LABEL_SUFFIX_PATCH`: Suffix of label for patch release -- `REVIEW_USERS_RULES`: Rule for requesting reviews from users (see [Assigning reviewers](#assigning-reviewers)) -- `REVIEW_TEAMS_RULES`: Rule for requesting reviews from teams (see [Assigning reviewers](#assigning-reviewers)) -- `AUTOMERGE_BRANCHES`: Comma-separated list of branch names that will be merged automatically if all checks are ok -- `AUTOMERGE_LABEL`: Label for branches that will be merged automatically -- `WIP_LABEL`: Label for unfinished branches that must not be merged +- `LABEL_PREFIX` (defaults to `release/`): Prefix for all created release labels + +- `LABEL_SUFFIX_MAJOR` (defaults to `major`): Suffix of label for major release + +- `LABEL_SUFFIX_MINOR` (defaults to `minor`): Suffix of label for minor release + +- `LABEL_SUFFIX_PATCH` (defaults to `patch`): Suffix of label for patch release +automatically + +- `WIP_LABEL` (defaults to `work-in-progress`): Label created for unfinished branches that must not be merged diff --git a/config/config.dev.json.sample b/config/config.dev.json.sample index cafb24e..5e9bbf8 100644 --- a/config/config.dev.json.sample +++ b/config/config.dev.json.sample @@ -1,8 +1,7 @@ { - "APP_ID": 1234, - "GITHUB_CLIENT_ID": "example-github-client-id", - "GITHUB_CLIENT_SECRET": "example-github-client-secret", - "REVIEW_TEAMS_RULES": "my-topic,release/feat=+my-team my-topic,release/fix=+my-team my-topic=-my-team", + "APP_ID": 0000, + "GITHUB_CLIENT_ID": "client-id-by-github", + "GITHUB_CLIENT_SECRET": "client-secret-by-github", "AUTOMERGE_BRANCHES": "branch1,branch2", "WEBHOOK_SECRET": "example-webhook-secret", "memorySize": 256 diff --git a/index.js b/index.js index fdeee36..f3e25a5 100644 --- a/index.js +++ b/index.js @@ -1,28 +1,21 @@ 'use strict' -const handleCheck = require('./lib/handleCheck') -const handleCommitStatus = require('./lib/handleCommitStatus') -const handleComment = require('./lib/handleComment') -const handlePullRequestChange = require('./lib/handlePullRequestChange') +const status = require('./lib/handlers/status') +const comment = require('./lib/handlers/comment') +const pullRequestChange = require('./lib/handlers/pullRequestChange') const probotPlugin = (robot) => { robot.on([ 'pull_request.edited', - 'pull_request.labeled', - 'pull_request.unlabeled', 'pull_request.opened', 'pull_request.reopened' - ], handlePullRequestChange) + ], pullRequestChange) robot.on([ 'issue_comment.created' - ], handleComment) - robot.on([ - 'check_run.completed', - 'check_suite.completed' - ], handleCheck) + ], comment) robot.on([ 'status' - ], handleCommitStatus) + ], status) } module.exports = probotPlugin diff --git a/lib/areAllChecksSuccessful.js b/lib/areAllChecksSuccessful.js deleted file mode 100644 index 58b4f48..0000000 --- a/lib/areAllChecksSuccessful.js +++ /dev/null @@ -1,28 +0,0 @@ -'use strict' - -// See: https://dev.to/gr2m/github-api-how-to-retrieve-the-combined-pull-request-status-from-commit-statuses-check-runs-and-github-action-results-2cen - -const debug = require('debug')('app:areAllChecksSuccessful') - -const areAllChecksSuccessful = async function (context) { - debug('Start') - - const commitContext = context.repo({ ref: context.payload.pull_request.head.sha }) - debug(`Context: ${JSON.stringify(commitContext)}`) - - // https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref - const { data: { state: commitStatusState } } = await context.github.repos.getCombinedStatusForRef(commitContext) - debug(`CommitStatus: ${commitStatusState}`) - // https://developer.github.com/v3/checks/runs/#list-check-runs-for-a-specific-ref - const checks = await context.github.paginate( - context.github.checks.listForRef.endpoint.merge(commitContext) - ) - debug(`Checks: ${JSON.stringify(checks)}`) - - const allChecksSuccessOrNeutral = checks.every((check) => { ['success', 'neutral'].includes(check.conclusion) }) - debug(`allChecksSuccessOrNeutral: ${allChecksSuccessOrNeutral}`) - - return commitStatusState === 'success' && allChecksSuccessOrNeutral -} - -module.exports = areAllChecksSuccessful diff --git a/lib/extractMessage.js b/lib/buildCommitMessage.js similarity index 57% rename from lib/extractMessage.js rename to lib/buildCommitMessage.js index 913220b..d563407 100644 --- a/lib/extractMessage.js +++ b/lib/buildCommitMessage.js @@ -1,10 +1,18 @@ 'use strict' -const debug = require('debug')('app:extractMessage') +const debug = require('debug')('app:buildCommitMessage') -const extractMessage = function (title, description) { +const config = require('./config') + +const buildCommitMessage = function (title, description) { debug('Start') + // Get keyword for breaking changes from config + const noteKeyword = (config.parserOpts && config.parserOpts.noteKeywords) + // noteKeywords can be string or array + ? Array.isArray(config.parserOpts.noteKeywords) ? config.parserOpts.noteKeywords[0] : config.parserOpts.noteKeywords + : 'BREAKING CHANGE' + const message = { title: title.trim(), body: '', @@ -18,7 +26,7 @@ const extractMessage = function (title, description) { details = details ? details[1].trim() : '' if (breaking) { // Add BREAKING CHANGE only if there is more text - breaking = breaking[1].trim() === '' ? '' : `BREAKING CHANGES: ${breaking[1].trim()}` + breaking = breaking[1].trim() === '' ? '' : `${noteKeyword}: ${breaking[1].trim()}` } else { breaking = '' } @@ -33,4 +41,4 @@ const extractMessage = function (title, description) { return message } -module.exports = extractMessage +module.exports = buildCommitMessage diff --git a/lib/buildResultMessage.js b/lib/buildResultMessage.js new file mode 100644 index 0000000..8378c38 --- /dev/null +++ b/lib/buildResultMessage.js @@ -0,0 +1,69 @@ +'use strict' + +const debug = require('debug')('app:buildResultMessage') + +const getMergeComment = require('./merge/getMergeComment') +const getReleaseType = require('./getReleaseType') + +const getSuccessMessage = async function ({ commitMessage }) { + debug('composeSuccessMessage') + + const releaseType = await getReleaseType(commitMessage.full) + const successComment = releaseType ? [ + '🎉 Pull Request Merged 🎉', + '', + 'This pull request has been merged with the following commit message:', + '', + '``````', + commitMessage.full, + '``````', + '', + `A **${releaseType}** release is triggered.` + ] : [ + '🎉 Pull Request Merged 🎉', + '', + 'No type of pull request detected. Therefore, no release is triggered.' + ] + + return successComment.join('\n') +} + +const getErrorMessage = async function ({ context, exception }) { + debug('composeErrorMessage') + + let errorMessage + try { + errorMessage = JSON.parse(exception.message).message + } catch (exJson) { + errorMessage = exception.message + } + + const comment = await getMergeComment(context) + let deleteNote + if (comment) { + deleteNote = `Delete the [/merge comment](${comment.html_url}) to stop.` + } + + return [ + '⚠️ Merging Pull Request Failed ⚠️', + '', + errorMessage, + '', + `Merge will be **automatically retried** on any change. ${deleteNote}` + ].join('\n') +} + +const buildResultMessage = async function ({ context, exception = null, commitMessage = null }) { + debug('Start') + + let message = '' + if (exception) { + message = await getErrorMessage({ context, exception }) + } else { + message = await getSuccessMessage({ context, commitMessage }) + } + + return message +} + +module.exports = buildResultMessage diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..c6238e5 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,7 @@ +'use strict' + +const getenv = require('getenv') + +const config = JSON.parse(getenv('CONFIG', '{ "preset": "angular" }')) + +module.exports = config diff --git a/lib/getReleaseType.js b/lib/getReleaseType.js index b7a63b3..ed0e7e0 100644 --- a/lib/getReleaseType.js +++ b/lib/getReleaseType.js @@ -3,22 +3,8 @@ const debug = require('debug')('app:getReleaseType') const commitAnalyzer = require('@semantic-release/commit-analyzer') -const getenv = require('getenv') - -let config = { - preset: 'angular', - releaseRules: [ - { type: 'chore', release: 'patch' } - ], - parserOpts: { - noteKeywords: ['BREAKING CHANGES'] - } -} -const configEnv = getenv('COMMIT_CONFIG', '') -if (configEnv !== '') { - config = JSON.parse(configEnv) -} +const config = require('./config') const getReleaseType = async function (message = '') { debug('Start') diff --git a/lib/handleCheck.js b/lib/handleCheck.js deleted file mode 100644 index 03da549..0000000 --- a/lib/handleCheck.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict' - -const debug = require('debug')('app:handleCheck') -const getenv = require('getenv') - -const areAllChecksSuccessful = require('./areAllChecksSuccessful') -const containsPrMergeComment = require('./containsPrMergeComment') -const merge = require('./merge') - -const automergeBranches = getenv.array('AUTOMERGE_BRANCHES', 'string', []) - -const handleCheck = async function (context) { - debug('Start') - - const payload = context.payload.check_run || context.payload.check_suite || {} - const pullRequests = payload.pull_requests || [] - - for (const { number } of pullRequests) { - context.payload.pull_request = (await context.github.pulls.get( - context.repo({ pull_number: number }) - )).data - - if (context.payload.pull_request.state !== 'open') { - return debug('Pull request is not open. Skip.') - } - - const branchName = context.payload.pull_request.head.ref - debug(`Branch name: ${branchName}`) - if (!automergeBranches.includes(branchName) && !await containsPrMergeComment(context)) { - return debug('Branch is not in auto-merge list and no merge commit found. Skip.') - } - - if (await areAllChecksSuccessful(context)) { - debug('All checks are successful. Merging.') - await merge(context) - } else { - debug('Not all checks are successful. Skip merging.') - } - } -} - -module.exports = handleCheck diff --git a/lib/handleComment.js b/lib/handleComment.js deleted file mode 100644 index b5834ce..0000000 --- a/lib/handleComment.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict' - -const debug = require('debug')('app:handleComment') - -const merge = require('./merge') - -const handleComment = async function (context) { - debug('Start') - - if (context.payload.comment.body.toLowerCase().trim() !== '/merge') { - return debug('Comment is no \'/merge\' command. Skip.') - } - - const { number, pull_request: pullRequest } = context.payload.issue - - if (!pullRequest) { - return debug('Comment does not belong to a pull request. Skip.') - } - - context.payload.pull_request = (await context.github.pulls.get( - context.repo({ pull_number: number }) - )).data - - if (context.payload.pull_request.state !== 'open') { - return debug('Pull request is not open. Skip.') - } - - await merge(context) -} - -module.exports = handleComment diff --git a/lib/handleCommitStatus.js b/lib/handleCommitStatus.js deleted file mode 100644 index 89e5759..0000000 --- a/lib/handleCommitStatus.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict' - -const debug = require('debug')('app:handleCommitStatus') -const getenv = require('getenv') - -const areAllChecksSuccessful = require('./areAllChecksSuccessful') -const containsPrMergeComment = require('./containsPrMergeComment') -const merge = require('./merge') - -const automergeBranches = getenv.array('AUTOMERGE_BRANCHES', 'string', []) - -const handleCommitStatus = async function (context) { - debug('Start') - - for (const branch of context.payload.branches) { - debug(`Branch: ${branch.name}`) - - if (context.payload.state !== 'success') { - debug('Status not "success". Skip.') - continue - } - - if (branch.commit.sha !== context.payload.commit.sha) { - debug('Commit not head of branch. Skip.') - continue - } - - const options = context.repo({ head: `${context.repo().owner}:${branch.name}` }) - debug(`Context: ${JSON.stringify(options, null, 2)}`) - const pullRequests = await context.github.paginate( - context.github.pulls.list.endpoint.merge( - options - ) - ) - - // debug(`PULLS: ${JSON.stringify(pullRequests, null, 2)}`) - - for (const { number } of pullRequests) { - context.payload.pull_request = (await context.github.pulls.get( - context.repo({ pull_number: number }) - )).data - - // debug(`PULL: ${JSON.stringify(context.payload.pull_request, null, 2)}`) - - if (context.payload.pull_request.state !== 'open') { - return debug('Pull request is not open. Skip.') - } - - const branchName = context.payload.pull_request.head.ref - debug(`Branch name: ${branchName}`) - if (!automergeBranches.includes(branchName) && !await containsPrMergeComment(context)) { - return debug('Branch is not in auto-merge list. Skip.') - } - - if (await areAllChecksSuccessful(context)) { - debug('All checks are successful. Merging.') - await merge(context) - } else { - debug('Not all checks are successful. Skip merging.') - } - } - } -} - -module.exports = handleCommitStatus diff --git a/lib/handlePullRequestChange.js b/lib/handlePullRequestChange.js deleted file mode 100644 index e9eb464..0000000 --- a/lib/handlePullRequestChange.js +++ /dev/null @@ -1,47 +0,0 @@ -'use strict' - -const debug = require('debug')('app:handlePullRequestChange') -const getenv = require('getenv') - -const extractMessage = require('./extractMessage') -const getReleaseType = require('./getReleaseType') -const updateLabels = require('./updateLabels') -const updateReviewers = require('./updateReviewers') -const updateStatus = require('./updateStatus') - -const reviewUsersRules = getenv('REVIEW_USERS_RULES', '') -const reviewTeamsRules = getenv('REVIEW_TEAMS_RULES', '') -const labelSuffixMajor = getenv('LABEL_SUFFIX_MAJOR', 'major') -const labelSuffixMinor = getenv('LABEL_SUFFIX_MINOR', 'minor') -const labelSuffixPatch = getenv('LABEL_SUFFIX_PATCH', 'patch') -const labelPrefix = getenv('LABEL_PREFIX', 'release/') -const automergeBranches = getenv.array('AUTOMERGE_BRANCHES', 'string', []) -const automergeLabel = getenv('AUTOMERGE_LABEL', 'automatic-merge') -const wipLabel = getenv('WIP_LABEL', 'work-in-progress') - -const releaseTypeLabels = { - major: `${labelPrefix}${labelSuffixMajor}`, - minor: `${labelPrefix}${labelSuffixMinor}`, - patch: `${labelPrefix}${labelSuffixPatch}` -} - -const handlePullRequestChange = async function (context) { - debug('Start') - - const { title, body, head, number, state } = context.payload.pull_request - - if (state !== 'open') { - return debug('Pull request is not open. Skip.') - } - - const message = extractMessage(title, body) - const releaseType = await getReleaseType(message.full) - - debug('Release type:', releaseType) - - await updateStatus({ context, head, releaseType, messageBody: message.body }) - await updateLabels({ context, number, releaseType, title, releaseTypeLabel: releaseTypeLabels[releaseType], labelPrefix, automergeBranches, automergeLabel, wipLabel }) - await updateReviewers({ context, number, releaseType, reviewUsersRules, reviewTeamsRules }) -} - -module.exports = handlePullRequestChange diff --git a/lib/handlers/comment.js b/lib/handlers/comment.js new file mode 100644 index 0000000..78dc295 --- /dev/null +++ b/lib/handlers/comment.js @@ -0,0 +1,53 @@ +'use strict' + +const debug = require('debug')('app:comment') + +const buildResultMessage = require('../buildResultMessage') +const isMergeComment = require('../tests/isMergeComment') +const isPrOpen = require('../tests/isPrOpen') +const isWip = require('../tests/isWip') +const merge = require('../merge') + +const createErrorComment = async function (context, message) { + const body = await buildResultMessage({ + context, + exception: new Error(message) + }) + + await context.github.issues.createComment(context.repo({ + body, + issue_number: context.payload.pull_request.number + })) +} + +const comment = async function (context) { + debug('Start') + + if (!isMergeComment(context.payload.comment)) { + return debug('Comment is no merge command. Skip.') + } + + const { number, pull_request: pullRequest } = context.payload.issue + + if (!pullRequest) { + return debug('Comment does not belong to a pull request. Skip.') + } + + context.payload.pull_request = (await context.github.pulls.get( + context.repo({ pull_number: number }) + )).data + + if (!isPrOpen(context)) { + debug('Pull request is not open. Skip.') + return await createErrorComment(context, 'Pull request is not open.') + } + + if (isWip(context)) { + debug('WIP keyword found. Skip.') + return await createErrorComment(context, 'Please remove `WIP:` from the title to merge this pull request.') + } + + await merge(context) +} + +module.exports = comment diff --git a/lib/handlers/pullRequestChange/index.js b/lib/handlers/pullRequestChange/index.js new file mode 100644 index 0000000..43877d4 --- /dev/null +++ b/lib/handlers/pullRequestChange/index.js @@ -0,0 +1,21 @@ +'use strict' + +const debug = require('debug')('app:pullRequestChange') + +const merge = require('../../merge') +const updateLabels = require('./updateLabels') + +const pullRequestChange = async function (context) { + debug('Start') + + const { title, body, number, state } = context.payload.pull_request + + if (state !== 'open') { + return debug('Pull request is not open. Skip.') + } + + await updateLabels({ context, number, title, body }) + await merge(context) +} + +module.exports = pullRequestChange diff --git a/lib/updateLabels.js b/lib/handlers/pullRequestChange/updateLabels.js similarity index 57% rename from lib/updateLabels.js rename to lib/handlers/pullRequestChange/updateLabels.js index 37441b2..019d942 100644 --- a/lib/updateLabels.js +++ b/lib/handlers/pullRequestChange/updateLabels.js @@ -1,10 +1,30 @@ /* eslint-disable camelcase */ 'use strict' -const _ = require('lodash') const debug = require('debug')('app:updateLabels') -const updateLabels = async function ({ context, number, releaseType, commitType, title, releaseTypeLabel, labelPrefix, automergeBranches, automergeLabel, wipLabel }) { +const _ = require('lodash') +const getenv = require('getenv') + +const buildCommitMessage = require('../../buildCommitMessage') +const getReleaseType = require('../../getReleaseType') +const isWip = require('../../tests/isWip') + +const labelSuffixMajor = getenv('LABEL_SUFFIX_MAJOR', 'major') +const labelSuffixMinor = getenv('LABEL_SUFFIX_MINOR', 'minor') +const labelSuffixPatch = getenv('LABEL_SUFFIX_PATCH', 'patch') +const labelPrefix = getenv('LABEL_PREFIX', 'release/') +const automergeBranches = getenv.array('AUTOMERGE_BRANCHES', 'string', []) +const automergeLabel = getenv('AUTOMERGE_LABEL', 'automatic-merge') +const wipLabel = getenv('WIP_LABEL', 'work-in-progress') + +const releaseTypeLabels = { + major: `${labelPrefix}${labelSuffixMajor}`, + minor: `${labelPrefix}${labelSuffixMinor}`, + patch: `${labelPrefix}${labelSuffixPatch}` +} + +const updateLabels = async function ({ context, number, title, body }) { debug('Start') const newLabels = [] @@ -14,28 +34,38 @@ const updateLabels = async function ({ context, number, releaseType, commitType, context.repo({ issue_number: number }) )).data.map(item => item.name) - // Collect existing labels with prefix + // Collect existing labels for (const label of issueLabels) { if (label.startsWith(labelPrefix)) { oldLabels.push(label) } + if (label === automergeLabel) { + oldLabels.push(automergeLabel) + } + if (label === wipLabel) { + oldLabels.push(wipLabel) + } } - if (title.toLowerCase().startsWith('wip:')) { + if (isWip(context)) { debug('WIP branch detected.') newLabels.push(wipLabel) } else { - oldLabels.push(wipLabel) + const message = buildCommitMessage(title, body) + const releaseType = await getReleaseType(message.full) + + debug('Release type:', releaseType) + // Collect new labels for release type - if (releaseTypeLabel) { - newLabels.push(releaseTypeLabel) + if (releaseTypeLabels[releaseType]) { + newLabels.push(releaseTypeLabels[releaseType]) // Add label with type of merge commit (chore, fix, feat) followed by a colon or opening bracket const regex = /^[a-zA-Z]+[:(]/ if (title.match(regex)) { const labelSuffix = title.match(regex)[0].slice(0, -1) // Remove trailing ':' or '(' newLabels.push(`${labelPrefix}${labelSuffix}`) } else { - debug('Warning: Type label exists without a label for title prefix. Title prefix wrong?') + debug('Warning: No keyword for type of merge found in title.') } } } diff --git a/lib/handlers/status.js b/lib/handlers/status.js new file mode 100644 index 0000000..0e5e54f --- /dev/null +++ b/lib/handlers/status.js @@ -0,0 +1,44 @@ +'use strict' + +const debug = require('debug')('app:status') + +const merge = require('../merge') + +const status = async function (context) { + debug('Start') + + for (const branch of context.payload.branches) { + debug(`Branch: ${branch.name}`) + + if (context.payload.state !== 'success') { + debug('Status not "success". Skip.') + continue + } + + if (branch.commit.sha !== context.payload.commit.sha) { + debug('Commit not head of branch. Skip.') + continue + } + + const pullRequests = await context.github.paginate( + context.github.pulls.list.endpoint.merge( + context.repo({ head: `${context.repo().owner}:${branch.name}` }) + ) + ) + + for (const { number } of pullRequests) { + context.payload.pull_request = (await context.github.pulls.get( + context.repo({ pull_number: number }) + )).data + + if (context.payload.pull_request.state !== 'open') { + debug(`Pull request ${number} is not open. Skip.`) + continue + } + + await merge(context) + } + } +} + +module.exports = status diff --git a/lib/merge.js b/lib/merge.js deleted file mode 100644 index cf74ef5..0000000 --- a/lib/merge.js +++ /dev/null @@ -1,179 +0,0 @@ -'use strict' - -const debug = require('debug')('app:merge') - -const extractMessage = require('./extractMessage') -const getReleaseType = require('./getReleaseType') - -const merge = async function (context) { - debug('Start') - - const { title, head, body, number } = context.payload.pull_request - - // Check user permissions if merge is triggered by a comment - if (context.payload.comment) { - const level = await context.github.repos.getCollaboratorPermissionLevel( - context.repo({ username: context.payload.comment.user.login }) - ) - if (!level || !level.data || !['admin', 'maintain', 'write'].includes(level.data.permission)) { - debug(`User ${context.payload.comment.user.login} is not allowed to commit to the repository. Permission level: ${ - JSON.stringify(level) || 'not found' - }`) - // Add reaction - await context.github.reactions.createForIssueComment(context.repo({ - comment_id: context.payload.comment.id, - content: 'confused' - })) - // Add comment - await context.github.issues.createComment(context.repo({ - body: [ - '⚠️ Not Allowed to Merge ⚠️', - '', - 'Only users with admin or write permissions for the repository are allowed to merge the pull request.' - ].join('\n'), - issue_number: number - })) - return - } - } - - // Abort if pull request is not mergable - if (!context.payload.pull_request.mergeable) { - // Add reaction to comment - if (context.payload.comment) { - await context.github.reactions.createForIssueComment(context.repo({ - comment_id: context.payload.comment.id, - content: 'confused' - })) - } - // Add comment if merge is triggered by a comment - if (context.payload.comment) { - await context.github.issues.createComment(context.repo({ - body: [ - '⚠️ Unable to Merge Pull Request ⚠️', - '', - 'This pull request is not in a mergeable state at the moment.', - '', - 'Please resolve the problem and run the `/merge` command again.' - ].join('\n'), - issue_number: number - })) - } - return - } - - const message = extractMessage(title, body) - const releaseType = await getReleaseType(message.full) - - // Abort if title contains prefix 'wip:' (case insensitive) - if (message.title.toLowerCase().startsWith('wip:')) { - if (context.payload.comment) { - // Add reaction to user comment - await context.github.reactions.createForIssueComment(context.repo({ - comment_id: context.payload.comment.id, - content: 'confused' - })) - // Add comment if merge is triggered by a comment - await context.github.issues.createComment(context.repo({ - body: [ - '⚠️ Unable to Merge Work in Progress ⚠️', - '', - '"WIP:" must be removed from the title before it can be merged.', - '', - 'Please repeat the `/merge` command afterwards.' - ].join('\n'), - issue_number: number - })) - } - return - } - - // Add reaction to indicate progress - let progressReaction - if (context.payload.comment) { - progressReaction = await context.github.reactions.createForIssueComment(context.repo({ - comment_id: context.payload.comment.id, - content: 'rocket' - })) - } - - let successComment = releaseType ? [ - '🎉 Pull Request Merged 🎉', - '', - 'This pull request has been merged with the following commit message:', - '', - '``````', - message.full, - '``````', - '', - `A **${releaseType}** release is triggered.` - ] : [ - '🎉 Pull Request Merged 🎉', - '', - 'No type of pull request detected. Therefore, no release is triggered.' - ] - successComment = successComment.join('\n') - - try { - // Merge branch to master - await context.github.pulls.merge(context.repo({ - merge_method: 'squash', - pull_number: number, - sha: head.sha, - commit_message: message.body, - commit_title: message.title - })) - // Success comment - await context.github.issues.createComment(context.repo({ - body: successComment, - issue_number: number - })) - // Delete branch - await context.github.git.deleteRef(context.repo({ - ref: `heads/${head.ref}` - })) - } catch (ex) { - debug('An error occurred while merging:', ex) - - let errorMessage - try { - errorMessage = JSON.parse(ex.message).message - } catch (exJson) { - errorMessage = ex.message - } - - let errorTitle = '⚠️ Merging Pull Request Failed ⚠️' - let errorReaction = 'confused' - - // If error is caused by pending checks, notify user about automatic retries - if (errorMessage.match(/required status (check .* is|checks are) pending./i)) { - errorMessage += '\n\nMerge will be **automatically retried** after checks are completed.' - errorTitle = '⌛ Merging Pull Request Postponed ⌛' - errorReaction = 'eyes' - } - - if (context.payload.comment) { - // Remove progress reaction from comment - await context.github.reactions.delete({ - reaction_id: progressReaction.data.id - }) - // Add negative reaction - await context.github.reactions.createForIssueComment(context.repo({ - comment_id: context.payload.comment.id, - content: errorReaction - })) - } - - // Add comment - await context.github.issues.createComment(context.repo({ - body: [ - errorTitle, - '', - errorMessage - ].join('\n'), - issue_number: number - })) - } -} - -module.exports = merge diff --git a/lib/containsPrMergeComment.js b/lib/merge/getMergeComment.js similarity index 62% rename from lib/containsPrMergeComment.js rename to lib/merge/getMergeComment.js index ac2b068..ce5fd81 100644 --- a/lib/containsPrMergeComment.js +++ b/lib/merge/getMergeComment.js @@ -2,9 +2,11 @@ // See: https://dev.to/gr2m/github-api-how-to-retrieve-the-combined-pull-request-status-from-commit-statuses-check-runs-and-github-action-results-2cen -const debug = require('debug')('app:containsPrMergeComment') +const debug = require('debug')('app:getMergeComment') -const containsPrMergeComment = async function (context) { +const isMergeComment = require('../tests/isMergeComment') + +const getMergeComment = async function (context) { debug('Start') const issueContext = context.repo({ issue_number: context.payload.pull_request.number }) @@ -19,14 +21,13 @@ const containsPrMergeComment = async function (context) { comments.reverse() for (const comment of comments) { - if (comment.body.toLowerCase().trim() === '/merge') { - // Comment is needed in context to check permissions when merging - context.payload.comment = comment - return true + if (isMergeComment(comment)) { + return comment } } - return false + // No merge comment found + return null } -module.exports = containsPrMergeComment +module.exports = getMergeComment diff --git a/lib/merge/index.js b/lib/merge/index.js new file mode 100644 index 0000000..4e9f094 --- /dev/null +++ b/lib/merge/index.js @@ -0,0 +1,73 @@ +'use strict' + +const debug = require('debug')('app:merge') + +const buildCommitMessage = require('../buildCommitMessage') +const buildResultMessage = require('../buildResultMessage') +const isMergePermitted = require('../tests/isMergePermitted') +const isMergeTriggered = require('../tests/isMergeTriggered') +const isWip = require('../tests/isWip') + +const merge = async function (context) { + debug('Start') + + if (isWip(context)) { + return + } + + if (!await isMergeTriggered(context)) { + return + } + + if (!await isMergePermitted(context)) { + return + } + + // Add reaction to merge comment to indicate progress + if (context.payload.comment) { + await context.github.reactions.createForIssueComment(context.repo({ + comment_id: context.payload.comment.id, + content: 'rocket' + })) + } + + const { title, head, body, number } = context.payload.pull_request + const commitMessage = buildCommitMessage(title, body) + + try { + // Merge branch to master + await context.github.pulls.merge(context.repo({ + merge_method: 'squash', + pull_number: number, + sha: head.sha, + commit_message: commitMessage.body, + commit_title: commitMessage.title + })) + + // Success comment + await context.github.issues.createComment(context.repo({ + body: await buildResultMessage({ context, commitMessage }), + issue_number: number + })) + + // Delete branch + await context.github.git.deleteRef(context.repo({ + ref: `heads/${head.ref}` + })) + } catch (exception) { + debug(`An error occurred while merging: ${exception.message}`) + + // Skip message if checks are simply still running + if (exception.message && exception.message.match(/required status (check .* is|checks are) pending./i)) { + return + } + + // Error comment + await context.github.issues.createComment(context.repo({ + body: await buildResultMessage({ context, exception }), + issue_number: number + })) + } +} + +module.exports = merge diff --git a/lib/tests/isAutomergeBranch.js b/lib/tests/isAutomergeBranch.js new file mode 100644 index 0000000..e4b9fb0 --- /dev/null +++ b/lib/tests/isAutomergeBranch.js @@ -0,0 +1,12 @@ +'use strict' + +const getenv = require('getenv') + +const automergeBranches = getenv.array('AUTOMERGE_BRANCHES', 'string', []) + +const isAutomergeBranch = function (context) { + const branchName = context.payload.pull_request.head.ref + return automergeBranches.includes(branchName) +} + +module.exports = isAutomergeBranch diff --git a/lib/tests/isMergeComment.js b/lib/tests/isMergeComment.js new file mode 100644 index 0000000..bb6857c --- /dev/null +++ b/lib/tests/isMergeComment.js @@ -0,0 +1,7 @@ +'use strict' + +const isMergeComment = function (comment) { + return comment && comment.body && comment.body.toLowerCase().trim() === '/merge' +} + +module.exports = isMergeComment diff --git a/lib/tests/isMergePermitted.js b/lib/tests/isMergePermitted.js new file mode 100644 index 0000000..f4d2112 --- /dev/null +++ b/lib/tests/isMergePermitted.js @@ -0,0 +1,45 @@ +'use strict' + +const debug = require('debug')('app:isMergePermitted') + +const isMergePermitted = async function (context) { + debug('Start') + + // Check user permissions only if merge is triggered by a comment + if (!context.payload.comment) { + debug('No comment found. Skip checking permissions.') + return true + } + + const level = await context.github.repos.getCollaboratorPermissionLevel( + context.repo({ username: context.payload.comment.user.login }) + ) + + if (!level || !level.data || !['admin', 'maintain', 'write'].includes(level.data.permission)) { + debug(`User ${context.payload.comment.user.login} is not allowed to commit to the repository. Permission level: ${ + JSON.stringify(level) || 'not found' + }`) + + // Add reaction + await context.github.reactions.createForIssueComment(context.repo({ + comment_id: context.payload.comment.id, + content: 'confused' + })) + + // Add comment + await context.github.issues.createComment(context.repo({ + body: [ + '⚠️ Not Allowed to Merge ⚠️', + '', + 'Only users with write permissions for the repository are allowed to merge the pull request.' + ].join('\n'), + issue_number: context.payload.pull_request.number + })) + + return false + } + + return true +} + +module.exports = isMergePermitted diff --git a/lib/tests/isMergeTriggered.js b/lib/tests/isMergeTriggered.js new file mode 100644 index 0000000..75a4d7e --- /dev/null +++ b/lib/tests/isMergeTriggered.js @@ -0,0 +1,24 @@ +'use strict' + +const debug = require('debug')('app:isMergeTriggered') + +const getMergeComment = require('../merge/getMergeComment') +const isAutomergeBranch = require('./isAutomergeBranch') + +const isMergeTriggered = async function (context) { + debug('Start') + + const mergeComment = await getMergeComment(context) + + if (!isAutomergeBranch(context) && !mergeComment) { + debug('Branch is not in auto-merge list and no merge commit found. Skip.') + return false + } + + // Set context's comment to last merge comment for later use + context.payload.comment = mergeComment + + return true +} + +module.exports = isMergeTriggered diff --git a/lib/tests/isPrOpen.js b/lib/tests/isPrOpen.js new file mode 100644 index 0000000..07350a7 --- /dev/null +++ b/lib/tests/isPrOpen.js @@ -0,0 +1,7 @@ +'use strict' + +const isPrOpen = function (context) { + return context.payload.pull_request && context.payload.pull_request.state === 'open' +} + +module.exports = isPrOpen diff --git a/lib/tests/isWip.js b/lib/tests/isWip.js new file mode 100644 index 0000000..6c078ab --- /dev/null +++ b/lib/tests/isWip.js @@ -0,0 +1,7 @@ +'use strict' + +const isWip = function (context) { + return context.payload.pull_request && context.payload.pull_request.title.toLowerCase().startsWith('wip:') +} + +module.exports = isWip diff --git a/lib/updateReviewers.js b/lib/updateReviewers.js deleted file mode 100644 index 0e50844..0000000 --- a/lib/updateReviewers.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict' - -const _ = require('lodash') -const debug = require('debug')('app:updateReviewers') - -const evaluateRules = function (rules, labelsAndTopics, reviewersToAdd, reviewersToDelete) { - debug(`rules: ${rules}`) - for (const rule of rules.split(' ')) { - debug(`rule: ${rule}`) - const [condition, action] = rule.split('=') - debug(`condition: ${condition}`) - debug(`action: ${action}`) - - if (_.difference(condition.split(','), labelsAndTopics).length === 0) { - debug('condition met') - // condition is met by the given labels and topics - for (const reviewer of action.split(',')) { - debug(`reviewer: ${reviewer}`) - if (reviewer.startsWith('-')) { - debug(`delete: ${reviewer.slice(1)}`) - // Remove first char and mark for deletion - reviewersToDelete.push(reviewer.slice(1)) - } else if (reviewer.startsWith('+')) { - debug(`add: ${reviewer.slice(1)}`) - // Remove first char and mark for addition - reviewersToAdd.push(reviewer.slice(1)) - } else { - debug(`add: ${reviewer}`) - // Mark for addition as it is - reviewersToAdd.push(reviewer) - } - } - // Stop after first match - break - } - } -} - -const updateReviewers = async function ({ context, number, reviewUsersRules, reviewTeamsRules }) { - debug('Start') - - if (reviewUsersRules === '' && reviewTeamsRules === '') { - debug('No rules given. Skip.') - return - } - - const labelsAndTopics = [] - const reviewUsersToAdd = [] - const reviewUsersToDelete = [] - const reviewTeamsToAdd = [] - const reviewTeamsToDelete = [] - - const topics = await context.github.repos.listTopics( - context.repo({ - headers: { - accept: 'application/vnd.github.mercy-preview+json' - } - }) - ) - debug('Topics:', topics) - labelsAndTopics.push(...topics.data.names) - - const labels = (await context.github.issues.listLabelsOnIssue( - context.repo({ issue_number: number }) - )).data.map(item => item.name) - debug('Labels:', labels) - labelsAndTopics.push(...labels) - - evaluateRules(reviewUsersRules, labelsAndTopics, reviewUsersToAdd, reviewUsersToDelete) - evaluateRules(reviewTeamsRules, labelsAndTopics, reviewTeamsToAdd, reviewTeamsToDelete) - - if (reviewUsersToAdd.length > 0 || reviewTeamsToAdd.length > 0) { - debug('Users to add:', reviewUsersToAdd) - debug('Teams to add:', reviewTeamsToAdd) - try { - await context.github.pulls.createReviewRequest( - context.repo({ - pull_number: number, - reviewers: reviewUsersToAdd, - team_reviewers: reviewTeamsToAdd - }) - ) - } catch (err) { - // Intentionally ignore errors - } - } - - if (reviewUsersToDelete.length > 0 || reviewTeamsToDelete.length > 0) { - debug('Users to remove:', reviewUsersToDelete) - debug('Teams to remove:', reviewTeamsToDelete) - try { - await context.github.pulls.deleteReviewRequest( - context.repo({ - pull_number: number, - reviewers: reviewUsersToDelete, - team_reviewers: reviewTeamsToDelete - }) - ) - } catch (err) { - // Intentionally ignore errors - } - } -} - -module.exports = updateReviewers diff --git a/lib/updateStatus.js b/lib/updateStatus.js deleted file mode 100644 index efd6cc0..0000000 --- a/lib/updateStatus.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict' - -const debug = require('debug')('app:updateStatus') - -const updateStatus = async function ({ context, head, releaseType, messageBody = '' }) { - debug('Start') - - try { - await context.github.repos.createStatus(context.repo({ - context: 'Conventional Mergebot', - description: releaseType - ? `'/merge' triggers a ${releaseType} release.` - : '\'/merge\' will not trigger a release.', - target_url: 'https://conventionalcommits.org', - sha: head.sha, - state: 'success' - })) - } catch (err) { - // Intentionally ignore errors - } -} - -module.exports = updateStatus diff --git a/package-lock.json b/package-lock.json index c506144..98517e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2029,23 +2029,6 @@ } } }, - "@serverless/cli": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@serverless/cli/-/cli-1.4.0.tgz", - "integrity": "sha512-YqlCiYmRFeGksw6XJaXbigIDlktc7OfRuVpyPB7IZgkCJ9mUlBmvyWdwqJEQdkUz0xPTGsd4Jd/XSrwyiw1Brg==", - "requires": { - "@serverless/core": "^1.0.0", - "@serverless/template": "^1.1.0", - "ansi-escapes": "^4.2.0", - "chalk": "^2.4.2", - "chokidar": "^3.0.2", - "dotenv": "^8.0.0", - "figures": "^3.0.0", - "minimist": "^1.2.0", - "prettyoutput": "^1.2.0", - "strip-ansi": "^5.2.0" - } - }, "@serverless/component-metrics": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@serverless/component-metrics/-/component-metrics-1.0.8.tgz", @@ -2055,6 +2038,80 @@ "shortid": "^2.2.14" } }, + "@serverless/components": { + "version": "2.30.12", + "resolved": "https://registry.npmjs.org/@serverless/components/-/components-2.30.12.tgz", + "integrity": "sha512-cBqo2FcvKRIUF9IR8lwvTzQIdpx5AnDbGJRCz1xCFzLgav/Yaow46TY7eQApDEZI0/Pf6E6mSRAdGFx3PgxA0w==", + "requires": { + "@serverless/inquirer": "^1.1.0", + "@serverless/platform-client": "^0.25.7", + "@serverless/platform-client-china": "^1.0.12", + "@serverless/platform-sdk": "^2.3.0", + "adm-zip": "^0.4.14", + "ansi-escapes": "^4.3.1", + "axios": "^0.19.2", + "chalk": "^2.4.2", + "chokidar": "^3.3.1", + "dotenv": "^8.2.0", + "figures": "^3.2.0", + "fs-extra": "^8.1.0", + "globby": "^10.0.2", + "graphlib": "^2.1.8", + "https-proxy-agent": "^5.0.0", + "ini": "^1.3.5", + "js-yaml": "^3.13.1", + "minimist": "^1.2.5", + "moment": "^2.24.0", + "open": "^7.0.3", + "prettyoutput": "^1.2.0", + "ramda": "^0.26.1", + "semver": "^7.3.2", + "strip-ansi": "^5.2.0", + "traverse": "^0.6.6", + "uuid": "^3.4.0", + "ws": "^7.2.3" + }, + "dependencies": { + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "globby": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", + "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", + "requires": { + "@types/glob": "^7.1.1", + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.0.3", + "glob": "^7.1.3", + "ignore": "^5.1.1", + "merge2": "^1.2.3", + "slash": "^3.0.0" + } + }, + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, "@serverless/core": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@serverless/core/-/core-1.1.2.tgz", @@ -16606,60 +16663,21 @@ "yargs-parser": "^18.1.3" }, "dependencies": { - "@serverless/components": { - "version": "2.30.12", - "resolved": "https://registry.npmjs.org/@serverless/components/-/components-2.30.12.tgz", - "integrity": "sha512-cBqo2FcvKRIUF9IR8lwvTzQIdpx5AnDbGJRCz1xCFzLgav/Yaow46TY7eQApDEZI0/Pf6E6mSRAdGFx3PgxA0w==", - "requires": { - "@serverless/inquirer": "^1.1.0", - "@serverless/platform-client": "^0.25.7", - "@serverless/platform-client-china": "^1.0.12", - "@serverless/platform-sdk": "^2.3.0", - "adm-zip": "^0.4.14", - "ansi-escapes": "^4.3.1", - "axios": "^0.19.2", + "@serverless/cli": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@serverless/cli/-/cli-1.4.0.tgz", + "integrity": "sha512-YqlCiYmRFeGksw6XJaXbigIDlktc7OfRuVpyPB7IZgkCJ9mUlBmvyWdwqJEQdkUz0xPTGsd4Jd/XSrwyiw1Brg==", + "requires": { + "@serverless/core": "^1.0.0", + "@serverless/template": "^1.1.0", + "ansi-escapes": "^4.2.0", "chalk": "^2.4.2", - "chokidar": "^3.3.1", - "dotenv": "^8.2.0", - "figures": "^3.2.0", - "fs-extra": "^8.1.0", - "globby": "^10.0.2", - "graphlib": "^2.1.8", - "https-proxy-agent": "^5.0.0", - "ini": "^1.3.5", - "js-yaml": "^3.13.1", - "minimist": "^1.2.5", - "moment": "^2.24.0", - "open": "^7.0.3", + "chokidar": "^3.0.2", + "dotenv": "^8.0.0", + "figures": "^3.0.0", + "minimist": "^1.2.0", "prettyoutput": "^1.2.0", - "ramda": "^0.26.1", - "semver": "^7.3.2", - "strip-ansi": "^5.2.0", - "traverse": "^0.6.6", - "uuid": "^3.4.0", - "ws": "^7.2.3" - }, - "dependencies": { - "globby": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz", - "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==", - "requires": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - } - }, - "semver": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", - "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" - } + "strip-ansi": "^5.2.0" } }, "ansi-regex": { @@ -16742,19 +16760,6 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, - "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, "global-dirs": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", diff --git a/serverless.yml b/serverless.yml index a4add29..810a192 100644 --- a/serverless.yml +++ b/serverless.yml @@ -6,13 +6,12 @@ plugins: provider: name: aws logRetentionInDays: 1 - profile: serverless runtime: nodejs12.x stackTags: Business Unit: COM stage: ${opt:stage, 'dev'} # Set to maximum timeout for API Gateway functions - timeout: 30 + timeout: 60 memorySize: ${file(config/config.${self:provider.stage}.json):memorySize} functions: @@ -23,8 +22,7 @@ functions: WEBHOOK_SECRET: ${file(config/config.${self:provider.stage}.json):WEBHOOK_SECRET} APP_ID: ${file(config/config.${self:provider.stage}.json):APP_ID} PRIVATE_KEY_PATH: keys/private-key.${self:provider.stage}.pem - REVIEW_TEAMS_RULES: ${file(config/config.${self:provider.stage}.json):REVIEW_TEAMS_RULES} events: - http: - path: / - method: post + path: / + method: post diff --git a/test/unit/lib/extractMessage.test.js b/test/unit/lib/buildCommitMessage.test.js similarity index 70% rename from test/unit/lib/extractMessage.test.js rename to test/unit/lib/buildCommitMessage.test.js index e124b6c..c8fd521 100644 --- a/test/unit/lib/extractMessage.test.js +++ b/test/unit/lib/buildCommitMessage.test.js @@ -1,15 +1,15 @@ -const extractMessage = require('../../../lib/extractMessage') +const buildCommitMessage = require('../../../lib/buildCommitMessage') -describe('extractMessage', () => { +describe('buildCommitMessage', () => { it('is a function.', async () => { - expect(extractMessage).toBeInstanceOf(Function) + expect(buildCommitMessage).toBeInstanceOf(Function) }) it('works with empty messages.', async () => { const title = '' const body = '' - expect(extractMessage(title, body, [])).toEqual({ title: '', body: '', full: '' }) + expect(buildCommitMessage(title, body, [])).toEqual({ title: '', body: '', full: '' }) }) it('works with a complete messages.', async () => { @@ -17,18 +17,18 @@ describe('extractMessage', () => { const body = [ '### Details', 'details1', - '### Breaking Changes', + '### BREAKING CHANGE', 'breaking1', '### References', 'ref1' ].join('\n') - expect(extractMessage(title, body, [])).toEqual({ + expect(buildCommitMessage(title, body, [])).toEqual({ title: 'fix: Test', body: [ 'details1', '', - 'BREAKING CHANGES: breaking1', + 'BREAKING CHANGE: breaking1', '', 'ref1' ].join('\n'), @@ -37,7 +37,7 @@ describe('extractMessage', () => { '', 'details1', '', - 'BREAKING CHANGES: breaking1', + 'BREAKING CHANGE: breaking1', '', 'ref1' ].join('\n') @@ -48,11 +48,11 @@ describe('extractMessage', () => { const title = 'fix: Test' const body = [ '### Details', - '### Breaking Changes', + '### BREAKING CHANGE', '### References' ].join('\n') - expect(extractMessage(title, body, [])).toEqual({ + expect(buildCommitMessage(title, body, [])).toEqual({ title: 'fix: Test', body: '', full: 'fix: Test' @@ -65,7 +65,7 @@ describe('extractMessage', () => { const body = '' const expected = { title: 'feat: Test', body: '', full: 'feat: Test' } - expect(extractMessage(title, body, [])).toEqual(expected) + expect(buildCommitMessage(title, body, [])).toEqual(expected) }) }) @@ -83,7 +83,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('extracts from the start of the body.', async () => { @@ -100,7 +100,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('extracts from the middle of the body.', async () => { @@ -120,7 +120,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('ignores case when searching for section.', async () => { @@ -134,7 +134,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('allows singular when searching for section.', async () => { @@ -146,7 +146,7 @@ describe('extractMessage', () => { 'message 1' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('ignores # in the middle of the section.', async () => { @@ -168,7 +168,7 @@ describe('extractMessage', () => { 'message ### 123' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('trims the contents.', async () => { @@ -190,30 +190,30 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) }) - describe('breaking changes', () => { + describe('BREAKING CHANGE', () => { it('extracts from the end of the body.', async () => { const body = [ 'line 1', 'line 2', - '## Breaking Changes', + '## BREAKING CHANGE', 'message 1', 'message 2' ].join('\n') const expected = [ - 'BREAKING CHANGES: message 1', + 'BREAKING CHANGE: message 1', 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('extracts from the start of the body.', async () => { const body = [ - '## Breaking Changes', + '## BREAKING CHANGE', 'message 1', 'message 2', '## Other Section', @@ -221,11 +221,11 @@ describe('extractMessage', () => { 'line 2' ].join('\n') const expected = [ - 'BREAKING CHANGES: message 1', + 'BREAKING CHANGE: message 1', 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('extracts from the middle of the body.', async () => { @@ -233,7 +233,7 @@ describe('extractMessage', () => { '## First Section', 'line 1', 'line 2', - '## Breaking Changes', + '## BREAKING CHANGE', 'message 1', 'message 2', '## Other Section', @@ -241,25 +241,25 @@ describe('extractMessage', () => { 'line 2' ].join('\n') const expected = [ - 'BREAKING CHANGES: message 1', + 'BREAKING CHANGE: message 1', 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('ignores case when searching for section.', async () => { const body = [ - '## breaKING chaNGES', + '## BREAKING CHANGE', 'message 1', 'message 2' ].join('\n') const expected = [ - 'BREAKING CHANGES: message 1', + 'BREAKING CHANGE: message 1', 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('allows singular when searching for section.', async () => { @@ -268,10 +268,10 @@ describe('extractMessage', () => { 'message 1' ].join('\n') const expected = [ - 'BREAKING CHANGES: message 1' + 'BREAKING CHANGE: message 1' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('allows \'Breaking\' as shortcut when searching for section.', async () => { @@ -280,10 +280,10 @@ describe('extractMessage', () => { 'message 1' ].join('\n') const expected = [ - 'BREAKING CHANGES: message 1' + 'BREAKING CHANGE: message 1' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('ignores # in the middle of the section.', async () => { @@ -291,7 +291,7 @@ describe('extractMessage', () => { '## First Section', 'line 1', 'line 2', - '## Breaking Changes', + '## BREAKING CHANGE', 'message #1', 'message #2', 'message ### 123', @@ -300,12 +300,12 @@ describe('extractMessage', () => { 'line 2' ].join('\n') const expected = [ - 'BREAKING CHANGES: message #1', + 'BREAKING CHANGE: message #1', 'message #2', 'message ### 123' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('trims the contents.', async () => { @@ -313,7 +313,7 @@ describe('extractMessage', () => { '## First Section', 'line 1', 'line 2', - '## Breaking Changes', + '## BREAKING CHANGE', ' ', 'message 1', 'message 2', @@ -323,11 +323,11 @@ describe('extractMessage', () => { 'line 2' ].join('\n') const expected = [ - 'BREAKING CHANGES: message 1', + 'BREAKING CHANGE: message 1', 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) }) @@ -345,7 +345,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('extracts from the start of the body.', async () => { @@ -362,7 +362,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('extracts from the middle of the body.', async () => { @@ -382,7 +382,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('ignores case when searching for section.', async () => { @@ -396,7 +396,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('allows \'Ref\' as shortcut when searching for section.', async () => { @@ -408,7 +408,7 @@ describe('extractMessage', () => { 'message 1' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('ignores # in the middle of the section.', async () => { @@ -430,7 +430,7 @@ describe('extractMessage', () => { 'message ### 123' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) it('trims the contents.', async () => { @@ -452,7 +452,7 @@ describe('extractMessage', () => { 'message 2' ].join('\n') - expect(extractMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) + expect(buildCommitMessage('', body, [])).toEqual({ title: '', body: expected, full: expected }) }) }) }) diff --git a/test/unit/lib/getReleaseType.test.js b/test/unit/lib/getReleaseType.test.js index 8a905b5..7f5df25 100644 --- a/test/unit/lib/getReleaseType.test.js +++ b/test/unit/lib/getReleaseType.test.js @@ -9,7 +9,7 @@ describe('getReleaseType', () => { const message = [ 'feat: message 1', '', - 'BREAKING CHANGES: message 2' + 'BREAKING CHANGE: message 2' ].join('\n') expect(await getReleaseType(message)).toEqual('major') diff --git a/test/unit/lib/handlePullRequestChange.test.js b/test/unit/lib/handlePullRequestChange.test.js deleted file mode 100644 index 315eae0..0000000 --- a/test/unit/lib/handlePullRequestChange.test.js +++ /dev/null @@ -1,7 +0,0 @@ -const handlePullRequestChange = require('../../../lib/handlePullRequestChange') - -describe('handlePullRequestChange', () => { - it('is a function.', async () => { - expect(handlePullRequestChange).toBeInstanceOf(Function) - }) -}) diff --git a/test/unit/lib/handleComment.test.js b/test/unit/lib/handlers/comment.test.js similarity index 83% rename from test/unit/lib/handleComment.test.js rename to test/unit/lib/handlers/comment.test.js index 54ebedd..934b1dc 100644 --- a/test/unit/lib/handleComment.test.js +++ b/test/unit/lib/handlers/comment.test.js @@ -1,9 +1,19 @@ +'use strict' + +const _ = require('lodash') + let mergeCalled -jest.doMock('../../../lib/merge', () => { +jest.doMock('../../../../lib/merge', () => { return async () => { mergeCalled = true } }) +jest.doMock('../../../../lib/buildResultMessage', () => { + return async () => { return '' } +}) +jest.doMock('../../../../lib/tests/isWip', () => { + return () => { return false } +}) -const handleComment = require('../../../lib/handleComment') +const handleComment = require('../../../../lib/handlers/comment') const contextTemplate = { payload: { @@ -17,6 +27,9 @@ const contextTemplate = { }, repo: () => {}, github: { + issues: { + createComment: () => { } + }, pulls: { get: () => { return { @@ -33,7 +46,7 @@ let context describe('handleComment', () => { beforeEach(() => { // eslint-disable-line mergeCalled = false - context = Object.assign({}, contextTemplate) + context = _.cloneDeep(contextTemplate) }) it('is a function.', async () => { diff --git a/test/unit/lib/handlers/pullRequestChange.test.js b/test/unit/lib/handlers/pullRequestChange.test.js new file mode 100644 index 0000000..7523def --- /dev/null +++ b/test/unit/lib/handlers/pullRequestChange.test.js @@ -0,0 +1,7 @@ +const pullRequestChange = require('../../../../lib/handlers/pullRequestChange') + +describe('pullRequestChange', () => { + it('is a function.', async () => { + expect(pullRequestChange).toBeInstanceOf(Function) + }) +}) diff --git a/test/unit/lib/updateReviewers.test.js b/test/unit/lib/updateReviewers.test.js deleted file mode 100644 index c8fab77..0000000 --- a/test/unit/lib/updateReviewers.test.js +++ /dev/null @@ -1,198 +0,0 @@ -const updateReviewers = require('../../../lib/updateReviewers') - -let topics -let labels -let userReviewRequestsToAdd -let teamReviewRequestsToAdd -let userReviewRequestsToDelete -let teamReviewRequestsToDelete - -const contextTemplate = { - payload: { - comment: { - body: '' - }, - issue: { - number: 1, - pull_request: { } - } - }, - repo: (data) => { return data }, - github: { - issues: { - listLabelsOnIssue: () => { - return { data: labels.map((label) => { return { name: label } }) } - } - }, - pulls: { - createReviewRequest: (data) => { - userReviewRequestsToAdd.push(...data.reviewers) - teamReviewRequestsToAdd.push(...data.team_reviewers) - }, - deleteReviewRequest: (data) => { - userReviewRequestsToDelete.push(...data.reviewers) - teamReviewRequestsToDelete.push(...data.team_reviewers) - } - }, - repos: { - listTopics: () => { - return { - data: { - names: topics - } - } - } - } - } -} -let context - -describe('updateReviewers', () => { - beforeEach(() => { // eslint-disable-line - context = Object.assign({}, contextTemplate) - userReviewRequestsToAdd = [] - teamReviewRequestsToAdd = [] - userReviewRequestsToDelete = [] - teamReviewRequestsToDelete = [] - labels = [] - topics = [] - }) - - it('is a function.', async () => { - expect(updateReviewers).toBeInstanceOf(Function) - }) - - it('does nothing if no rules are defined.', async () => { - const reviewUsersRules = '' - const reviewTeamsRules = '' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual([]) - expect(teamReviewRequestsToAdd).toStrictEqual([]) - expect(userReviewRequestsToDelete).toStrictEqual([]) - expect(teamReviewRequestsToDelete).toStrictEqual([]) - }) - - describe('labels', () => { - it('are used to add user.', async () => { - labels = ['test'] - const reviewUsersRules = 'test=+user-test' - const reviewTeamsRules = '' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual(['user-test']) - expect(teamReviewRequestsToAdd).toStrictEqual([]) - expect(userReviewRequestsToDelete).toStrictEqual([]) - expect(teamReviewRequestsToDelete).toStrictEqual([]) - }) - - it('are used to add team.', async () => { - labels = ['test'] - const reviewUsersRules = '' - const reviewTeamsRules = 'test=+team-test' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual([]) - expect(teamReviewRequestsToAdd).toStrictEqual(['team-test']) - expect(userReviewRequestsToDelete).toStrictEqual([]) - expect(teamReviewRequestsToDelete).toStrictEqual([]) - }) - - it('are used to delete user.', async () => { - labels = ['test'] - const reviewUsersRules = 'test=-user-test' - const reviewTeamsRules = '' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual([]) - expect(teamReviewRequestsToAdd).toStrictEqual([]) - expect(userReviewRequestsToDelete).toStrictEqual(['user-test']) - expect(teamReviewRequestsToDelete).toStrictEqual([]) - }) - - it('are used to delete team.', async () => { - labels = ['test'] - const reviewUsersRules = '' - const reviewTeamsRules = 'test=-team-test' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual([]) - expect(teamReviewRequestsToAdd).toStrictEqual([]) - expect(userReviewRequestsToDelete).toStrictEqual([]) - expect(teamReviewRequestsToDelete).toStrictEqual(['team-test']) - }) - }) - - describe('topics', () => { - it('are used to add user.', async () => { - topics = ['test'] - const reviewUsersRules = 'test=+user-test' - const reviewTeamsRules = '' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual(['user-test']) - expect(teamReviewRequestsToAdd).toStrictEqual([]) - expect(userReviewRequestsToDelete).toStrictEqual([]) - expect(teamReviewRequestsToDelete).toStrictEqual([]) - }) - - it('are used to add team.', async () => { - topics = ['test'] - const reviewUsersRules = '' - const reviewTeamsRules = 'test=+team-test' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual([]) - expect(teamReviewRequestsToAdd).toStrictEqual(['team-test']) - expect(userReviewRequestsToDelete).toStrictEqual([]) - expect(teamReviewRequestsToDelete).toStrictEqual([]) - }) - - it('are used to delete user.', async () => { - topics = ['test'] - const reviewUsersRules = 'test=-user-test' - const reviewTeamsRules = '' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual([]) - expect(teamReviewRequestsToAdd).toStrictEqual([]) - expect(userReviewRequestsToDelete).toStrictEqual(['user-test']) - expect(teamReviewRequestsToDelete).toStrictEqual([]) - }) - - it('are used to delete team.', async () => { - topics = ['test'] - const reviewUsersRules = '' - const reviewTeamsRules = 'test=-team-test' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual([]) - expect(teamReviewRequestsToAdd).toStrictEqual([]) - expect(userReviewRequestsToDelete).toStrictEqual([]) - expect(teamReviewRequestsToDelete).toStrictEqual(['team-test']) - }) - }) - - it('lables and topics can be used together to update review requests.', async () => { - topics = ['team-com'] - labels = ['release/chore', 'release/patch'] - const reviewUsersRules = '' - const reviewTeamsRules = 'team-com,release/feat=+com team-com,release/fix=+com team-com=-com' - - await updateReviewers({ context, number: 1, reviewUsersRules, reviewTeamsRules }) - - expect(userReviewRequestsToAdd).toStrictEqual([]) - expect(teamReviewRequestsToAdd).toStrictEqual([]) - expect(userReviewRequestsToDelete).toStrictEqual([]) - expect(teamReviewRequestsToDelete).toStrictEqual(['com']) - }) -})