diff --git a/README.md b/README.md index c0463754..f2f37462 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ Publish a [GitHub release](https://help.github.com/articles/about-releases), opt ## success -Add a comment to each GitHub issue or pull request resolved by the release. +Add a comment to each GitHub issue or pull request resolved by the release and close issues previously open by the [fail](#fail) step. + +## fail + +Open or update a GitHub issue with informations about the errors that caused the release to fail. ## Configuration @@ -42,12 +46,16 @@ Follow the [Creating a personal access token for the command line](https://help. ### Options -| Option | Description | Default | -|-----------------------|------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| -| `githubUrl` | The GitHub Enterprise endpoint. | `GH_URL` or `GITHUB_URL` environment variable. | -| `githubApiPathPrefix` | The GitHub Enterprise API prefix. | `GH_PREFIX` or `GITHUB_PREFIX` environment variable. | -| `assets` | An array of files to upload to the release. See [assets](#assets). | - | -| `successComment` | The comment added to each issue and pull request resolved by the release. See [successComment](#successcomment). | `:tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on [GitHub release]()` | +| Option | Description | Default | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `githubUrl` | The GitHub Enterprise endpoint. | `GH_URL` or `GITHUB_URL` environment variable. | +| `githubApiPathPrefix` | The GitHub Enterprise API prefix. | `GH_PREFIX` or `GITHUB_PREFIX` environment variable. | +| `assets` | An array of files to upload to the release. See [assets](#assets). | - | +| `successComment` | The comment added to each issue and pull request resolved by the release. See [successComment](#successcomment). | `:tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on [GitHub release]()` | +| `failComment` | The content of the issue created when a release fails. See [failComment](#failcomment). | Friendly message with links to **semantic-release** documentation and support, with the list of errors that caused the release to fail. | +| `failTitle` | The title of the issue created when a release fails. | `The automated release is failing :rotating_light:` | +| `labels` | The [labels](https://help.github.com/articles/about-labels) to add to the issue created when a release fails. | `['semantic-release']` | +| `assignees` | The [assignees](https://help.github.com/articles/assigning-issues-and-pull-requests-to-other-github-users) to add to the issue created when a release fails. | - | #### assets @@ -102,6 +110,23 @@ The `successComment` `This ${issue.pull_request ? 'pull request' : 'issue'} is i > This pull request is included in version 1.0.0 +#### failComment + +The message for the issue content is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available: + +| Parameter | Description | +|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `branch` | The branch from which the release had failed. | +| `errors` | An `Array` of [SemanticReleaseError](https://github.com/semantic-release/error). Each error has the `message`, `code`, `pluginName` and `details` properties.
`pluginName` contains the package name of the plugin that threw the error.
`details` contains a informations about the error formatted in markdown. | + +##### failComment examples + +The `failComment` `This release from branch ${branch} had failed due to the following errors:\n- ${errors.map(err => err.message).join('\\n- ')}` will generate the comment: + +> This release from branch master had failed due to the following errors: +> - Error message 1 +> - Error message 2 + ### Usage The plugins are used by default by [Semantic-release](https://github.com/semantic-release/semantic-release) so no @@ -114,7 +139,8 @@ Each individual plugin can be disabled, replaced or used with other plugins in t "release": { "verifyConditions": ["@semantic-release/github", "@semantic-release/npm", "verify-other-condition"], "publish": ["@semantic-release/npm", "@semantic-release/github", "other-publish"], - "success": ["@semantic-release/github", "other-success"] + "success": ["@semantic-release/github", "other-success"], + "fail": ["@semantic-release/github", "other-fail"] } } ``` diff --git a/index.js b/index.js index 6b4dece8..f9818302 100644 --- a/index.js +++ b/index.js @@ -1,22 +1,25 @@ const verifyGitHub = require('./lib/verify'); const publishGitHub = require('./lib/publish'); const successGitHub = require('./lib/success'); +const failGitHub = require('./lib/fail'); let verified; async function verifyConditions(pluginConfig, context) { const {options} = context; - // If the GitHub publish plugin is used and has `assets` configured, validate it now in order to prevent any release if the configuration is wrong + // If the GitHub publish plugin is used and has `assets`, `successComment`, `failComment`, `failTitle`, `labels` or `assignees` configured, validate it now in order to prevent any release if the configuration is wrong if (options.publish) { - const publishPlugin = (Array.isArray(options.publish) ? options.publish : [options.publish]).find( - config => config.path && config.path === '@semantic-release/github' - ); - if (publishPlugin && publishPlugin.assets) { - pluginConfig.assets = publishPlugin.assets; - } - if (publishPlugin && publishPlugin.successComment) { - pluginConfig.successComment = publishPlugin.successComment; - } + const publishPlugin = + (Array.isArray(options.publish) ? options.publish : [options.publish]).find( + config => config.path && config.path === '@semantic-release/github' + ) || {}; + + pluginConfig.assets = pluginConfig.assets || publishPlugin.assets; + pluginConfig.successComment = pluginConfig.successComment || publishPlugin.successComment; + pluginConfig.failComment = pluginConfig.failComment || publishPlugin.failComment; + pluginConfig.failTitle = pluginConfig.failTitle || publishPlugin.failTitle; + pluginConfig.labels = pluginConfig.labels || publishPlugin.labels; + pluginConfig.assignees = pluginConfig.assignees || publishPlugin.assignees; } await verifyGitHub(pluginConfig, context); @@ -39,4 +42,12 @@ async function success(pluginConfig, context) { await successGitHub(pluginConfig, context); } -module.exports = {verifyConditions, publish, success}; +async function fail(pluginConfig, context) { + if (!verified) { + await verifyGitHub(pluginConfig, context); + verified = true; + } + await failGitHub(pluginConfig, context); +} + +module.exports = {verifyConditions, publish, success, fail}; diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 1501a3fa..0571634c 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -23,6 +23,38 @@ Your configuration for the \`assets\` option is \`${stringify(assets)}\`.`, )}) option, if defined, must be a non empty \`String\`. Your configuration for the \`successComment\` option is \`${stringify(successComment)}\`.`, + }), + EINVALIDFAILTITLE: ({failTitle}) => ({ + message: 'Invalid `failTitle` option.', + details: `The [failTitle option](${linkify( + 'README.md#failtitle' + )}) option, if defined, must be a non empty \`String\`. + +Your configuration for the \`failTitle\` option is \`${stringify(failTitle)}\`.`, + }), + EINVALIDFAILCOMMENT: ({failComment}) => ({ + message: 'Invalid `failComment` option.', + details: `The [failComment option](${linkify( + 'README.md#failcomment' + )}) option, if defined, must be a non empty \`String\`. + +Your configuration for the \`failComment\` option is \`${stringify(failComment)}\`.`, + }), + EINVALIDLABELS: ({labels}) => ({ + message: 'Invalid `labels` option.', + details: `The [labels option](${linkify( + 'README.md#labels' + )}) option, if defined, must be an \`Array\` of non empty \`String\`. + +Your configuration for the \`labels\` option is \`${stringify(labels)}\`.`, + }), + EINVALIDASSIGNEES: ({assignees}) => ({ + message: 'Invalid `assignees` option.', + details: `The [assignees option](${linkify( + 'README.md#assignees' + )}) option must be an \`Array\` of non empty \`Strings\`. + +Your configuration for the \`assignees\` option is \`${stringify(assignees)}\`.`, }), EINVALIDGITHUBURL: () => ({ message: 'The git repository URL is not a valid GitHub URL.', diff --git a/lib/definitions/sr-issue-id.js b/lib/definitions/sr-issue-id.js new file mode 100644 index 00000000..f53880a5 --- /dev/null +++ b/lib/definitions/sr-issue-id.js @@ -0,0 +1 @@ +module.exports = ''; diff --git a/lib/fail.js b/lib/fail.js new file mode 100644 index 00000000..7d80f7d2 --- /dev/null +++ b/lib/fail.js @@ -0,0 +1,31 @@ +const {template} = require('lodash'); +const parseGithubUrl = require('parse-github-url'); +const debug = require('debug')('semantic-release:github'); +const ISSUE_ID = require('./definitions/sr-issue-id'); +const resolveConfig = require('./resolve-config'); +const getClient = require('./get-client'); +const findSRIssues = require('./find-sr-issues'); +const getFailComment = require('./get-fail-comment'); + +module.exports = async (pluginConfig, {options: {branch, repositoryUrl}, errors, logger}) => { + const {githubToken, githubUrl, githubApiPathPrefix, failComment, failTitle, labels, assignees} = resolveConfig( + pluginConfig + ); + const {name: repo, owner} = parseGithubUrl(repositoryUrl); + const github = getClient(githubToken, githubUrl, githubApiPathPrefix); + const body = failComment ? template(failComment)({branch, errors}) : getFailComment(branch, errors); + const srIssue = (await findSRIssues(github, failTitle, owner, repo))[0]; + + if (srIssue) { + logger.log('Found existing semantic-release issue #%d.', srIssue.number); + const comment = {owner, repo, number: srIssue.number, body}; + debug('create comment: %O', comment); + const {data: {html_url: url}} = await github.issues.createComment(comment); + logger.log('Added comment to issue #%d: %s.', srIssue.number, url); + } else { + const newIssue = {owner, repo, title: failTitle, body: `${body}\n\n${ISSUE_ID}`, labels, assignees}; + debug('create issue: %O', newIssue); + const {data: {html_url: url, number}} = await github.issues.create(newIssue); + logger.log('Created issue #%d: %s.', number, url); + } +}; diff --git a/lib/find-sr-issues.js b/lib/find-sr-issues.js new file mode 100644 index 00000000..e98b5013 --- /dev/null +++ b/lib/find-sr-issues.js @@ -0,0 +1,9 @@ +const ISSUE_ID = require('./definitions/sr-issue-id'); + +module.exports = async (github, title, owner, repo) => { + const {data: {items: issues}} = await github.search.issues({ + q: `title:${title}+repo:${owner}/${repo}+type:issue+state:open`, + }); + + return issues.filter(issue => issue.body && issue.body.includes(ISSUE_ID)); +}; diff --git a/lib/get-fail-comment.js b/lib/get-fail-comment.js new file mode 100644 index 00000000..8c06d703 --- /dev/null +++ b/lib/get-fail-comment.js @@ -0,0 +1,44 @@ +const HOME_URL = 'https://github.com/semantic-release/semantic-release'; +const FAQ_URL = `${HOME_URL}/blob/caribou/docs/support/FAQ.md`; +const GET_HELP_URL = `${HOME_URL}#get-help`; +const USAGE_DOC_URL = `${HOME_URL}/blob/caribou/docs/usage/README.md`; +const NEW_ISSUE_URL = `${HOME_URL}/issues/new`; + +const formatError = error => `### ${error.message} + +${error.details || + `Unfortunatly this error doesn't have any additionnal information.${ + error.pluginName + ? ` Feel free to kindly ask the author of the \`${error.pluginName}\` plugin to add more helpful informations.` + : '' + }`}`; + +module.exports = ( + branch, + errors +) => `## :rotating_light: The automated release from the \`${branch}\` branch failed. :rotating_light: + +I recommend you give this issue a high priority, so other packages depending on you could benefit from your bug fixes and new features. + +You can find below the list of errors reported by **semantic-release**. Each one of them has to be resolved in order to automatically publish your package. I’m sure you can resolve this 💪. + +Errors are usually caused by a misconfiguration or an authentication problem. With each error reported below you will find explanation and guidance to help you to resolve it. + +Once all the errors are resolved, **semantic-release** will release your package the next time you push a commit the \`${branch}\` branch. You can also manually restart the failed CI job that runs **semantic-release**. + +If you are not sure how to resolve this, here is some links that can help you: +- [Usage documentation](${USAGE_DOC_URL}) +- [Frequently Asked Questions](${FAQ_URL}) +- [Support channels](${GET_HELP_URL}) + +If those don’t help, or if this issue is reporting something you think isn’t right, you can always ask the humans behind **[semantic-release](${NEW_ISSUE_URL})**. + +--- + +${errors.map(formatError).join('\n\n---\n\n')} + +--- + +Good luck with your project ✨ + +Your **[semantic-release](${HOME_URL})** bot :package::rocket:`; diff --git a/lib/resolve-config.js b/lib/resolve-config.js index 4a607a87..24dad960 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -1,9 +1,23 @@ -const {castArray} = require('lodash'); +const {isUndefined, castArray} = require('lodash'); -module.exports = ({githubUrl, githubApiPathPrefix, assets, successComment}) => ({ +module.exports = ({ + githubUrl, + githubApiPathPrefix, + assets, + successComment, + failComment, + failTitle, + labels, + assignees, +}) => ({ githubToken: process.env.GH_TOKEN || process.env.GITHUB_TOKEN, githubUrl: githubUrl || process.env.GH_URL || process.env.GITHUB_URL, githubApiPathPrefix: githubApiPathPrefix || process.env.GH_PREFIX || process.env.GITHUB_PREFIX || '', assets: assets ? castArray(assets) : assets, successComment, + failComment, + failTitle: + isUndefined(failTitle) || failTitle === false ? 'The automated release is failing :rotating_light:' : failTitle, + labels: isUndefined(labels) ? ['semantic-release'] : labels === false ? [] : castArray(labels), + assignees: assignees ? castArray(assignees) : assignees, }); diff --git a/lib/success.js b/lib/success.js index b4273a21..4b2c81ce 100644 --- a/lib/success.js +++ b/lib/success.js @@ -7,12 +7,13 @@ const debug = require('debug')('semantic-release:github'); const resolveConfig = require('./resolve-config'); const getClient = require('./get-client'); const getSuccessComment = require('./get-success-comment'); +const findSRIssues = require('./find-sr-issues'); module.exports = async ( pluginConfig, {options: {branch, repositoryUrl}, lastRelease, commits, nextRelease, releases, logger} ) => { - const {githubToken, githubUrl, githubApiPathPrefix, successComment} = resolveConfig(pluginConfig); + const {githubToken, githubUrl, githubApiPathPrefix, successComment, failTitle} = resolveConfig(pluginConfig); const {name: repo, owner} = parseGithubUrl(repositoryUrl); const github = getClient(githubToken, githubUrl, githubApiPathPrefix); const releaseInfos = releases.filter(release => Boolean(release.name)); @@ -56,6 +57,25 @@ module.exports = async ( // Don't throw right away and continue to update other issues } }); + + const srIssues = await findSRIssues(github, failTitle, owner, repo); + + debug('found semantic-release issues: %O', srIssues); + + await pReduce(srIssues, async (_, issue) => { + debug('close issue: %O', issue); + try { + const updateIssue = {owner, repo, number: issue.number, state: 'closed'}; + debug('closing issue: %O', updateIssue); + const {data: {html_url: url}} = await github.issues.edit(updateIssue); + logger.log('Closed issue #%d: %s.', issue.number, url); + } catch (err) { + errors.push(err); + logger.error('Failed to close the issue #%d.', issue.number); + // Don't throw right away and continue to close other issues + } + }); + if (errors.length > 0) { throw new AggregateError(errors); } diff --git a/lib/verify.js b/lib/verify.js index f640a7cc..47e38355 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -11,7 +11,17 @@ const isStringOrStringArray = value => isNonEmptyString(value) || (isArray(value module.exports = async (pluginConfig, {options: {repositoryUrl}, logger}) => { const errors = []; - const {githubToken, githubUrl, githubApiPathPrefix, assets, successComment} = resolveConfig(pluginConfig); + const { + githubToken, + githubUrl, + githubApiPathPrefix, + assets, + successComment, + failComment, + failTitle, + labels, + assignees, + } = resolveConfig(pluginConfig); if ( !isUndefined(assets) && @@ -28,6 +38,30 @@ module.exports = async (pluginConfig, {options: {repositoryUrl}, logger}) => { errors.push(getError('EINVALIDSUCCESSCOMMENT', {successComment})); } + if (!isUndefined(failTitle) && failTitle !== false && !isNonEmptyString(failTitle)) { + errors.push(getError('EINVALIDFAILTITLE', {failTitle})); + } + + if (!isUndefined(failComment) && failComment !== false && !isNonEmptyString(failComment)) { + errors.push(getError('EINVALIDFAILCOMMENT', {failComment})); + } + + if ( + !isUndefined(labels) && + labels !== false && + !(isArray(labels) && labels.every(label => isStringOrStringArray(label))) + ) { + errors.push(getError('EINVALIDLABELS', {labels})); + } + + if ( + !isUndefined(assignees) && + assignees !== false && + !(isArray(assignees) && assignees.every(assignee => isStringOrStringArray(assignee))) + ) { + errors.push(getError('EINVALIDASSIGNEES', {assignees})); + } + if (githubUrl) { logger.log('Verify GitHub authentication (%s)', urlJoin(githubUrl, githubApiPathPrefix)); } else { diff --git a/package.json b/package.json index 9a976198..89588a75 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,10 @@ "keywords": [ "git", "github", + "issue", + "notifications", "publish", + "pull-request", "release", "semantic-release", "version" diff --git a/test/fail.test.js b/test/fail.test.js new file mode 100644 index 00000000..09a3dc55 --- /dev/null +++ b/test/fail.test.js @@ -0,0 +1,200 @@ +import {escape} from 'querystring'; +import test from 'ava'; +import nock from 'nock'; +import {stub} from 'sinon'; +import SemanticReleaseError from '@semantic-release/error'; +import ISSUE_ID from '../lib/definitions/sr-issue-id'; +import fail from '../lib/fail'; +import {authenticate} from './helpers/mock-github'; + +/* eslint camelcase: ["error", {properties: "never"}] */ + +// Save the current process.env +const envBackup = Object.assign({}, process.env); + +test.beforeEach(t => { + // Delete env variables in case they are on the machine running the tests + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_URL; + delete process.env.GITHUB_URL; + delete process.env.GH_PREFIX; + delete process.env.GITHUB_PREFIX; + // Mock logger + t.context.log = stub(); + t.context.error = stub(); + t.context.logger = {log: t.context.log, error: t.context.error}; +}); + +test.afterEach.always(() => { + // Restore process.env + process.env = envBackup; + // Clear nock + nock.cleanAll(); +}); + +test.serial('Open a new issue with the list of errors', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const failTitle = 'The automated release is failing :rotating_light:'; + const pluginConfig = {failTitle}; + const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const errors = [ + new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details'), + new SemanticReleaseError('Error message 2', 'ERR2', 'Error 2 details'), + new SemanticReleaseError('Error message 3', 'ERR3', 'Error 3 details'), + ]; + const github = authenticate() + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}) + .post(`/repos/${owner}/${repo}/issues`, { + title: failTitle, + body: /---\n\n### Error message 1\n\nError 1 details\n\n---\n\n### Error message 2\n\nError 2 details\n\n---\n\n### Error message 3\n\nError 3 details\n\n---/, + labels: ['semantic-release'], + }) + .reply(200, {html_url: 'https://github.com/issues/1', number: 1}); + + await fail(pluginConfig, {options, errors, logger: t.context.logger}); + + t.deepEqual(t.context.log.args[0], ['Created issue #%d: %s.', 1, 'https://github.com/issues/1']); + t.true(github.isDone()); +}); + +test.serial('Open a new issue with the list of errors and custom title and comment', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const failTitle = 'Custom title'; + const failComment = `branch \${branch} \${errors[0].message} \${errors[1].message} \${errors[2].message}`; + const pluginConfig = {failTitle, failComment}; + const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const errors = [ + new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details'), + new SemanticReleaseError('Error message 2', 'ERR2', 'Error 2 details'), + new SemanticReleaseError('Error message 3', 'ERR3', 'Error 3 details'), + ]; + const github = authenticate() + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}) + .post(`/repos/${owner}/${repo}/issues`, { + title: failTitle, + body: `branch master Error message 1 Error message 2 Error message 3\n\n${ISSUE_ID}`, + labels: ['semantic-release'], + }) + .reply(200, {html_url: 'https://github.com/issues/1', number: 1}); + + await fail(pluginConfig, {options, errors, logger: t.context.logger}); + + t.deepEqual(t.context.log.args[0], ['Created issue #%d: %s.', 1, 'https://github.com/issues/1']); + t.true(github.isDone()); +}); + +test.serial('Open a new issue with assignees and the list of errors', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const failTitle = 'The automated release is failing :rotating_light:'; + const assignees = ['user1', 'user2']; + const pluginConfig = {failTitle, assignees}; + const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const errors = [ + new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details'), + new SemanticReleaseError('Error message 2', 'ERR2', 'Error 2 details'), + ]; + const github = authenticate() + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}) + .post(`/repos/${owner}/${repo}/issues`, { + title: failTitle, + body: /---\n\n### Error message 1\n\nError 1 details\n\n---\n\n### Error message 2\n\nError 2 details\n\n---/, + labels: ['semantic-release'], + assignees: ['user1', 'user2'], + }) + .reply(200, {html_url: 'https://github.com/issues/1', number: 1}); + + await fail(pluginConfig, {options, errors, logger: t.context.logger}); + + t.deepEqual(t.context.log.args[0], ['Created issue #%d: %s.', 1, 'https://github.com/issues/1']); + t.true(github.isDone()); +}); + +test.serial('Open a new issue without labels and the list of errors', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const failTitle = 'The automated release is failing :rotating_light:'; + const labels = false; + const pluginConfig = {failTitle, labels}; + const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const errors = [ + new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details'), + new SemanticReleaseError('Error message 2', 'ERR2', 'Error 2 details'), + ]; + const github = authenticate() + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}) + .post(`/repos/${owner}/${repo}/issues`, { + title: failTitle, + body: /---\n\n### Error message 1\n\nError 1 details\n\n---\n\n### Error message 2\n\nError 2 details\n\n---/, + labels: [], + }) + .reply(200, {html_url: 'https://github.com/issues/1', number: 1}); + + await fail(pluginConfig, {options, errors, logger: t.context.logger}); + + t.deepEqual(t.context.log.args[0], ['Created issue #%d: %s.', 1, 'https://github.com/issues/1']); + t.true(github.isDone()); +}); + +test.serial('Update the first existing issue with the list of errors', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const failTitle = 'The automated release is failing :rotating_light:'; + const pluginConfig = {failTitle}; + const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const errors = [ + new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details'), + new SemanticReleaseError('Error message 2', 'ERR2', 'Error 2 details'), + new SemanticReleaseError('Error message 3', 'ERR3', 'Error 3 details'), + ]; + const issues = [ + {number: 1, body: 'Issue 1 body', title: failTitle}, + {number: 2, body: `Issue 2 body\n\n${ISSUE_ID}`, title: failTitle}, + {number: 3, body: `Issue 3 body\n\n${ISSUE_ID}`, title: failTitle}, + ]; + const github = authenticate() + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: issues}) + .post(`/repos/${owner}/${repo}/issues/2/comments`, { + body: /---\n\n### Error message 1\n\nError 1 details\n\n---\n\n### Error message 2\n\nError 2 details\n\n---\n\n### Error message 3\n\nError 3 details\n\n---/, + }) + .reply(200, {html_url: 'https://github.com/issues/2', number: 2}); + + await fail(pluginConfig, {options, errors, logger: t.context.logger}); + + t.deepEqual(t.context.log.args[0], ['Found existing semantic-release issue #%d.', 2]); + t.deepEqual(t.context.log.args[1], ['Added comment to issue #%d: %s.', 2, 'https://github.com/issues/2']); + t.true(github.isDone()); +}); diff --git a/test/find-sr-issue.test.js b/test/find-sr-issue.test.js new file mode 100644 index 00000000..2eafd933 --- /dev/null +++ b/test/find-sr-issue.test.js @@ -0,0 +1,104 @@ +import {escape} from 'querystring'; +import test from 'ava'; +import nock from 'nock'; +import {stub} from 'sinon'; +import ISSUE_ID from '../lib/definitions/sr-issue-id'; +import findSRIssues from '../lib/find-sr-issues'; +import getClient from '../lib/get-client'; +import {authenticate} from './helpers/mock-github'; + +/* eslint camelcase: ["error", {properties: "never"}] */ + +// Save the current process.env +const envBackup = Object.assign({}, process.env); + +test.beforeEach(t => { + // Delete env variables in case they are on the machine running the tests + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_URL; + delete process.env.GITHUB_URL; + delete process.env.GH_PREFIX; + delete process.env.GITHUB_PREFIX; + // Mock logger + t.context.log = stub(); + t.context.error = stub(); + t.context.logger = {log: t.context.log, error: t.context.error}; +}); + +test.afterEach.always(() => { + // Restore process.env + process.env = envBackup; + // Clear nock + nock.cleanAll(); +}); + +test.serial('Filter out issues without ID', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + const title = 'The automated release is failing :rotating_light:'; + const issues = [ + {number: 1, body: 'Issue 1 body', title}, + {number: 2, body: `Issue 2 body\n\n${ISSUE_ID}`, title}, + {number: 3, body: `Issue 3 body\n\n${ISSUE_ID}`, title}, + ]; + const github = authenticate({githubToken}) + .get( + `/search/issues?q=${escape(`title:${title}`)}+${escape(`repo:${owner}/${repo}`)}+${escape('type:issue')}+${escape( + 'state:open' + )}` + ) + .reply(200, {items: issues}); + + const srIssues = await findSRIssues(getClient(githubToken), title, owner, repo); + + t.deepEqual(srIssues, [ + {number: 2, body: 'Issue 2 body\n\n', title}, + {number: 3, body: 'Issue 3 body\n\n', title}, + ]); + + t.true(github.isDone()); +}); + +test.serial('Return empty array if not issues found', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + const title = 'The automated release is failing :rotating_light:'; + const issues = []; + const github = authenticate({githubToken}) + .get( + `/search/issues?q=${escape(`title:${title}`)}+${escape(`repo:${owner}/${repo}`)}+${escape('type:issue')}+${escape( + 'state:open' + )}` + ) + .reply(200, {items: issues}); + + const srIssues = await findSRIssues(getClient(githubToken), title, owner, repo); + + t.deepEqual(srIssues, []); + + t.true(github.isDone()); +}); + +test.serial('Return empty array if not issues has matching ID', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + const githubToken = 'github_token'; + const title = 'The automated release is failing :rotating_light:'; + const issues = [{number: 1, body: 'Issue 1 body', title}, {number: 2, body: 'Issue 2 body', title}]; + const github = authenticate({githubToken}) + .get( + `/search/issues?q=${escape(`title:${title}`)}+${escape(`repo:${owner}/${repo}`)}+${escape('type:issue')}+${escape( + 'state:open' + )}` + ) + .reply(200, {items: issues}); + + const srIssues = await findSRIssues(getClient(githubToken), title, owner, repo); + + t.deepEqual(srIssues, []); + + t.true(github.isDone()); +}); diff --git a/test/get-fail-comment.test.js b/test/get-fail-comment.test.js new file mode 100644 index 00000000..318ce572 --- /dev/null +++ b/test/get-fail-comment.test.js @@ -0,0 +1,51 @@ +import test from 'ava'; +import SemanticReleaseError from '@semantic-release/error'; +import getfailComment from '../lib/get-fail-comment'; + +test('Comment with mutiple errors', t => { + const errors = [ + new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details'), + new SemanticReleaseError('Error message 2', 'ERR2', 'Error 2 details'), + new SemanticReleaseError('Error message 3', 'ERR3', 'Error 3 details'), + ]; + const comment = getfailComment('master', errors); + + t.regex(comment, /the `master` branch/); + t.regex( + comment, + /---\n\n### Error message 1\n\nError 1 details\n\n---\n\n### Error message 2\n\nError 2 details\n\n---\n\n### Error message 3\n\nError 3 details\n\n---/ + ); +}); + +test('Comment with one error', t => { + const errors = [new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details')]; + const comment = getfailComment('master', errors); + + t.regex(comment, /the `master` branch/); + t.regex(comment, /---\n\n### Error message 1\n\nError 1 details\n\n---/); +}); + +test('Comment with missing error details and pluginName', t => { + const error = new SemanticReleaseError('Error message 1', 'ERR1'); + error.pluginName = 'some-plugin'; + const errors = [error]; + const comment = getfailComment('master', errors); + + t.regex(comment, /the `master` branch/); + t.regex( + comment, + /---\n\n### Error message 1\n\nUnfortunatly this error doesn't have any additionnal information. Feel free to kindly ask the author of the `some-plugin` plugin to add more helpful informations.\n\n---/ + ); +}); + +test('Comment with missing error details and no pluginName', t => { + const error = new SemanticReleaseError('Error message 1', 'ERR1'); + const errors = [error]; + const comment = getfailComment('master', errors); + + t.regex(comment, /the `master` branch/); + t.regex( + comment, + /---\n\n### Error message 1\n\nUnfortunatly this error doesn't have any additionnal information.\n\n---/ + ); +}); diff --git a/test/integration.test.js b/test/integration.test.js index 0aa09267..393e6f86 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -4,6 +4,7 @@ import {stat} from 'fs-extra'; import nock from 'nock'; import {stub} from 'sinon'; import clearModule from 'clear-module'; +import SemanticReleaseError from '@semantic-release/error'; import {authenticate, upload} from './helpers/mock-github'; /* eslint camelcase: ["error", {properties: "never"}] */ @@ -78,14 +79,14 @@ test.serial('Verify GitHub auth and assets config', async t => { {path: ['dist/**', '!dist/*.js']}, ]; const options = { - publish: [{path: '@semantic-release/npm'}, {path: '@semantic-release/github', assets}], + publish: [{path: '@semantic-release/npm'}], repositoryUrl: `git+https://othertesturl.com/${owner}/${repo}.git`, }; const github = authenticate() .get(`/repos/${owner}/${repo}`) .reply(200, {permissions: {push: true}}); - await t.notThrows(t.context.m.verifyConditions({}, {options, logger: t.context.logger})); + await t.notThrows(t.context.m.verifyConditions({assets}, {options, logger: t.context.logger})); t.true(github.isDone()); }); @@ -93,8 +94,15 @@ test.serial('Verify GitHub auth and assets config', async t => { test.serial('Throw SemanticReleaseError if invalid config', async t => { const assets = [{wrongProperty: 'lib/file.js'}]; const successComment = 42; + const failComment = 42; + const failTitle = 42; + const labels = 42; + const assignees = 42; const options = { - publish: [{path: '@semantic-release/npm'}, {path: '@semantic-release/github', assets, successComment}], + publish: [ + {path: '@semantic-release/npm'}, + {path: '@semantic-release/github', assets, successComment, failComment, failTitle, labels, assignees}, + ], repositoryUrl: 'invalid_url', }; @@ -105,9 +113,17 @@ test.serial('Throw SemanticReleaseError if invalid config', async t => { t.is(errors[1].name, 'SemanticReleaseError'); t.is(errors[1].code, 'EINVALIDSUCCESSCOMMENT'); t.is(errors[2].name, 'SemanticReleaseError'); - t.is(errors[2].code, 'EINVALIDGITHUBURL'); + t.is(errors[2].code, 'EINVALIDFAILTITLE'); t.is(errors[3].name, 'SemanticReleaseError'); - t.is(errors[3].code, 'ENOGHTOKEN'); + t.is(errors[3].code, 'EINVALIDFAILCOMMENT'); + t.is(errors[4].name, 'SemanticReleaseError'); + t.is(errors[4].code, 'EINVALIDLABELS'); + t.is(errors[5].name, 'SemanticReleaseError'); + t.is(errors[5].code, 'EINVALIDASSIGNEES'); + t.is(errors[6].name, 'SemanticReleaseError'); + t.is(errors[6].code, 'EINVALIDGITHUBURL'); + t.is(errors[7].name, 'SemanticReleaseError'); + t.is(errors[7].code, 'ENOGHTOKEN'); }); test.serial('Publish a release with an array of assets', async t => { @@ -166,6 +182,7 @@ test.serial('Comment on PR included in the releases', async t => { const owner = 'test_user'; const repo = 'test_repo'; process.env.GH_TOKEN = 'github_token'; + const failTitle = 'The automated release is failing :rotating_light:'; const prs = [{number: 1, pull_request: {}}]; const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; const commits = [{hash: '123', message: 'Commit 1 message'}]; @@ -181,14 +198,56 @@ test.serial('Comment on PR included in the releases', async t => { ) .reply(200, {items: prs}) .post(`/repos/${owner}/${repo}/issues/1/comments`, {body: /This PR is included/}) - .reply(200, {html_url: 'https://github.com/successcomment-1'}); + .reply(200, {html_url: 'https://github.com/successcomment-1'}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}); + + await t.context.m.success({failTitle}, {options, commits, nextRelease, releases, logger: t.context.logger}); + + t.deepEqual(t.context.log.args[0], ['Verify GitHub authentication']); + t.deepEqual(t.context.log.args[1], ['Added comment to issue #%d: %s', 1, 'https://github.com/successcomment-1']); + t.true(github.isDone()); +}); + +test.serial('Open a new issue with the list of errors', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const failTitle = 'The automated release is failing :rotating_light:'; + const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const errors = [ + new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details'), + new SemanticReleaseError('Error message 2', 'ERR2', 'Error 2 details'), + new SemanticReleaseError('Error message 3', 'ERR3', 'Error 3 details'), + ]; + const github = authenticate() + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}) + .post(`/repos/${owner}/${repo}/issues`, { + title: failTitle, + body: /---\n\n### Error message 1\n\nError 1 details\n\n---\n\n### Error message 2\n\nError 2 details\n\n---\n\n### Error message 3\n\nError 3 details\n\n---/, + labels: ['semantic-release'], + }) + .reply(200, {html_url: 'https://github.com/issues/1', number: 1}); - await t.context.m.success({}, {options, commits, nextRelease, releases, logger: t.context.logger}); + await t.context.m.fail({failTitle}, {options, errors, logger: t.context.logger}); + t.deepEqual(t.context.log.args[0], ['Verify GitHub authentication']); + t.deepEqual(t.context.log.args[1], ['Created issue #%d: %s.', 1, 'https://github.com/issues/1']); t.true(github.isDone()); }); -test.serial('Verify GitHub auth, release and notify', async t => { +test.serial('Verify, release and notify success', async t => { process.env.GH_TOKEN = 'github_token'; const owner = 'test_user'; const repo = 'test_repo'; @@ -196,6 +255,7 @@ test.serial('Verify GitHub auth, release and notify', async t => { 'test/fixtures/upload.txt', {path: 'test/fixtures/upload_other.txt', name: 'other_file.txt', label: 'Other File'}, ]; + const failTitle = 'The automated release is failing :rotating_light:'; const options = { publish: [{path: '@semantic-release/npm'}, {path: '@semantic-release/github', assets}], branch: 'master', @@ -227,7 +287,13 @@ test.serial('Verify GitHub auth, release and notify', async t => { ) .reply(200, {items: prs}) .post(`/repos/${owner}/${repo}/issues/1/comments`, {body: /This PR is included/}) - .reply(200, {html_url: 'https://github.com/successcomment-1'}); + .reply(200, {html_url: 'https://github.com/successcomment-1'}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}); const githubUpload1 = upload({ uploadUrl: 'https://github.com', contentLength: (await stat('test/fixtures/upload.txt')).size, @@ -243,7 +309,10 @@ test.serial('Verify GitHub auth, release and notify', async t => { await t.notThrows(t.context.m.verifyConditions({}, {options, logger: t.context.logger})); await t.context.m.publish({assets}, {nextRelease, options, logger: t.context.logger}); - await t.context.m.success({assets}, {nextRelease, options, commits, releases: [], logger: t.context.logger}); + await t.context.m.success( + {assets, failTitle}, + {nextRelease, options, commits, releases: [], logger: t.context.logger} + ); t.deepEqual(t.context.log.args[0], ['Verify GitHub authentication']); t.deepEqual(t.context.log.args[1], ['Published GitHub release: %s', releaseUrl]); @@ -253,3 +322,38 @@ test.serial('Verify GitHub auth, release and notify', async t => { t.true(githubUpload1.isDone()); t.true(githubUpload2.isDone()); }); + +test.serial('Verify and notify failure', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const failTitle = 'The automated release is failing :rotating_light:'; + const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const errors = [ + new SemanticReleaseError('Error message 1', 'ERR1', 'Error 1 details'), + new SemanticReleaseError('Error message 2', 'ERR2', 'Error 2 details'), + new SemanticReleaseError('Error message 3', 'ERR3', 'Error 3 details'), + ]; + const github = authenticate() + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}) + .post(`/repos/${owner}/${repo}/issues`, { + title: failTitle, + body: /---\n\n### Error message 1\n\nError 1 details\n\n---\n\n### Error message 2\n\nError 2 details\n\n---\n\n### Error message 3\n\nError 3 details\n\n---/, + labels: ['semantic-release'], + }) + .reply(200, {html_url: 'https://github.com/issues/1', number: 1}); + + await t.notThrows(t.context.m.verifyConditions({}, {options, logger: t.context.logger})); + await t.context.m.fail({failTitle}, {options, errors, logger: t.context.logger}); + + t.deepEqual(t.context.log.args[0], ['Verify GitHub authentication']); + t.deepEqual(t.context.log.args[1], ['Created issue #%d: %s.', 1, 'https://github.com/issues/1']); + t.true(github.isDone()); +}); diff --git a/test/success.test.js b/test/success.test.js index 2a7f6080..58bbcdf0 100644 --- a/test/success.test.js +++ b/test/success.test.js @@ -2,6 +2,7 @@ import {escape} from 'querystring'; import test from 'ava'; import nock from 'nock'; import {stub} from 'sinon'; +import ISSUE_ID from '../lib/definitions/sr-issue-id'; import success from '../lib/success'; import {authenticate} from './helpers/mock-github'; @@ -35,7 +36,8 @@ test.serial('Add comment to PRs associated with release commits and issues close const owner = 'test_user'; const repo = 'test_repo'; process.env.GITHUB_TOKEN = 'github_token'; - const pluginConfig = {}; + const failTitle = 'The automated release is failing :rotating_light:'; + const pluginConfig = {failTitle}; const prs = [{number: 1, pull_request: {}}, {number: 2, pull_request: {}, body: 'Fixes #3'}]; const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; const commits = [ @@ -59,7 +61,13 @@ test.serial('Add comment to PRs associated with release commits and issues close .post(`/repos/${owner}/${repo}/issues/3/comments`, {body: /This issue has been resolved/}) .reply(200, {html_url: 'https://github.com/successcomment-3'}) .post(`/repos/${owner}/${repo}/issues/4/comments`, {body: /This issue has been resolved/}) - .reply(200, {html_url: 'https://github.com/successcomment-4'}); + .reply(200, {html_url: 'https://github.com/successcomment-4'}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}); await success(pluginConfig, {options, commits, nextRelease, releases, logger: t.context.logger}); @@ -74,7 +82,8 @@ test.serial('Do not add comment if no PR is associated with release commits', as const owner = 'test_user'; const repo = 'test_repo'; process.env.GITHUB_TOKEN = 'github_token'; - const pluginConfig = {}; + const failTitle = 'The automated release is failing :rotating_light:'; + const pluginConfig = {failTitle}; const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; const commits = [{hash: '123', message: 'Commit 1 message'}]; const nextRelease = {version: '1.0.0'}; @@ -85,6 +94,12 @@ test.serial('Do not add comment if no PR is associated with release commits', as 'type:pr' )}` ) + .reply(200, {items: []}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) .reply(200, {items: []}); await success(pluginConfig, {options, commits, nextRelease, releases, logger: t.context.logger}); @@ -92,11 +107,55 @@ test.serial('Do not add comment if no PR is associated with release commits', as t.true(github.isDone()); }); -test.serial('Ignore errors when adding comments', async t => { +test.serial('Add custom comment', async t => { const owner = 'test_user'; const repo = 'test_repo'; process.env.GITHUB_TOKEN = 'github_token'; - const pluginConfig = {}; + const failTitle = 'The automated release is failing :rotating_light:'; + const pluginConfig = { + successComment: `last release: \${lastRelease.version} nextRelease: \${nextRelease.version} branch: \${branch} commits: \${commits.length} releases: \${releases.length} PR attribute: \${issue.prop}`, + failTitle, + }; + const prs = [{number: 1, prop: 'PR prop', pull_request: {}}]; + const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const lastRelease = {version: '1.0.0'}; + const commits = [{hash: '123', message: 'Commit 1 message'}]; + const nextRelease = {version: '2.0.0'}; + const releases = [{name: 'GitHub release', url: 'https://github.com/release'}]; + const github = authenticate() + .get( + `/search/issues?q=${commits.map(commit => commit.hash).join('+')}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:pr' + )}` + ) + .reply(200, {items: prs}) + .post(`/repos/${owner}/${repo}/issues/1/comments`, { + body: /last release: 1\.0\.0 nextRelease: 2\.0\.0 branch: master commits: 1 releases: 1 PR attribute: PR prop/, + }) + .reply(200, {html_url: 'https://github.com/successcomment-1'}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: []}); + + await success(pluginConfig, {options, lastRelease, commits, nextRelease, releases, logger: t.context.logger}); + + t.true(github.isDone()); +}); + +test.serial('Ignore errors when adding comments and closing issues', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const failTitle = 'The automated release is failing :rotating_light:'; + const pluginConfig = {failTitle}; + const issues = [ + {number: 1, body: 'Issue 1 body', title: failTitle}, + {number: 2, body: `Issue 2 body\n\n${ISSUE_ID}`, title: failTitle}, + {number: 3, body: `Issue 3 body\n\n${ISSUE_ID}`, title: failTitle}, + ]; const prs = [{number: 1, pull_request: {}}, {number: 2, pull_request: {}}]; const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; const commits = [{hash: '123', message: 'Commit 1 message'}]; @@ -112,30 +171,45 @@ test.serial('Ignore errors when adding comments', async t => { .post(`/repos/${owner}/${repo}/issues/1/comments`, {body: /This PR is included/}) .reply(404, {}) .post(`/repos/${owner}/${repo}/issues/2/comments`, {body: /This PR is included/}) - .reply(200, {html_url: 'https://github.com/successcomment-2'}); + .reply(200, {html_url: 'https://github.com/successcomment-2'}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: issues}) + .patch(`/repos/${owner}/${repo}/issues/2`, {state: 'closed'}) + .reply(500) + .patch(`/repos/${owner}/${repo}/issues/3`, {state: 'closed'}) + .reply(200, {html_url: 'https://github.com/issues/3'}); - const [error] = await t.throws( + const [error1, error2] = await t.throws( success(pluginConfig, {options, commits, nextRelease, releases, logger: t.context.logger}) ); - t.is(error.code, 404); + t.is(error1.code, 404); + t.is(error2.code, 500); t.deepEqual(t.context.error.args[0], ['Failed to add a comment to the issue #%d.', 1]); + t.deepEqual(t.context.error.args[1], ['Failed to close the issue #%d.', 2]); t.deepEqual(t.context.log.args[0], ['Added comment to issue #%d: %s', 2, 'https://github.com/successcomment-2']); + t.deepEqual(t.context.log.args[1], ['Closed issue #%d: %s.', 3, 'https://github.com/issues/3']); t.true(github.isDone()); }); -test.serial('Add custom comment', async t => { +test.serial('Close open issues when a release is successful', async t => { const owner = 'test_user'; const repo = 'test_repo'; process.env.GITHUB_TOKEN = 'github_token'; - const pluginConfig = { - successComment: `last release: \${lastRelease.version} nextRelease: \${nextRelease.version} branch: \${branch} commits: \${commits.length} releases: \${releases.length} PR attribute: \${issue.prop}`, - }; - const prs = [{number: 1, prop: 'PR prop', pull_request: {}}]; + const failTitle = 'The automated release is failing :rotating_light:'; + const pluginConfig = {failTitle}; + const issues = [ + {number: 1, body: 'Issue 1 body', title: failTitle}, + {number: 2, body: `Issue 2 body\n\n${ISSUE_ID}`, title: failTitle}, + {number: 3, body: `Issue 3 body\n\n${ISSUE_ID}`, title: failTitle}, + ]; const options = {branch: 'master', repositoryUrl: `https://github.com/${owner}/${repo}.git`}; - const lastRelease = {version: '1.0.0'}; const commits = [{hash: '123', message: 'Commit 1 message'}]; - const nextRelease = {version: '2.0.0'}; + const nextRelease = {version: '1.0.0'}; const releases = [{name: 'GitHub release', url: 'https://github.com/release'}]; const github = authenticate() .get( @@ -143,13 +217,21 @@ test.serial('Add custom comment', async t => { 'type:pr' )}` ) - .reply(200, {items: prs}) - .post(`/repos/${owner}/${repo}/issues/1/comments`, { - body: /last release: 1\.0\.0 nextRelease: 2\.0\.0 branch: master commits: 1 releases: 1 PR attribute: PR prop/, - }) - .reply(200, {html_url: 'https://github.com/successcomment-1'}); + .reply(200, {items: []}) + .get( + `/search/issues?q=${escape(`title:${failTitle}`)}+${escape(`repo:${owner}/${repo}`)}+${escape( + 'type:issue' + )}+${escape('state:open')}` + ) + .reply(200, {items: issues}) + .patch(`/repos/${owner}/${repo}/issues/2`, {state: 'closed'}) + .reply(200, {html_url: 'https://github.com/issues/2'}) + .patch(`/repos/${owner}/${repo}/issues/3`, {state: 'closed'}) + .reply(200, {html_url: 'https://github.com/issues/3'}); - await success(pluginConfig, {options, lastRelease, commits, nextRelease, releases, logger: t.context.logger}); + await success(pluginConfig, {options, commits, nextRelease, releases, logger: t.context.logger}); + t.deepEqual(t.context.log.args[0], ['Closed issue #%d: %s.', 2, 'https://github.com/issues/2']); + t.deepEqual(t.context.log.args[1], ['Closed issue #%d: %s.', 3, 'https://github.com/issues/3']); t.true(github.isDone()); }); diff --git a/test/verify.test.js b/test/verify.test.js index 538c8354..66cd713e 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -34,37 +34,46 @@ test.serial('Verify package, token and repository access', async t => { process.env.GH_TOKEN = 'github_token'; const assets = [{path: 'lib/file.js'}, 'file.js']; const successComment = 'Test comment'; + const failTitle = 'Test title'; + const failComment = 'Test comment'; + const labels = ['semantic-release']; const github = authenticate() .get(`/repos/${owner}/${repo}`) .reply(200, {permissions: {push: true}}); await t.notThrows( verify( - {assets, successComment}, + {assets, successComment, failTitle, failComment, labels}, {options: {repositoryUrl: `git+https://othertesturl.com/${owner}/${repo}.git`}, logger: t.context.logger} ) ); t.true(github.isDone()); }); -test.serial('Verify package, token and repository access with "asset" and "successComment" set to "false"', async t => { - const owner = 'test_user'; - const repo = 'test_repo'; - process.env.GH_TOKEN = 'github_token'; - const assets = false; - const successComment = false; - const github = authenticate() - .get(`/repos/${owner}/${repo}`) - .reply(200, {permissions: {push: true}}); - - await t.notThrows( - verify( - {assets, successComment}, - {options: {repositoryUrl: `git+https://othertesturl.com/${owner}/${repo}.git`}, logger: t.context.logger} - ) - ); - t.true(github.isDone()); -}); +test.serial( + 'Verify package, token and repository access with "asset", "successComment", "failTitle", "failComment" and "label" set to "false"', + async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GH_TOKEN = 'github_token'; + const assets = false; + const successComment = false; + const failTitle = false; + const failComment = false; + const labels = false; + const github = authenticate() + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrows( + verify( + {assets, successComment, failTitle, failComment, labels}, + {options: {repositoryUrl: `git+https://othertesturl.com/${owner}/${repo}.git`}, logger: t.context.logger} + ) + ); + t.true(github.isDone()); + } +); test.serial('Verify package, token and repository access and custom URL with prefix', async t => { const owner = 'test_user'; @@ -142,36 +151,6 @@ test.serial('Verify package, token and repository access with alternative enviro t.true(github.isDone()); }); -test.serial('Throw SemanticReleaseError if "assets" option is not a String or an Array of Objects', async t => { - process.env.GITHUB_TOKEN = 'github_token'; - const assets = 42; - - const [error] = await t.throws( - verify( - {assets}, - {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} - ) - ); - - t.is(error.name, 'SemanticReleaseError'); - t.is(error.code, 'EINVALIDASSETS'); -}); - -test.serial('Throw SemanticReleaseError if "assets" option is an Array with invalid elements', async t => { - process.env.GITHUB_TOKEN = 'github_token'; - const assets = ['file.js', 42]; - - const [error] = await t.throws( - verify( - {assets}, - {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} - ) - ); - - t.is(error.name, 'SemanticReleaseError'); - t.is(error.code, 'EINVALIDASSETS'); -}); - test.serial('Verify "assets" is a String', async t => { const owner = 'test_user'; const repo = 'test_repo'; @@ -252,38 +231,40 @@ test.serial('Verify "assets" is an Array of Object with a glob Arrays in path pr t.true(github.isDone()); }); -test.serial('Throw SemanticReleaseError if "assets" option is an Object missing the "path" property', async t => { +test.serial('Verify "labels" is a String', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; process.env.GITHUB_TOKEN = 'github_token'; - const assets = {name: 'file.js'}; + const labels = 'semantic-release'; + const github = authenticate() + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); - const [error] = await t.throws( - verify( - {assets}, - {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} - ) + await t.notThrows( + verify({labels}, {options: {repositoryUrl: `git@othertesturl.com:${owner}/${repo}.git`}, logger: t.context.logger}) ); - t.is(error.name, 'SemanticReleaseError'); - t.is(error.code, 'EINVALIDASSETS'); + t.true(github.isDone()); }); -test.serial( - 'Throw SemanticReleaseError if "assets" option is an Array with objects missing the "path" property', - async t => { - process.env.GITHUB_TOKEN = 'github_token'; - const assets = [{path: 'lib/file.js'}, {name: 'file.js'}]; +test.serial('Verify "assignees" is a String', async t => { + const owner = 'test_user'; + const repo = 'test_repo'; + process.env.GITHUB_TOKEN = 'github_token'; + const assignees = 'user'; + const github = authenticate() + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); - const [error] = await t.throws( - verify( - {assets}, - {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} - ) - ); + await t.notThrows( + verify( + {assignees}, + {options: {repositoryUrl: `git@othertesturl.com:${owner}/${repo}.git`}, logger: t.context.logger} + ) + ); - t.is(error.name, 'SemanticReleaseError'); - t.is(error.code, 'EINVALIDASSETS'); - } -); + t.true(github.isDone()); +}); test.serial('Throw SemanticReleaseError for missing github token', async t => { const [error] = await t.throws( @@ -370,6 +351,69 @@ test.serial('Throw error if github return any other errors', async t => { t.true(github.isDone()); }); +test.serial('Throw SemanticReleaseError if "assets" option is not a String or an Array of Objects', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const assets = 42; + + const [error] = await t.throws( + verify( + {assets}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test.serial('Throw SemanticReleaseError if "assets" option is an Array with invalid elements', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const assets = ['file.js', 42]; + + const [error] = await t.throws( + verify( + {assets}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test.serial('Throw SemanticReleaseError if "assets" option is an Object missing the "path" property', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const assets = {name: 'file.js'}; + + const [error] = await t.throws( + verify( + {assets}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test.serial( + 'Throw SemanticReleaseError if "assets" option is an Array with objects missing the "path" property', + async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const assets = [{path: 'lib/file.js'}, {name: 'file.js'}]; + + const [error] = await t.throws( + verify( + {assets}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSETS'); + } +); + test('Throw SemanticReleaseError if "successComment" option is not a String', async t => { const successComment = 42; const [error] = await t.throws( @@ -408,3 +452,167 @@ test('Throw SemanticReleaseError if "successComment" option is a whitespace Stri t.is(error.name, 'SemanticReleaseError'); t.is(error.code, 'EINVALIDSUCCESSCOMMENT'); }); + +test('Throw SemanticReleaseError if "failTitle" option is not a String', async t => { + const failTitle = 42; + const [error] = await t.throws( + verify( + {failTitle}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDFAILTITLE'); +}); + +test('Throw SemanticReleaseError if "failTitle" option is an empty String', async t => { + const failTitle = ''; + const [error] = await t.throws( + verify( + {failTitle}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDFAILTITLE'); +}); + +test('Throw SemanticReleaseError if "failTitle" option is a whitespace String', async t => { + const failTitle = ' \n \r '; + const [error] = await t.throws( + verify( + {failTitle}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDFAILTITLE'); +}); + +test('Throw SemanticReleaseError if "failComment" option is not a String', async t => { + const failComment = 42; + const [error] = await t.throws( + verify( + {failComment}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDFAILCOMMENT'); +}); + +test('Throw SemanticReleaseError if "failComment" option is an empty String', async t => { + const failComment = ''; + const [error] = await t.throws( + verify( + {failComment}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDFAILCOMMENT'); +}); + +test('Throw SemanticReleaseError if "failComment" option is a whitespace String', async t => { + const failComment = ' \n \r '; + const [error] = await t.throws( + verify( + {failComment}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDFAILCOMMENT'); +}); + +test.serial('Throw SemanticReleaseError if "labels" option is not a String or an Array of String', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const labels = 42; + + const [error] = await t.throws( + verify( + {labels}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDLABELS'); +}); + +test.serial('Throw SemanticReleaseError if "labels" option is an Array with invalid elements', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const labels = ['label1', 42]; + + const [error] = await t.throws( + verify( + {labels}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDLABELS'); +}); + +test('Throw SemanticReleaseError if "labels" option is a whitespace String', async t => { + const labels = ' \n \r '; + const [error] = await t.throws( + verify( + {labels}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDLABELS'); +}); + +test.serial('Throw SemanticReleaseError if "assignees" option is not a String or an Array of String', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const assignees = 42; + + const [error] = await t.throws( + verify( + {assignees}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSIGNEES'); +}); + +test.serial('Throw SemanticReleaseError if "assignees" option is an Array with invalid elements', async t => { + process.env.GITHUB_TOKEN = 'github_token'; + const assignees = ['user', 42]; + + const [error] = await t.throws( + verify( + {assignees}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSIGNEES'); +}); + +test('Throw SemanticReleaseError if "assignees" option is a whitespace String', async t => { + const assignees = ' \n \r '; + const [error] = await t.throws( + verify( + {assignees}, + {options: {repositoryUrl: 'https://github.com/semantic-release/github.git'}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSIGNEES'); +});