Skip to content

Commit

Permalink
Add gitlab.milestones option to associate milestones with a release (
Browse files Browse the repository at this point in the history
…#883)

* Add `gitlab.milestones` option to associate milestones with a release

* Add check for GitLab release milestones
  • Loading branch information
j-ulrich committed Mar 31, 2022
1 parent 3ccdef0 commit af0882f
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 4 deletions.
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

0 comments on commit af0882f

Please sign in to comment.