From 16589bfb6931d59c1c53c4d22391d3b202b5c07a Mon Sep 17 00:00:00 2001 From: Nils Andresen Date: Wed, 21 Feb 2024 21:47:48 +0100 Subject: [PATCH] feat(github): Add the possibility to link a Milestone (#27343) Co-authored-by: Michael Kriese Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com> Co-authored-by: Rhys Arkins --- docs/usage/configuration-options.md | 11 +++ lib/config/options/index.ts | 7 ++ lib/config/types.ts | 1 + lib/modules/platform/github/index.spec.ts | 86 +++++++++++++++++++++++ lib/modules/platform/github/index.ts | 37 ++++++++++ lib/modules/platform/types.ts | 1 + lib/util/http/github.spec.ts | 20 ++++++ lib/util/http/github.ts | 2 + lib/workers/repository/update/pr/index.ts | 1 + 9 files changed, 166 insertions(+) diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 647ffe6adcccb4..83274fdd043376 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2195,6 +2195,17 @@ Be careful with remapping `warn` or `error` messages to lower log levels, as it Add to this object if you wish to define rules that apply only to major updates. +## milestone + +If set to the number of an existing [GitHub milestone](https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/about-milestones), Renovate will add that milestone to its PR. +Renovate will only add a milestone when it _creates_ the PR. + +```json title="Example Renovate config" +{ + "milestone": 12 +} +``` + ## minimumReleaseAge This feature used to be called `stabilityDays`. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 9270f2ebcb74b0..e5f2878f09a06f 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -2844,6 +2844,13 @@ const options: RenovateOptions[] = [ cli: false, env: false, }, + { + name: 'milestone', + description: `The number of a milestone. If set, the milestone will be set when Renovate creates the PR.`, + type: 'integer', + default: null, + supportedPlatforms: ['github'], + }, ]; export function getOptions(): RenovateOptions[] { diff --git a/lib/config/types.ts b/lib/config/types.ts index dc9a690db41b79..7d0b8c4b0e3891 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -90,6 +90,7 @@ export interface RenovateSharedConfig { unicodeEmoji?: boolean; gitIgnoredAuthors?: string[]; platformCommit?: boolean; + milestone?: number; } // Config options used only within the global worker diff --git a/lib/modules/platform/github/index.spec.ts b/lib/modules/platform/github/index.spec.ts index 32a8b842118140..11b8efe42f3857 100644 --- a/lib/modules/platform/github/index.spec.ts +++ b/lib/modules/platform/github/index.spec.ts @@ -2852,6 +2852,92 @@ describe('modules/platform/github/index', () => { ]); }); }); + + describe('milestone', () => { + it('should set the milestone on the PR', async () => { + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .post( + '/repos/some/repo/pulls', + (body) => body.title === 'bump someDep to v2', + ) + .reply(200, { + number: 123, + head: { repo: { full_name: 'some/repo' }, ref: 'some-branch' }, + }); + scope + .patch('/repos/some/repo/issues/123', (body) => body.milestone === 1) + .reply(200, {}); + await github.initRepo({ repository: 'some/repo' }); + const pr = await github.createPr({ + targetBranch: 'main', + sourceBranch: 'renovate/someDep-v2', + prTitle: 'bump someDep to v2', + prBody: 'many informations about someDep', + milestone: 1, + }); + expect(pr?.number).toBe(123); + }); + + it('should log a warning but not throw on error', async () => { + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .post( + '/repos/some/repo/pulls', + (body) => body.title === 'bump someDep to v2', + ) + .reply(200, { + number: 123, + head: { repo: { full_name: 'some/repo' }, ref: 'some-branch' }, + }); + scope + .patch('/repos/some/repo/issues/123', (body) => body.milestone === 1) + .reply(422, { + message: 'Validation Failed', + errors: [ + { + value: 1, + resource: 'Issue', + field: 'milestone', + code: 'invalid', + }, + ], + documentation_url: + 'https://docs.github.com/rest/issues/issues#update-an-issue', + }); + await github.initRepo({ repository: 'some/repo' }); + const pr = await github.createPr({ + targetBranch: 'main', + sourceBranch: 'renovate/someDep-v2', + prTitle: 'bump someDep to v2', + prBody: 'many informations about someDep', + milestone: 1, + }); + expect(pr?.number).toBe(123); + expect(logger.logger.warn).toHaveBeenCalledWith( + { + err: { + message: 'Validation Failed', + errors: [ + { + value: 1, + resource: 'Issue', + field: 'milestone', + code: 'invalid', + }, + ], + documentation_url: + 'https://docs.github.com/rest/issues/issues#update-an-issue', + }, + milestone: 1, + pr: 123, + }, + 'Unable to add milestone to PR', + ); + }); + }); }); describe('getPr(prNo)', () => { diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts index 309210317668c2..d8eea93985f6c3 100644 --- a/lib/modules/platform/github/index.ts +++ b/lib/modules/platform/github/index.ts @@ -1378,6 +1378,41 @@ export async function ensureIssueClosing(title: string): Promise { } } +async function tryAddMilestone( + issueNo: number, + milestoneNo: number | undefined, +): Promise { + if (!milestoneNo) { + return; + } + + logger.debug( + { + milestone: milestoneNo, + pr: issueNo, + }, + 'Adding milestone to PR', + ); + const repository = config.parentRepo ?? config.repository; + try { + await githubApi.patchJson(`repos/${repository}/issues/${issueNo}`, { + body: { + milestone: milestoneNo, + }, + }); + } catch (err) { + const actualError = err.response?.body || /* istanbul ignore next */ err; + logger.warn( + { + milestone: milestoneNo, + pr: issueNo, + err: actualError, + }, + 'Unable to add milestone to PR', + ); + } +} + export async function addAssignees( issueNo: number, assignees: string[], @@ -1658,6 +1693,7 @@ export async function createPr({ labels, draftPR = false, platformOptions, + milestone, }: CreatePRConfig): Promise { const body = sanitize(rawBody); const base = targetBranch; @@ -1697,6 +1733,7 @@ export async function createPr({ const { number, node_id } = result; await addLabels(number, labels); + await tryAddMilestone(number, milestone); await tryPrAutomerge(number, node_id, platformOptions); cachePr(result); diff --git a/lib/modules/platform/types.ts b/lib/modules/platform/types.ts index e98e3cae015640..8a27564666d997 100644 --- a/lib/modules/platform/types.ts +++ b/lib/modules/platform/types.ts @@ -112,6 +112,7 @@ export interface CreatePRConfig { labels?: string[] | null; platformOptions?: PlatformPrOptions; draftPR?: boolean; + milestone?: number; } export interface UpdatePrConfig { number: number; diff --git a/lib/util/http/github.spec.ts b/lib/util/http/github.spec.ts index c2acafddbb7462..68af7bc831436d 100644 --- a/lib/util/http/github.spec.ts +++ b/lib/util/http/github.spec.ts @@ -461,6 +461,26 @@ describe('util/http/github', () => { }), ).rejects.toThrow('Sorry, this is a teapot'); }); + + it('should throw original error when milestone not found', async () => { + const milestoneNotFoundError = { + message: 'Validation Failed', + errors: [ + { + value: 1, + resource: 'Issue', + field: 'milestone', + code: 'invalid', + }, + ], + documentation_url: + 'https://docs.github.com/rest/issues/issues#update-an-issue', + }; + + await expect(fail(422, milestoneNotFoundError)).rejects.toThrow( + 'Validation Failed', + ); + }); }); }); diff --git a/lib/util/http/github.ts b/lib/util/http/github.ts index 1197c841d69d69..65f9e6e81ba22b 100644 --- a/lib/util/http/github.ts +++ b/lib/util/http/github.ts @@ -135,6 +135,8 @@ function handleGotError( message.includes('Review cannot be requested from pull request author') ) { return err; + } else if (err.body?.errors?.find((e: any) => e.field === 'milestone')) { + return err; } else if (err.body?.errors?.find((e: any) => e.code === 'invalid')) { logger.debug({ err }, 'Received invalid response - aborting'); return new Error(REPOSITORY_CHANGED); diff --git a/lib/workers/repository/update/pr/index.ts b/lib/workers/repository/update/pr/index.ts index 02f5ba42da37f3..bd136d0a3bd91a 100644 --- a/lib/workers/repository/update/pr/index.ts +++ b/lib/workers/repository/update/pr/index.ts @@ -437,6 +437,7 @@ export async function ensurePr( labels: prepareLabels(config), platformOptions: getPlatformPrOptions(config), draftPR: !!config.draftPR, + milestone: config.milestone, }); incLimitedValue('PullRequests');