From af0882fd1c316ff610187720eb9692b1bd9a021f Mon Sep 17 00:00:00 2001 From: Jochen Ulrich Date: Thu, 31 Mar 2022 23:26:55 +0200 Subject: [PATCH] Add `gitlab.milestones` option to associate milestones with a release (#883) * Add `gitlab.milestones` option to associate milestones with a release * Add check for GitLab release milestones --- config/release-it.json | 1 + docs/gitlab-releases.md | 17 +++++++++ lib/plugin/gitlab/GitLab.js | 68 ++++++++++++++++++++++++++++++++++ test/gitlab.js | 73 +++++++++++++++++++++++++++++++++++-- test/stub/gitlab.js | 16 ++++++++ 5 files changed, 171 insertions(+), 4 deletions(-) diff --git a/config/release-it.json b/config/release-it.json index 4f4fd5c3..7cb17c9b 100644 --- a/config/release-it.json +++ b/config/release-it.json @@ -49,6 +49,7 @@ "release": false, "releaseName": "Release ${version}", "releaseNotes": null, + "milestones": [], "tokenRef": "GITLAB_TOKEN", "tokenHeader": "Private-Token", "certificateAuthorityFile": null, diff --git a/docs/gitlab-releases.md b/docs/gitlab-releases.md index fd46743b..a23b3821 100644 --- a/docs/gitlab-releases.md +++ b/docs/gitlab-releases.md @@ -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), diff --git a/lib/plugin/gitlab/GitLab.js b/lib/plugin/gitlab/GitLab.js index fff28133..89d6900d 100644 --- a/lib/plugin/gitlab/GitLab.js +++ b/lib/plugin/gitlab/GitLab.js @@ -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(); @@ -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 }); @@ -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'); diff --git a/test/gitlab.js b/test/gitlab.js index 7a1073e8..981c4c53 100644 --- a/test/gitlab.js +++ b/test/gitlab.js @@ -7,7 +7,8 @@ const { interceptCollaborator, interceptCollaboratorFallback, interceptPublish, - interceptAsset + interceptAsset, + interceptMilestones } = require('./stub/gitlab'); const { factory, runTasks } = require('./util'); @@ -55,7 +56,8 @@ 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 }); @@ -63,6 +65,26 @@ test.serial('should upload assets and release', async t => { 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: { @@ -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'] } }); @@ -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); @@ -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); }); diff --git a/test/stub/gitlab.js b/test/stub/gitlab.js index 5a011f59..f5d096ed 100644 --- a/test/stub/gitlab.js +++ b/test/stub/gitlab.js @@ -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