Skip to content

Commit a28de30

Browse files
committed
feat: add success hook
- Add comment to issues and pull requests included in the release
1 parent 68b1d18 commit a28de30

11 files changed

+492
-19
lines changed

README.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ the [assets](#assets) option configuration.
1919

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

22+
## success
23+
24+
Add a comment to each GitHub issue or pull request resolved by the release.
25+
2226
## Configuration
2327

2428
### GitHub authentication
@@ -38,13 +42,14 @@ Follow the [Creating a personal access token for the command line](https://help.
3842

3943
### Options
4044

41-
| Option | Description | Default |
42-
| --------------------- | ------------------------------------------------------------------ | ---------------------------------------------------- |
43-
| `githubUrl` | The GitHub Enterprise endpoint. | `GH_URL` or `GITHUB_URL` environment variable. |
44-
| `githubApiPathPrefix` | The GitHub Enterprise API prefix. | `GH_PREFIX` or `GITHUB_PREFIX` environment variable. |
45-
| `assets` | An array of files to upload to the release. See [assets](#assets). | - |
45+
| Option | Description | Default |
46+
|-----------------------|------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
47+
| `githubUrl` | The GitHub Enterprise endpoint. | `GH_URL` or `GITHUB_URL` environment variable. |
48+
| `githubApiPathPrefix` | The GitHub Enterprise API prefix. | `GH_PREFIX` or `GITHUB_PREFIX` environment variable. |
49+
| `assets` | An array of files to upload to the release. See [assets](#assets). | - |
50+
| `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>)` |
4651

47-
#### `assets`
52+
#### assets
4853

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

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

66-
##### `assets` examples
71+
##### assets examples
6772

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

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

86+
#### successComment
87+
88+
The message for the issue comments is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available:
89+
90+
| Parameter | Description |
91+
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
92+
| `branch` | The branch from which the release is done. |
93+
| `lastRelease` | `Object` with `version`, `gitTag` and `gitHead` of the last release. |
94+
| `nextRelease` | `Object` with `version`, `gitTag`, `gitHead` and `notes` of the release being done. |
95+
| `commits` | `Array` of commit `Object`s with `hash`, `subject`, `body` `message` and `author`. |
96+
| `releases` | `Array` with a release `Object`s for each release published, with optional release data such has `name` and `url`. |
97+
| `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) |
98+
99+
##### successComment examples
100+
101+
The `successComment` `This ${issue.pull_request ? 'pull request' : 'issue'} is included in version ${nextRelease.version}` will generate the comment:
102+
103+
> This pull request is included in version 1.0.0
104+
81105
### Usage
82106

83107
The plugins are used by default by [Semantic-release](https://github.com/semantic-release/semantic-release) so no
@@ -89,7 +113,8 @@ Each individual plugin can be disabled, replaced or used with other plugins in t
89113
{
90114
"release": {
91115
"verifyConditions": ["@semantic-release/github", "@semantic-release/npm", "verify-other-condition"],
92-
"publish": ["@semantic-release/npm", "@semantic-release/github", "other-publish"]
116+
"publish": ["@semantic-release/npm", "@semantic-release/github", "other-publish"],
117+
"success": ["@semantic-release/github", "other-success"]
93118
}
94119
}
95120
```

index.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const verifyGitHub = require('./lib/verify');
22
const publishGitHub = require('./lib/publish');
3+
const successGitHub = require('./lib/success');
34

45
let verified;
56

@@ -13,6 +14,9 @@ async function verifyConditions(pluginConfig, context) {
1314
if (publishPlugin && publishPlugin.assets) {
1415
pluginConfig.assets = publishPlugin.assets;
1516
}
17+
if (publishPlugin && publishPlugin.successComment) {
18+
pluginConfig.successComment = publishPlugin.successComment;
19+
}
1620
}
1721

1822
await verifyGitHub(pluginConfig, context);
@@ -27,4 +31,12 @@ async function publish(pluginConfig, context) {
2731
return publishGitHub(pluginConfig, context);
2832
}
2933

30-
module.exports = {verifyConditions, publish};
34+
async function success(pluginConfig, context) {
35+
if (!verified) {
36+
await verifyGitHub(pluginConfig, context);
37+
verified = true;
38+
}
39+
await successGitHub(pluginConfig, context);
40+
}
41+
42+
module.exports = {verifyConditions, publish, success};

lib/get-success-comment.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const HOME_URL = 'https://github.com/semantic-release/semantic-release';
2+
const linkify = releaseInfo =>
3+
`${releaseInfo.url ? `[${releaseInfo.name}](${releaseInfo.url})` : `\`${releaseInfo.name}\``}`;
4+
5+
module.exports = (issue, releaseInfos, nextRelease) =>
6+
`:tada: This ${issue.pull_request ? 'PR is included' : 'issue has been resolved'} in version ${
7+
nextRelease.version
8+
} :tada:${
9+
releaseInfos.length > 0
10+
? `\n\nThe release is available on${
11+
releaseInfos.length === 1
12+
? ` ${linkify(releaseInfos[0])}`
13+
: `:\n${releaseInfos.map(releaseInfo => `- ${linkify(releaseInfo)}`).join('\n')}`
14+
}`
15+
: ''
16+
}
17+
18+
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`;

lib/resolve-config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
const {castArray} = require('lodash');
22

3-
module.exports = ({githubUrl, githubApiPathPrefix, assets}) => ({
3+
module.exports = ({githubUrl, githubApiPathPrefix, assets, successComment}) => ({
44
githubToken: process.env.GH_TOKEN || process.env.GITHUB_TOKEN,
55
githubUrl: githubUrl || process.env.GH_URL || process.env.GITHUB_URL,
66
githubApiPathPrefix: githubApiPathPrefix || process.env.GH_PREFIX || process.env.GITHUB_PREFIX || '',
77
assets: assets ? castArray(assets) : assets,
8+
successComment,
89
});

lib/success.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const {uniqBy, template} = require('lodash');
2+
const parseGithubUrl = require('parse-github-url');
3+
const pReduce = require('p-reduce');
4+
const AggregateError = require('aggregate-error');
5+
const issueParser = require('issue-parser')('github');
6+
const debug = require('debug')('semantic-release:github');
7+
const resolveConfig = require('./resolve-config');
8+
const getClient = require('./get-client');
9+
const getSuccessComment = require('./get-success-comment');
10+
11+
module.exports = async (
12+
pluginConfig,
13+
{options: {branch, repositoryUrl}, lastRelease, commits, nextRelease, releases, logger}
14+
) => {
15+
const {githubToken, githubUrl, githubApiPathPrefix, successComment} = resolveConfig(pluginConfig);
16+
const {name: repo, owner} = parseGithubUrl(repositoryUrl);
17+
const github = getClient(githubToken, githubUrl, githubApiPathPrefix);
18+
const releaseInfos = releases.filter(release => Boolean(release.name));
19+
20+
// Search for PRs associated with any commit in the release
21+
const {data: {items: prs}} = await github.search.issues({
22+
q: `${commits.map(commit => commit.hash).join('+')}+repo:${owner}/${repo}+type:pr`,
23+
});
24+
25+
debug('found pull requests: %O', prs.map(pr => pr.number));
26+
27+
// Parse the release commits message and PRs body to find resolved issues/PRs via comment keyworkds
28+
const issues = uniqBy(
29+
[...prs.map(pr => pr.body), ...commits.map(commit => commit.message)]
30+
.reduce((issues, message) => {
31+
return message
32+
? issues.concat(issueParser(message).actions.map(action => ({number: parseInt(action.issue, 10)})))
33+
: issues;
34+
}, [])
35+
.filter(issue => !prs.find(pr => pr.number === issue.number)),
36+
'number'
37+
);
38+
39+
debug('found issues via comments: %O', issues);
40+
41+
const errors = [];
42+
43+
// Make requests serially to avoid hitting the rate limit (https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits)
44+
await pReduce([...prs, ...issues], async (_, issue) => {
45+
const body = successComment
46+
? template(successComment)({branch, lastRelease, commits, nextRelease, releases, issue})
47+
: getSuccessComment(issue, releaseInfos, nextRelease);
48+
try {
49+
const comment = {owner, repo, number: issue.number, body};
50+
debug('create comment: %O', comment);
51+
const {data: {html_url: url}} = await github.issues.createComment(comment);
52+
logger.log('Added comment to issue #%d: %s', issue.number, url);
53+
} catch (err) {
54+
errors.push(err);
55+
logger.error('Failed to add a comment to the issue #%d.', issue.number);
56+
// Don't throw right away and continue to update other issues
57+
}
58+
});
59+
if (errors.length > 0) {
60+
throw new AggregateError(errors);
61+
}
62+
};

lib/verify.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ const SemanticReleaseError = require('@semantic-release/error');
55
const resolveConfig = require('./resolve-config');
66
const getClient = require('./get-client');
77

8-
const {githubToken, githubUrl, githubApiPathPrefix, assets} = resolveConfig(pluginConfig);
8+
const isNonEmptyString = value => isString(value) && value.trim();
9+
const isStringOrStringArray = value => isNonEmptyString(value) || (isArray(value) && value.every(isNonEmptyString));
10+
911
module.exports = async (pluginConfig, {options: {repositoryUrl}, logger}) => {
12+
const {githubToken, githubUrl, githubApiPathPrefix, assets, successComment} = resolveConfig(pluginConfig);
1013

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

37+
if (!isUndefined(successComment) && successComment !== false && !isNonEmptyString(successComment)) {
38+
throw new SemanticReleaseError(
39+
'The "successComment" options, if defined, must be a non empty String.',
40+
'EINVALIDSUCCESSCOMMENT'
41+
);
42+
}
43+
3444
if (githubUrl) {
3545
logger.log('Verify GitHub authentication (%s)', urlJoin(githubUrl, githubApiPathPrefix));
3646
} else {
@@ -57,7 +67,3 @@ module.exports = async (pluginConfig, {options: {repositoryUrl}, logger}) => {
5767
);
5868
}
5969
};
60-
61-
function isStringOrStringArray(value) {
62-
return isString(value) || (isArray(value) && value.every(isString));
63-
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616
"Gregor Martynus (https://twitter.com/gr2m)"
1717
],
1818
"dependencies": {
19-
"@octokit/rest": "^14.0.3",
19+
"@octokit/rest": "^14.0.9",
2020
"@semantic-release/error": "^2.1.0",
2121
"debug": "^3.1.0",
2222
"fs-extra": "^5.0.0",
2323
"globby": "^7.1.1",
24+
"issue-parser": "^1.0.1",
2425
"lodash": "^4.17.4",
2526
"mime": "^2.0.3",
2627
"p-reduce": "^1.0.0",

test/get-success-comment.test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import test from 'ava';
2+
import getSuccessComment from '../lib/get-success-comment';
3+
4+
const HOME_URL = 'https://github.com/semantic-release/semantic-release';
5+
6+
test('Comment for issue with multiple releases', t => {
7+
const issue = {number: 1};
8+
const releaseInfos = [
9+
{name: 'GitHub release', url: 'https://github.com/release'},
10+
{name: 'npm release', url: 'https://npm.com/release'},
11+
];
12+
const nextRelease = {version: '1.0.0'};
13+
const comment = getSuccessComment(issue, releaseInfos, nextRelease);
14+
15+
t.is(
16+
comment,
17+
`:tada: This issue has been resolved in version 1.0.0 :tada:
18+
19+
The release is available on:
20+
- [GitHub release](https://github.com/release)
21+
- [npm release](https://npm.com/release)
22+
23+
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
24+
);
25+
});
26+
27+
test('Comment for PR with multiple releases', t => {
28+
const issue = {number: 1, pull_request: {}}; // eslint-disable-line camelcase
29+
const releaseInfos = [
30+
{name: 'GitHub release', url: 'https://github.com/release'},
31+
{name: 'npm release', url: 'https://npm.com/release'},
32+
];
33+
const nextRelease = {version: '1.0.0'};
34+
const comment = getSuccessComment(issue, releaseInfos, nextRelease);
35+
36+
t.is(
37+
comment,
38+
`:tada: This PR is included in version 1.0.0 :tada:
39+
40+
The release is available on:
41+
- [GitHub release](https://github.com/release)
42+
- [npm release](https://npm.com/release)
43+
44+
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
45+
);
46+
});
47+
48+
test('Comment with missing release URL', t => {
49+
const issue = {number: 1};
50+
const releaseInfos = [{name: 'GitHub release', url: 'https://github.com/release'}, {name: 'npm release'}];
51+
const nextRelease = {version: '1.0.0'};
52+
const comment = getSuccessComment(issue, releaseInfos, nextRelease);
53+
54+
t.is(
55+
comment,
56+
`:tada: This issue has been resolved in version 1.0.0 :tada:
57+
58+
The release is available on:
59+
- [GitHub release](https://github.com/release)
60+
- \`npm release\`
61+
62+
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
63+
);
64+
});
65+
66+
test('Comment with one release', t => {
67+
const issue = {number: 1};
68+
const releaseInfos = [{name: 'GitHub release', url: 'https://github.com/release'}];
69+
const nextRelease = {version: '1.0.0'};
70+
const comment = getSuccessComment(issue, releaseInfos, nextRelease);
71+
72+
t.is(
73+
comment,
74+
`:tada: This issue has been resolved in version 1.0.0 :tada:
75+
76+
The release is available on [GitHub release](https://github.com/release)
77+
78+
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
79+
);
80+
});
81+
82+
test('Comment with no release object', t => {
83+
const issue = {number: 1};
84+
const releaseInfos = [];
85+
const nextRelease = {version: '1.0.0'};
86+
const comment = getSuccessComment(issue, releaseInfos, nextRelease);
87+
88+
t.is(
89+
comment,
90+
`:tada: This issue has been resolved in version 1.0.0 :tada:
91+
92+
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`
93+
);
94+
});

0 commit comments

Comments
 (0)