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

Add gitlab.milestones option to associate milestones with a release #883

Merged
merged 2 commits into from Mar 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions config/release-it.json
Expand Up @@ -49,6 +49,7 @@
"release": false,
"releaseName": "Release ${version}",
"releaseNotes": null,
"milestones": [],
"tokenRef": "GITLAB_TOKEN",
"tokenHeader": "Private-Token",
"certificateAuthorityFile": null,
Expand Down
17 changes: 17 additions & 0 deletions docs/gitlab-releases.md
Expand Up @@ -40,6 +40,23 @@ An example:

See [Changelog](./changelog.md) for more information about generating changelogs/release notes.

## Milestones

To associate one or more milestones with a GitLab release, set the `gitlab.milestones` option to an array of the
titles of the corresponding milestones, for example:

```json
{
"gitlab": {
"release": true,
"milestones": ["${version}"]
}
}
```

Note that creating a GitLab release will fail if one of the given milestones does not exist. release-it will check this
before doing the release. To skip this check, use `gitlab.skipChecks`.

## Attach binary assets

To upload binary release assets with a GitLab release (such as compiled executables, minified scripts, documentation),
Expand Down
68 changes: 68 additions & 0 deletions lib/plugin/gitlab/GitLab.js
Expand Up @@ -107,6 +107,69 @@ class GitLab extends Release {
}
}

async beforeRelease() {
await super.beforeRelease();
await this.checkReleaseMilestones();
}

async checkReleaseMilestones() {
if (this.options.skipChecks) return;

const releaseMilestones = this.getReleaseMilestones();
if (releaseMilestones.length < 1) {
return;
}

this.log.exec(`gitlab releases#checkReleaseMilestones`);

const { id } = this.getContext();
const endpoint = `projects/${id}/milestones`;
const requests = [];
for (let releaseMilestone of releaseMilestones) {
const options = {
method: 'GET',
searchParams: {
title: releaseMilestone,
include_parent_milestones: true
}
};
requests.push(
this.request(endpoint, options).then(response => {
if (!Array.isArray(response)) {
const { baseUrl } = this.getContext();
throw new Error(
`Unexpected response from ${baseUrl}/${endpoint}. Expected an array but got: ${JSON.stringify(response)}`
);
}
if (response.length === 0) {
const error = new Error(`Milestone '${releaseMilestone}' does not exist!`);
this.log.warn(error.message);
throw error;
}
this.log.verbose(`gitlab releases#checkReleaseMilestones: milestone '${releaseMilestone}' exists`);
})
);
}
try {
await Promise.allSettled(requests).then(results => {
for (const result of results) {
if (result.status === 'rejected') {
throw e('Missing one or more milestones in GitLab. Creating a GitLab release will fail.', docs);
}
}
});
} catch (err) {
this.debug(err);
throw err;
}
this.log.verbose('gitlab releases#checkReleaseMilestones: done');
}

getReleaseMilestones() {
const { milestones } = this.options;
return (milestones || []).map(milestone => format(milestone, this.config.getContext()));
}

