Skip to content

Commit

Permalink
feat: add success hook
Browse files Browse the repository at this point in the history
- Add comment to issues and pull requests included in the release
  • Loading branch information
pvdlg committed Feb 12, 2018
1 parent 68b1d18 commit a28de30
Show file tree
Hide file tree
Showing 11 changed files with 492 additions and 19 deletions.
41 changes: 33 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ the [assets](#assets) option configuration.

Publish a [GitHub release](https://help.github.com/articles/about-releases), optionally uploading files.

## success

Add a comment to each GitHub issue or pull request resolved by the release.

## Configuration

### GitHub authentication
Expand All @@ -38,13 +42,14 @@ 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). | - |
| 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](<github_release_url>)` |

#### `assets`
#### assets

Can be a [glob](https://github.com/isaacs/node-glob#glob-primer) or and `Array` of
[globs](https://github.com/isaacs/node-glob#glob-primer) and `Object`s with the following properties
Expand All @@ -63,7 +68,7 @@ If a directory is configured, all the files under this directory and its childre

Files can be included even if they have a match in `.gitignore`.

##### `assets` examples
##### assets examples

`'dist/*.js'`: include all the `js` files in the `dist` directory, but not in its sub-directories.

Expand All @@ -78,6 +83,25 @@ distribution` and `MyLibrary CSS distribution` in the GitHub release.
`css` files in the `dist` directory and its sub-directories excluding the minified version, plus the
`build/MyLibrary.zip` file and label it `MyLibrary` in the GitHub release.

#### successComment

The message for the issue comments is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available:

