feat(github): Add the possibility to link a Milestone (#27343)

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
This commit is contained in:
Nils Andresen 2024-02-21 21:47:48 +01:00 committed by GitHub
parent 2c2608f2a9
commit 16589bfb69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 166 additions and 0 deletions

View file

@ -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. 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 ## minimumReleaseAge
This feature used to be called `stabilityDays`. This feature used to be called `stabilityDays`.

View file

@ -2844,6 +2844,13 @@ const options: RenovateOptions[] = [
cli: false, cli: false,
env: 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[] { export function getOptions(): RenovateOptions[] {

View file

@ -90,6 +90,7 @@ export interface RenovateSharedConfig {
unicodeEmoji?: boolean; unicodeEmoji?: boolean;
gitIgnoredAuthors?: string[]; gitIgnoredAuthors?: string[];
platformCommit?: boolean; platformCommit?: boolean;
milestone?: number;
} }
// Config options used only within the global worker // Config options used only within the global worker

View file

@ -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)', () => { describe('getPr(prNo)', () => {

View file

@ -1378,6 +1378,41 @@ export async function ensureIssueClosing(title: string): Promise<void> {
} }
} }
async function tryAddMilestone(
issueNo: number,
milestoneNo: number | undefined,
): Promise<void> {
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( export async function addAssignees(
issueNo: number, issueNo: number,
assignees: string[], assignees: string[],
@ -1658,6 +1693,7 @@ export async function createPr({
labels, labels,
draftPR = false, draftPR = false,
platformOptions, platformOptions,
milestone,
}: CreatePRConfig): Promise<GhPr | null> { }: CreatePRConfig): Promise<GhPr | null> {
const body = sanitize(rawBody); const body = sanitize(rawBody);
const base = targetBranch; const base = targetBranch;
@ -1697,6 +1733,7 @@ export async function createPr({
const { number, node_id } = result; const { number, node_id } = result;
await addLabels(number, labels); await addLabels(number, labels);
await tryAddMilestone(number, milestone);
await tryPrAutomerge(number, node_id, platformOptions); await tryPrAutomerge(number, node_id, platformOptions);
cachePr(result); cachePr(result);

View file

@ -112,6 +112,7 @@ export interface CreatePRConfig {
labels?: string[] | null; labels?: string[] | null;
platformOptions?: PlatformPrOptions; platformOptions?: PlatformPrOptions;
draftPR?: boolean; draftPR?: boolean;
milestone?: number;
} }
export interface UpdatePrConfig { export interface UpdatePrConfig {
number: number; number: number;

View file

@ -461,6 +461,26 @@ describe('util/http/github', () => {
}), }),
).rejects.toThrow('Sorry, this is a teapot'); ).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',
);
});
}); });
}); });

View file

@ -135,6 +135,8 @@ function handleGotError(
message.includes('Review cannot be requested from pull request author') message.includes('Review cannot be requested from pull request author')
) { ) {
return err; 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')) { } else if (err.body?.errors?.find((e: any) => e.code === 'invalid')) {
logger.debug({ err }, 'Received invalid response - aborting'); logger.debug({ err }, 'Received invalid response - aborting');
return new Error(REPOSITORY_CHANGED); return new Error(REPOSITORY_CHANGED);

View file

@ -437,6 +437,7 @@ export async function ensurePr(
labels: prepareLabels(config), labels: prepareLabels(config),
platformOptions: getPlatformPrOptions(config), platformOptions: getPlatformPrOptions(config),
draftPR: !!config.draftPR, draftPR: !!config.draftPR,
milestone: config.milestone,
}); });
incLimitedValue('PullRequests'); incLimitedValue('PullRequests');