Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: comment on issues and merge requests resolved by current release #332

Merged
merged 6 commits into from
Mar 20, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Create a [personal access token](https://docs.gitlab.com/ce/user/profile/persona
| `gitlabApiPathPrefix` | The GitLab API prefix. | `GL_PREFIX` or `GITLAB_PREFIX` environment variable or CI provided environment variables if running on [GitLab CI/CD](https://docs.gitlab.com/ee/ci) or `/api/v4`. |
| `assets` | An array of files to upload to the release. See [assets](#assets). | - |
| `milestones` | An array of milestone titles to associate to the release. See [GitLab Release API](https://docs.gitlab.com/ee/api/releases/#create-a-release). | - |
| `postComments` | Post comments to issues and MRs associated with a release. | `false` |
fgreinacher marked this conversation as resolved.
Show resolved Hide resolved

#### assets

Expand Down
12 changes: 11 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const verifyGitLab = require('./lib/verify');
const publishGitLab = require('./lib/publish');
const successGitLab = require('./lib/success');

let verified;

Expand All @@ -19,4 +20,13 @@ async function publish(pluginConfig, context) {
return publishGitLab(pluginConfig, context);
}

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

return successGitLab(pluginConfig, context);
}

module.exports = {verifyConditions, publish, success};
3 changes: 3 additions & 0 deletions lib/definitions/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const RELEASE_NAME = 'GitLab release';

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

module.exports = (issueOrMergeRequest, releaseInfos, nextRelease) =>
`:tada: This ${issueOrMergeRequest.isMergeRequest ? 'MR 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:`;
5 changes: 4 additions & 1 deletion lib/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const debug = require('debug')('semantic-release:gitlab');
const resolveConfig = require('./resolve-config');
const getRepoId = require('./get-repo-id');
const getAssets = require('./glob-assets');
const {RELEASE_NAME} = require('./definitions/constants');

module.exports = async (pluginConfig, context) => {
const {
Expand Down Expand Up @@ -117,5 +118,7 @@ module.exports = async (pluginConfig, context) => {

logger.log('Published GitLab release: %s', gitTag);

return {url: urlJoin(gitlabUrl, encodedRepoId, `/-/releases/${encodedGitTag}`), name: 'GitLab release'};
const releaseUrl = urlJoin(gitlabUrl, encodedRepoId, `/-/releases/${encodedGitTag}`);

return {name: RELEASE_NAME, url: releaseUrl};
};
4 changes: 2 additions & 2 deletions lib/resolve-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const {castArray, isNil} = require('lodash');
const urlJoin = require('url-join');

module.exports = (
{gitlabUrl, gitlabApiPathPrefix, assets, milestones},
{gitlabUrl, gitlabApiPathPrefix, assets, milestones, successComment},
{
envCi: {service} = {},
env: {
Expand All @@ -29,7 +29,6 @@ module.exports = (
(service === 'gitlab' && CI_PROJECT_URL && CI_PROJECT_PATH
? CI_PROJECT_URL.replace(new RegExp(`/${CI_PROJECT_PATH}$`), '')
: 'https://gitlab.com');

return {
gitlabToken: GL_TOKEN || GITLAB_TOKEN,
gitlabUrl: defaultedGitlabUrl,
Expand All @@ -41,5 +40,6 @@ module.exports = (
: urlJoin(defaultedGitlabUrl, isNil(userGitlabApiPathPrefix) ? '/api/v4' : userGitlabApiPathPrefix),
assets: assets ? castArray(assets) : assets,
milestones: milestones ? castArray(milestones) : milestones,
successComment,
};
};
96 changes: 96 additions & 0 deletions lib/success.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
const {uniqWith, isEqual, template} = require('lodash');
const urlJoin = require('url-join');
const got = require('got');
const debug = require('debug')('semantic-release:gitlab');
const resolveConfig = require('./resolve-config');
const getRepoId = require('./get-repo-id');
const getSuccessComment = require('./get-success-comment');

module.exports = async (pluginConfig, context) => {
const {
options: {repositoryUrl},
nextRelease,
logger,
commits,
releases,
} = context;
const {gitlabToken, gitlabUrl, gitlabApiUrl, successComment} = resolveConfig(pluginConfig, context);
const repoId = getRepoId(context, gitlabUrl, repositoryUrl);
const encodedRepoId = encodeURIComponent(repoId);
const apiOptions = {headers: {'PRIVATE-TOKEN': gitlabToken}};

if (successComment === false) {
logger.log('Skip commenting on issues and pull requests.');
} else {
const releaseInfos = releases.filter(release => Boolean(release.name));
try {
const postCommentToIssue = issue => {
const issueNotesEndpoint = urlJoin(gitlabApiUrl, `/projects/${issue.project_id}/issues/${issue.iid}/notes`);
debug('Posting issue note to %s', issueNotesEndpoint);
const body = successComment
? template(successComment)({...context, issue})
: getSuccessComment(issue, releaseInfos, nextRelease);
return got.post(issueNotesEndpoint, {
...apiOptions,
json: {body},
});
};

const postCommentToMergeRequest = mergeRequest => {
const mergeRequestNotesEndpoint = urlJoin(
gitlabApiUrl,
`/projects/${mergeRequest.project_id}/merge_requests/${mergeRequest.iid}/notes`
);
debug('Posting MR note to %s', mergeRequestNotesEndpoint);
const body = successComment
? template(successComment)({...context, mergeRequest})
: getSuccessComment({isMergeRequest: true, ...mergeRequest}, releaseInfos, nextRelease);
return got.post(mergeRequestNotesEndpoint, {
...apiOptions,
json: {body},
});
};

const getRelatedMergeRequests = async commitHash => {
const relatedMergeRequestsEndpoint = urlJoin(
gitlabApiUrl,
`/projects/${encodedRepoId}/repository/commits/${commitHash}/merge_requests`
);
debug('Getting MRs from %s', relatedMergeRequestsEndpoint);
const relatedMergeRequests = await got
.get(relatedMergeRequestsEndpoint, {
...apiOptions,
})
.json();

return relatedMergeRequests.filter(x => x.state === 'merged');
};

const getRelatedIssues = async mergeRequest => {
const relatedIssuesEndpoint = urlJoin(
gitlabApiUrl,
`/projects/${mergeRequest.project_id}/merge_requests/${mergeRequest.iid}/closes_issues`
);
debug('Getting related issues from %s', relatedIssuesEndpoint);
const relatedIssues = await got
.get(relatedIssuesEndpoint, {
...apiOptions,
})
.json();

return relatedIssues.filter(x => x.state === 'closed');
};

const relatedMergeRequests = uniqWith(
(await Promise.all(commits.map(x => x.hash).map(getRelatedMergeRequests))).flat(),
isEqual
);
const relatedIssues = uniqWith((await Promise.all(relatedMergeRequests.map(getRelatedIssues))).flat(), isEqual);
await Promise.all(relatedIssues.map(postCommentToIssue));
await Promise.all(relatedMergeRequests.map(postCommentToMergeRequest));
} catch (error) {
logger.error('An error occurred while posting comments to related issues and merge requests:\n%O', error);
throw error;
}
}
};
29 changes: 22 additions & 7 deletions test/resolve-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ test('Returns user config', t => {
const gitlabUrl = 'https://host.com';
const gitlabApiPathPrefix = '/api/prefix';
const assets = ['file.js'];
const postComments = true;

t.deepEqual(resolveConfig({gitlabUrl, gitlabApiPathPrefix, assets}, {env: {GITLAB_TOKEN: gitlabToken}}), {
gitlabToken,
gitlabUrl,
gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix),
assets,
milestones: undefined,
});
t.deepEqual(
resolveConfig({gitlabUrl, gitlabApiPathPrefix, assets, postComments}, {env: {GITLAB_TOKEN: gitlabToken}}),
{
gitlabToken,
gitlabUrl,
gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix),
assets,
milestones: undefined,
successComment: undefined,
travi marked this conversation as resolved.
Show resolved Hide resolved
}
);
});

test('Returns user config via environment variables', t => {
Expand All @@ -35,6 +40,7 @@ test('Returns user config via environment variables', t => {
gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix),
assets,
milestones,
successComment: undefined,
}
);
});
Expand All @@ -53,6 +59,7 @@ test('Returns user config via alternative environment variables', t => {
gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix),
assets,
milestones: undefined,
successComment: undefined,
}
);
});
Expand All @@ -68,6 +75,7 @@ test('Returns default config', t => {
gitlabApiUrl: urlJoin('https://gitlab.com', '/api/v4'),
assets: undefined,
milestones: undefined,
successComment: undefined,
});

t.deepEqual(resolveConfig({gitlabApiPathPrefix}, {env: {GL_TOKEN: gitlabToken}}), {
Expand All @@ -76,6 +84,7 @@ test('Returns default config', t => {
gitlabApiUrl: urlJoin('https://gitlab.com', gitlabApiPathPrefix),
assets: undefined,
milestones: undefined,
successComment: undefined,
});

t.deepEqual(resolveConfig({gitlabUrl}, {env: {GL_TOKEN: gitlabToken}}), {
Expand All @@ -84,6 +93,7 @@ test('Returns default config', t => {
gitlabApiUrl: urlJoin(gitlabUrl, '/api/v4'),
assets: undefined,
milestones: undefined,
successComment: undefined,
});
});

Expand All @@ -107,6 +117,7 @@ test('Returns default config via GitLab CI/CD environment variables', t => {
gitlabApiUrl: CI_API_V4_URL,
assets: undefined,
milestones: undefined,
successComment: undefined,
}
);
});
Expand Down Expand Up @@ -134,6 +145,7 @@ test('Returns user config over GitLab CI/CD environment variables', t => {
gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix),
assets,
milestones: undefined,
successComment: undefined,
}
);
});
Expand Down Expand Up @@ -167,6 +179,7 @@ test('Returns user config via environment variables over GitLab CI/CD environmen
gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix),
assets: undefined,
milestones: undefined,
successComment: undefined,
}
);
});
Expand Down Expand Up @@ -200,6 +213,7 @@ test('Returns user config via alternative environment variables over GitLab CI/C
gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix),
assets: undefined,
milestones: undefined,
successComment: undefined,
}
);
});
Expand All @@ -224,6 +238,7 @@ test('Ignore GitLab CI/CD environment variables if not running on GitLab CI/CD',
gitlabApiUrl: urlJoin('https://gitlab.com', '/api/v4'),
assets: undefined,
milestones: undefined,
successComment: undefined,
}
);
});
83 changes: 83 additions & 0 deletions test/success.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const test = require('ava');
const nock = require('nock');
const {stub} = require('sinon');
const success = require('../lib/success');
const authenticate = require('./helpers/mock-gitlab');
const {RELEASE_NAME} = require('../lib/definitions/constants');