| Parameter | Description |
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `branch` | The branch from which the release is done. |
| `lastRelease` | `Object` with `version`, `gitTag` and `gitHead` of the last release. |
| `nextRelease` | `Object` with `version`, `gitTag`, `gitHead` and `notes` of the release being done. |
| `commits` | `Array` of commit `Object`s with `hash`, `subject`, `body` `message` and `author`. |
| `releases` | `Array` with a release `Object`s for each release published, with optional release data such has `name` and `url`. |
| `issue` | A [GitHub API pull request object](https://developer.github.com/v3/search/#search-issues) for pull requests related to a commit, or an `Object` with the `number` property for issues resolved via [keywords](https://help.github.com/articles/closing-issues-using-keywords) |

##### successComment examples

The `successComment` `This ${issue.pull_request ? 'pull request' : 'issue'} is included in version ${nextRelease.version}` will generate the comment:

> This pull request is included in version 1.0.0
### Usage

The plugins are used by default by [Semantic-release](https://github.com/semantic-release/semantic-release) so no
Expand All @@ -89,7 +113,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"]
"publish": ["@semantic-release/npm", "@semantic-release/github", "other-publish"],
"success": ["@semantic-release/github", "other-success"]
}
}
```
Expand Down
14 changes: 13 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const verifyGitHub = require('./lib/verify');
const publishGitHub = require('./lib/publish');
const successGitHub = require('./lib/success');

let verified;

Expand All @@ -13,6 +14,9 @@ async function verifyConditions(pluginConfig, context) {
if (publishPlugin && publishPlugin.assets) {
pluginConfig.assets = publishPlugin.assets;
}
if (publishPlugin && publishPlugin.successComment) {
pluginConfig.successComment = publishPlugin.successComment;
}
}

await verifyGitHub(pluginConfig, context);
Expand All @@ -27,4 +31,12 @@ async function publish(pluginConfig, context) {
return publishGitHub(pluginConfig, context);
}

module.exports = {verifyConditions, publish};
async function success(pluginConfig, context) {
if (!verified) {
await verifyGitHub(pluginConfig, context);
verified = true;
}
await successGitHub(pluginConfig, context);
}

module.exports = {verifyConditions, publish, success};
18 changes: 18 additions & 0 deletions lib/get-success-comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const HOME_URL = 'https://github.com/semantic-release/semantic-release';
const linkify = releaseInfo =>
`${releaseInfo.url ? `[${releaseInfo.name}](${releaseInfo.url})` : `\`${releaseInfo.name}\``}`;

module.exports = (issue, releaseInfos, nextRelease) =>
`:tada: This ${issue.pull_request ? 'PR is included' : 'issue has been resolved'} in version ${
nextRelease.version
} :tada:${
releaseInfos.length > 0
? `\n\nThe release is available on${
releaseInfos.length === 1
? ` ${linkify(releaseInfos[0])}`
: `:\n${releaseInfos.map(releaseInfo => `- ${linkify(releaseInfo)}`).join('\n')}`
}`
: ''
}
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`;
3 changes: 2 additions & 1 deletion lib/resolve-config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
const {castArray} = require('lodash');

module.exports = ({githubUrl, githubApiPathPrefix, assets}) => ({
module.exports = ({githubUrl, githubApiPathPrefix, assets, successComment}) => ({
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,
});
62 changes: 62 additions & 0 deletions lib/success.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const {uniqBy, template} = require('lodash');
const parseGithubUrl = require('parse-github-url');
const pReduce = require('p-reduce');
const AggregateError = require('aggregate-error');
const issueParser = require('issue-parser')('github');
const debug = require('debug')('semantic-release:github');
const resolveConfig = require('./resolve-config');
const getClient = require('./get-client');
const getSuccessComment = require('./get-success-comment');

module.exports = async (
pluginConfig,
{options: {branch, repositoryUrl}, lastRelease, commits, nextRelease, releases, logger}
) => {
const {githubToken, githubUrl, githubApiPathPrefix, successComment} = resolveConfig(pluginConfig);
const {name: repo, owner} = parseGithubUrl(repositoryUrl);
const github = getClient(githubToken, githubUrl, githubApiPathPrefix);
const releaseInfos = releases.filter(release => Boolean(release.name));

// Search for PRs associated with any commit in the release
const {data: {items: prs}} = await github.search.issues({
q: `${commits.map(commit => commit.hash).join('+')}+repo:${owner}/${repo}+type:pr`,
});

debug('found pull requests: %O', prs.map(pr => pr.number));

// Parse the release commits message and PRs body to find resolved issues/PRs via comment keyworkds
const issues = uniqBy(
[...prs.map(pr => pr.body), ...commits.map(commit => commit.message)]
.reduce((issues, message) => {
return message
? issues.concat(issueParser(message).actions.map(action => ({number: parseInt(action.issue, 10)})))
: issues;
}, [])
.filter(issue => !prs.find(pr => pr.number === issue.number)),
'number'
);

debug('found issues via comments: %O', issues);

const errors = [];

// Make requests serially to avoid hitting the rate limit (https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits)
await pReduce([...prs, ...issues], async (_, issue) => {
const body = successComment
? template(successComment)({branch, lastRelease, commits, nextRelease, releases, issue})
: getSuccessComment(issue, releaseInfos, nextRelease);
try {
const comment = {owner, repo, number: issue.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', issue.number, url);
} catch (err) {
errors.push(err);
logger.error('Failed to add a comment to the issue #%d.', issue.number);
// Don't throw right away and continue to update other issues
}
});
if (errors.length > 0) {
throw new AggregateError(errors);
}
};
16 changes: 11 additions & 5 deletions lib/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ const SemanticReleaseError = require('@semantic-release/error');
const resolveConfig = require('./resolve-config');
const getClient = require('./get-client');

const {githubToken, githubUrl, githubApiPathPrefix, assets} = resolveConfig(pluginConfig);
const isNonEmptyString = value => isString(value) && value.trim();
const isStringOrStringArray = value => isNonEmptyString(value) || (isArray(value) && value.every(isNonEmptyString));

module.exports = async (pluginConfig, {options: {repositoryUrl}, logger}) => {
const {githubToken, githubUrl, githubApiPathPrefix, assets, successComment} = resolveConfig(pluginConfig);

if (!githubToken) {
throw new SemanticReleaseError('No github token specified.', 'ENOGHTOKEN');
Expand All @@ -31,6 +34,13 @@ module.exports = async (pluginConfig, {options: {repositoryUrl}, logger}) => {
throw new SemanticReleaseError(`The git repository URL is not a valid GitHub URL.`, 'EINVALIDGITURL');
}

if (!isUndefined(successComment) && successComment !== false && !isNonEmptyString(successComment)) {
throw new SemanticReleaseError(
'The "successComment" options, if defined, must be a non empty String.',
'EINVALIDSUCCESSCOMMENT'
);
}

if (githubUrl) {
logger.log('Verify GitHub authentication (%s)', urlJoin(githubUrl, githubApiPathPrefix));
} else {
Expand All @@ -57,7 +67,3 @@ module.exports = async (pluginConfig, {options: {repositoryUrl}, logger}) => {
);
}
};

function isStringOrStringArray(value) {
return isString(value) || (isArray(value) && value.every(isString));
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
"Gregor Martynus (https://twitter.com/gr2m)"
],
"dependencies": {
"@octokit/rest": "^14.0.3",
"@octokit/rest": "^14.0.9",
"@semantic-release/error": "^2.1.0",
"debug": "^3.1.0",
"fs-extra": "^5.0.0",
"globby": "^7.1.1",
"issue-parser": "^1.0.1",
"lodash": "^4.17.4",
"mime": "^2.0.3",
"p-reduce": "^1.0.0",
Expand Down
94 changes: 94 additions & 0 deletions test/get-success-comment.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import test from 'ava';
import getSuccessComment from '../lib/get-success-comment';

const HOME_URL = 'https://github.com/semantic-release/semantic-release';

test('Comment for issue with multiple releases', t => {
const issue = {number: 1};
const releaseInfos = [
{name: 'GitHub release', url: 'https://github.com/release'},
{name: 'npm release', url: 'https://npm.com/release'},
];
const nextRelease = {version: '1.0.0'};
const comment = getSuccessComment(issue, releaseInfos, nextRelease);

t.is(
comment,
`:tada: This issue has been resolved in version 1.0.0 :tada:
The release is available on:
- [GitHub release](https://github.com/release)
- [npm release](https://npm.com/release)
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
);
});