async release() {
const glRelease = () => this.createRelease();
const glUploadAssets = () => this.uploadAssets();
Expand Down Expand Up @@ -138,6 +201,7 @@ class GitLab extends Release {
const name = format(releaseName, this.config.getContext());
const description = releaseNotes || '-';
const releaseUrl = `${origin}/${repo.repository}/-/releases`;
const releaseMilestones = this.getReleaseMilestones();

this.log.exec(`gitlab releases#createRelease "${name}" (${tagName})`, { isDryRun });

Expand All @@ -161,6 +225,10 @@ class GitLab extends Release {
};
}

if (releaseMilestones.length) {
options.json.milestones = releaseMilestones;
}

try {
await this.request(endpoint, options);
this.log.verbose('gitlab releases#createRelease: done');
Expand Down
73 changes: 69 additions & 4 deletions test/gitlab.js
Expand Up @@ -7,7 +7,8 @@ const {
interceptCollaborator,
interceptCollaboratorFallback,
interceptPublish,
interceptAsset
interceptAsset,
interceptMilestones
} = require('./stub/gitlab');
const { factory, runTasks } = require('./util');

Expand Down Expand Up @@ -55,14 +56,35 @@ test.serial('should upload assets and release', async t => {
release: true,
releaseName: 'Release ${version}',
releaseNotes: 'echo Custom notes',
assets: 'test/resources/file-v${version}.txt'
assets: 'test/resources/file-v${version}.txt',
milestones: ['${version}', '${latestVersion} UAT']
}
};
const gitlab = factory(GitLab, { options });
sinon.stub(gitlab, 'getLatestVersion').resolves('2.0.0');

interceptUser();
interceptCollaborator();
interceptMilestones({
query: { title: '2.0.1' },
milestones: [
{
id: 17,
iid: 3,
title: '2.0.1'
}
]
});
interceptMilestones({
query: { title: '2.0.0 UAT' },
milestones: [
{
id: 42,
iid: 4,
title: '2.0.0 UAT'
}
]
});
interceptAsset();
interceptPublish({
body: {
Expand All @@ -76,7 +98,8 @@ test.serial('should upload assets and release', async t => {
url: `${pushRepo}/uploads/7e8bec1fe27cc46a4bc6a91b9e82a07c/file-v2.0.1.txt`
}
]
}
},
milestones: ['2.0.1', '2.0.0 UAT']
}
});

Expand All @@ -88,6 +111,41 @@ test.serial('should upload assets and release', async t => {
t.is(releaseUrl, `${pushRepo}/-/releases`);
});

test.serial('should throw when release milestone is missing', async t => {
const pushRepo = 'https://gitlab.com/user/repo';
const options = {
git: { pushRepo },
gitlab: {
tokenRef,
release: true,
milestones: ['${version}', '${latestVersion} UAT']
}
};
const gitlab = factory(GitLab, { options });
sinon.stub(gitlab, 'getLatestVersion').resolves('2.0.0');

interceptUser();
interceptCollaborator();
interceptMilestones({
query: { title: '2.0.1' },
milestones: [
{
id: 17,
iid: 3,
title: '2.0.1'
}
]
});
interceptMilestones({
query: { title: '2.0.0 UAT' },
milestones: []
});

await t.throwsAsync(runTasks(gitlab), {
message: /^Missing one or more milestones in GitLab. Creating a GitLab release will fail./
});
});

test.serial('should release to self-managed host', async t => {
const host = 'https://gitlab.example.org';
const scope = nock(host);
Expand Down Expand Up @@ -195,7 +253,14 @@ test('should not make requests in dry run', async t => {
});

test('should skip checks', async t => {
const options = { gitlab: { tokenRef, skipChecks: true } };
const options = { gitlab: { tokenRef, skipChecks: true, release: true, milestones: ['v1.0.0'] } };
const gitlab = factory(GitLab, { options });
const spy = sinon.spy(gitlab, 'client', ['get']);

await t.notThrowsAsync(gitlab.init());
await t.notThrowsAsync(gitlab.beforeRelease());

t.is(spy.get.callCount, 0);

t.is(gitlab.log.exec.args.filter(entry => /checkReleaseMilestones/.test(entry[0])).length, 0);
});
16 changes: 16 additions & 0 deletions test/stub/gitlab.js
Expand Up @@ -19,6 +19,22 @@ module.exports.interceptCollaboratorFallback = (
.get(`/api/v4/projects/${group ? `${group}%2F` : ''}${owner}%2F${project}/members/${userId}`)
.reply(200, { id: userId, username: owner, access_level: 30 });

module.exports.interceptMilestones = (
{ host = 'https://gitlab.com', owner = 'user', project = 'repo', query = {}, milestones = [] } = {},
options
) =>
nock(host, options)
.get(`/api/v4/projects/${owner}%2F${project}/milestones`)
.query(
Object.assign(
{
include_parent_milestones: true
},
query
)
)
.reply(200, JSON.stringify(milestones));

module.exports.interceptPublish = (
{ host = 'https://gitlab.com', owner = 'user', project = 'repo', body } = {},
options
Expand Down