/* eslint camelcase: ["error", {properties: "never"}] */

test.beforeEach(t => {
// Mock logger
t.context.log = stub();
t.context.error = stub();
t.context.logger = {log: t.context.log, error: t.context.error};
});

test.afterEach.always(() => {
// Clear nock
nock.cleanAll();
});

test.serial('Post comments to related issues and MRs', async t => {
const owner = 'test_user';
const repo = 'test_repo';
const env = {GITLAB_TOKEN: 'gitlab_token'};
const pluginConfig = {};
const nextRelease = {version: '1.0.0'};
const releases = [{name: RELEASE_NAME, url: 'https://gitlab.com/test_user%2Ftest_repo/-/releases/v1.0.0'}];
const options = {repositoryUrl: `https://gitlab.com/${owner}/${repo}.git`};
const encodedRepoId = encodeURIComponent(`${owner}/${repo}`);
const commits = [{hash: 'abcdef'}, {hash: 'fedcba'}];
const gitlab = authenticate(env)
.get(`/projects/${encodedRepoId}/repository/commits/abcdef/merge_requests`)
.reply(200, [
{project_id: 100, iid: 1, state: 'merged'},
{project_id: 200, iid: 2, state: 'closed'},
{project_id: 300, iid: 3, state: 'merged'},
])
.get(`/projects/${encodedRepoId}/repository/commits/fedcba/merge_requests`)
.reply(200, [{project_id: 100, iid: 1, state: 'merged'}])
.get(`/projects/100/merge_requests/1/closes_issues`)
.reply(200, [
{project_id: 100, iid: 11, state: 'closed'},
{project_id: 100, iid: 12, state: 'open'},
{project_id: 100, iid: 13, state: 'closed'},
])
.get(`/projects/300/merge_requests/3/closes_issues`)
.reply(200, [])
.post(`/projects/100/merge_requests/1/notes`, {
body:
':tada: This MR is included in version 1.0.0 :tada:\n\nThe release is available on [GitLab release](https://gitlab.com/test_user%2Ftest_repo/-/releases/v1.0.0)\nYour **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:',
})
.reply(200)
.post(`/projects/300/merge_requests/3/notes`)
.reply(200)
.post(`/projects/100/issues/11/notes`, {
body:
':tada: This issue has been resolved in version 1.0.0 :tada:\n\nThe release is available on [GitLab release](https://gitlab.com/test_user%2Ftest_repo/-/releases/v1.0.0)\nYour **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:',
})
.reply(200)
.post(`/projects/100/issues/13/notes`)
.reply(200);

await success(pluginConfig, {env, options, nextRelease, logger: t.context.logger, commits, releases});

t.true(gitlab.isDone());
});

test.serial('Does not post comments when successComment is set to false', async t => {
const owner = 'test_user';
const repo = 'test_repo';
const env = {GITLAB_TOKEN: 'gitlab_token'};
const pluginConfig = {successComment: false};
const nextRelease = {version: '1.0.0'};
const releases = [{name: RELEASE_NAME, url: 'https://gitlab.com/test_user%2Ftest_repo/-/releases/v1.0.0'}];
const options = {repositoryUrl: `https://gitlab.com/${owner}/${repo}.git`};
const commits = [{hash: 'abcdef'}, {hash: 'fedcba'}];
const gitlab = authenticate(env);

await success(pluginConfig, {env, options, nextRelease, logger: t.context.logger, commits, releases});

t.true(gitlab.isDone());
});