test('Comment for PR with multiple releases', t => {
const issue = {number: 1, pull_request: {}}; // eslint-disable-line camelcase
const releaseInfos = [
{name: 'GitHub release', url: 'https://github.com/release'},
{name: 'npm release', url: 'https://npm.com/release'},
];
const nextRelease = {version: '1.0.0'};
const comment = getSuccessComment(issue, releaseInfos, nextRelease);

t.is(
comment,
`:tada: This PR is included in version 1.0.0 :tada:
The release is available on:
- [GitHub release](https://github.com/release)
- [npm release](https://npm.com/release)
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
);
});

test('Comment with missing release URL', t => {
const issue = {number: 1};
const releaseInfos = [{name: 'GitHub release', url: 'https://github.com/release'}, {name: 'npm release'}];
const nextRelease = {version: '1.0.0'};
const comment = getSuccessComment(issue, releaseInfos, nextRelease);

t.is(
comment,
`:tada: This issue has been resolved in version 1.0.0 :tada:
The release is available on:
- [GitHub release](https://github.com/release)
- \`npm release\`
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
);
});

test('Comment with one release', t => {
const issue = {number: 1};
const releaseInfos = [{name: 'GitHub release', url: 'https://github.com/release'}];
const nextRelease = {version: '1.0.0'};
const comment = getSuccessComment(issue, releaseInfos, nextRelease);

t.is(
comment,
`:tada: This issue has been resolved in version 1.0.0 :tada:
The release is available on [GitHub release](https://github.com/release)
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
);
});

test('Comment with no release object', t => {
const issue = {number: 1};
const releaseInfos = [];
const nextRelease = {version: '1.0.0'};
const comment = getSuccessComment(issue, releaseInfos, nextRelease);

t.is(
comment,
`:tada: This issue has been resolved in version 1.0.0 :tada:
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
);
});
Loading

0 comments on commit a28de30

Please sign in to comment.