diff --git a/lib/workers/repository/update/branch/schedule.spec.ts b/lib/workers/repository/update/branch/schedule.spec.ts index 3c7f819a4dee7119c27175a27cc85750a7470c50..ca3338350ef31e1dbda953aad2b1b3831e352a8f 100644 --- a/lib/workers/repository/update/branch/schedule.spec.ts +++ b/lib/workers/repository/update/branch/schedule.spec.ts @@ -40,6 +40,14 @@ describe('workers/repository/update/branch/schedule', () => { ).toBeFalse(); }); + it('returns false for wildcard minutes', () => { + const res = schedule.hasValidSchedule(['1 * * * *']); + expect(res).toEqual([ + false, + `Invalid schedule: "1 * * * *" has cron syntax, but doesn't have * as minutes`, + ]); + }); + it('returns false if schedules have no days or time range', () => { expect(schedule.hasValidSchedule(['at 5:00pm'])[0]).toBeFalse(); }); diff --git a/lib/workers/repository/update/branch/status-checks.ts b/lib/workers/repository/update/branch/status-checks.ts index 33d7dd3090c3bbb1468b7634e9011dea778eb63a..023cd083ee2aaec453f9885c51b254ddc1f827d3 100644 --- a/lib/workers/repository/update/branch/status-checks.ts +++ b/lib/workers/repository/update/branch/status-checks.ts @@ -31,7 +31,7 @@ async function setStatusCheck( context: string, description: string, state: BranchStatus, - url: string + url?: string ): Promise<void> { const existingState = await platform.getBranchStatusCheck( branchName, @@ -70,7 +70,7 @@ export async function setStability(config: StabilityConfig): Promise<void> { context, description, config.stabilityStatus, - config.productLinks.documentation + config.productLinks?.documentation ); } @@ -80,7 +80,12 @@ export type ConfidenceConfig = RenovateConfig & { }; export async function setConfidence(config: ConfidenceConfig): Promise<void> { - if (!isActiveConfidenceLevel(config.minimumConfidence)) { + if ( + !config.branchName || + !config.confidenceStatus || + (config.minimumConfidence && + !isActiveConfidenceLevel(config.minimumConfidence)) + ) { return; } const context = `renovate/merge-confidence`; @@ -93,6 +98,6 @@ export async function setConfidence(config: ConfidenceConfig): Promise<void> { context, description, config.confidenceStatus, - config.productLinks.documentation + config.productLinks?.documentation ); } diff --git a/lib/workers/repository/update/pr/__snapshots__/index.spec.ts.snap b/lib/workers/repository/update/pr/__snapshots__/index.spec.ts.snap deleted file mode 100644 index 8453638980fe5bd79ff2f0adc44d7025d0e8e367..0000000000000000000000000000000000000000 --- a/lib/workers/repository/update/pr/__snapshots__/index.spec.ts.snap +++ /dev/null @@ -1,248 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`workers/repository/update/pr/index ensurePr should add and deduplicate additionalReviewers on new PR 1`] = ` -Array [ - Array [ - undefined, - Array [ - "foo", - "bar", - "baz", - "boo", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should add and deduplicate additionalReviewers to empty reviewers on new PR 1`] = ` -Array [ - Array [ - undefined, - Array [ - "bar", - "baz", - "boo", - "foo", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should add assignees and reviewers to new PR 1`] = ` -Array [ - Array [ - undefined, - Array [ - "foo", - "bar", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should add assignees and reviewers to new PR 2`] = ` -Array [ - Array [ - undefined, - Array [ - "baz", - "boo", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should add note about Pin 1`] = ` -Array [ - Object { - "draftPR": false, - "labels": Array [], - "platformOptions": Object { - "azureAutoApprove": false, - "azureWorkItemId": 0, - "bbUseDefaultReviewers": true, - "gitLabIgnoreApprovals": false, - "usePlatformAutomerge": false, - }, - "prBody": "This PR contains the following updates:\\n\\n| Package | Type | Update | Change |\\n|---|---|---|---|\\n| [dummy](https://dummy.com) ([source](https://github.com/renovateapp/dummy), [changelog](https://github.com/renovateapp/dummy/changelog.md)) | devDependencies | pin | \`1.0.0\` -> \`1.1.0\` |\\n\\nAdd the preset \`:preserveSemverRanges\` to your config if you don't want to pin your dependencies.\\n\\n---\\n\\n### Release Notes\\n\\n<details>\\n<summary>renovateapp/dummy</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n---\\n\\n### Configuration\\n\\n📅 **Schedule**: \\"before 5am\\" in timezone some timezone.\\n\\n🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.\\n\\n♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.\\n\\n🔕 **Ignore**: Close this PR and you won't be reminded about this update again.\\n\\n---\\n\\n - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, click this checkbox.\\n\\n---\\n\\nThis PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).", - "prTitle": "Update dependency dummy to v1.1.0", - "sourceBranch": "renovate/dummy-1.x", - "targetBranch": undefined, - }, -] -`; - -exports[`workers/repository/update/pr/index ensurePr should combine assignees from code owners and config 1`] = ` -Array [ - Array [ - undefined, - Array [ - "mike", - "julie", - "jimmy", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should create PR if success 1`] = ` -Array [ - Object { - "draftPR": false, - "labels": Array [], - "platformOptions": Object { - "azureAutoApprove": false, - "azureWorkItemId": 0, - "bbUseDefaultReviewers": true, - "gitLabIgnoreApprovals": false, - "usePlatformAutomerge": false, - }, - "prBody": "This PR contains the following updates:\\n\\n| Package | Type | Update | Change |\\n|---|---|---|---|\\n| [dummy](https://dummy.com) ([source](https://github.com/renovateapp/dummy), [changelog](https://github.com/renovateapp/dummy/changelog.md)) | devDependencies | minor | \`1.0.0\` -> \`1.1.0\` |\\n\\n---\\n\\n### Release Notes\\n\\n<details>\\n<summary>renovateapp/dummy</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n---\\n\\n### Configuration\\n\\n📅 **Schedule**: \\"before 5am\\" (UTC).\\n\\n🚦 **Automerge**: Enabled.\\n\\n♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.\\n\\n🔕 **Ignore**: Close this PR and you won't be reminded about this update again.\\n\\n---\\n\\n - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, click this checkbox.\\n\\n---\\n\\nThis PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).", - "prTitle": "Update dependency dummy to v1.1.0", - "sourceBranch": "renovate/dummy-1.x", - "targetBranch": undefined, - }, -] -`; - -exports[`workers/repository/update/pr/index ensurePr should create PR if success for gitlab deps 1`] = ` -Array [ - Object { - "draftPR": false, - "labels": Array [], - "platformOptions": Object { - "azureAutoApprove": false, - "azureWorkItemId": 0, - "bbUseDefaultReviewers": true, - "gitLabIgnoreApprovals": false, - "usePlatformAutomerge": false, - }, - "prBody": "This PR contains the following updates:\\n\\n| Package | Type | Update | Change |\\n|---|---|---|---|\\n| [gitlabdummy](https://dummy.com) ([source](https://gitlab.com/renovateapp/gitlabdummy), [changelog](https://gitlab.com/renovateapp/gitlabdummy/changelog.md)) | devDependencies | minor | \`1.0.0\` -> \`1.1.0\` |\\n\\n---\\n\\n### Configuration\\n\\n📅 **Schedule**: \\"before 5am\\" (UTC).\\n\\n🚦 **Automerge**: Enabled.\\n\\n♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.\\n\\n🔕 **Ignore**: Close this PR and you won't be reminded about this update again.\\n\\n---\\n\\n - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, click this checkbox.\\n\\n---\\n\\nThis PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).", - "prTitle": "Update dependency dummy to v1.1.0", - "sourceBranch": "renovate/gitlabdummy-1.x", - "targetBranch": undefined, - }, -] -`; - -exports[`workers/repository/update/pr/index ensurePr should create group PR 1`] = ` -Array [ - Object { - "draftPR": false, - "labels": Array [], - "platformOptions": Object { - "azureAutoApprove": false, - "azureWorkItemId": 0, - "bbUseDefaultReviewers": true, - "gitLabIgnoreApprovals": false, - "usePlatformAutomerge": false, - }, - "prBody": "This PR contains the following updates:\\n\\n| Package | Type | Update | Change |\\n|---|---|---|---|\\n| [dummy](https://dummy.com) ([source](https://github.com/renovateapp/dummy), [changelog](https://github.com/renovateapp/dummy/changelog.md)) | devDependencies | lockFileMaintenance | \`1.0.0\` -> \`1.1.0\` |\\n| a | | | \`zzzzzz\` -> \`aaaaaaa\` |\\n| b | | pin | \`some_old_value\` -> \`some_new_value\` |\\n| c | | | |\\n| d | | lockFileMaintenance | All locks refreshed |\\n| e | | lockFileMaintenance | All locks refreshed |\\n| f | | lockFileMaintenance | All locks refreshed |\\n| g | | lockFileMaintenance | All locks refreshed |\\n| h | | lockFileMaintenance | All locks refreshed |\\n\\nnote 1\\n\\nnote 2\\n\\n:warning: Release Notes retrieval for this PR were skipped because no github.com credentials were available.\\nIf you are self-hosted, please see [this instruction](https://github.com/renovatebot/renovate/blob/master/docs/usage/examples/self-hosting.md#githubcom-token-for-release-notes).\\n\\n🔡 If you wish to disable git hash updates, add \`\\":disableDigestUpdates\\"\` to the extends array in your config.\\n\\n🔧 This Pull Request updates lock files to use the latest dependency versions.\\n\\n---\\n\\n### Release Notes\\n\\n<details>\\n<summary>renovateapp/dummy (dummy)</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n<details>\\n<summary>renovateapp/dummy (b)</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n<details>\\n<summary>renovateapp/dummymonorepo</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n<details>\\n<summary>renovateapp/anotherdummymonorepo (g)</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n<details>\\n<summary>renovateapp/anotherdummymonorepo (h)</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n---\\n\\n### Configuration\\n\\n📅 **Schedule**: At any time (no schedule defined).\\n\\n🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.\\n\\n♻ **Rebasing**: Never, or you tick the rebase/retry checkbox.\\n\\n👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.\\n\\n---\\n\\n - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, click this checkbox.\\n\\n---\\n\\nThis PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).", - "prTitle": "Update dependency dummy to v1.1.0", - "sourceBranch": "renovate/dummy-1.x", - "targetBranch": undefined, - }, -] -`; - -exports[`workers/repository/update/pr/index ensurePr should create privateRepo PR if success 1`] = ` -Array [ - Object { - "draftPR": false, - "labels": Array [], - "platformOptions": Object { - "azureAutoApprove": false, - "azureWorkItemId": 0, - "bbUseDefaultReviewers": true, - "gitLabIgnoreApprovals": false, - "usePlatformAutomerge": false, - }, - "prBody": "This PR contains the following updates:\\n\\n| Package | Type | Update | Change |\\n|---|---|---|---|\\n| [dummy](https://dummy.com) ([source](https://github.com/renovateapp/dummy), [changelog](https://github.com/renovateapp/dummy/changelog.md)) | devDependencies | minor | \`1.0.0\` -> \`1.1.0\` |\\n\\n---\\n\\n### Release Notes\\n\\n<details>\\n<summary>someproject</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n---\\n\\n### Configuration\\n\\n📅 **Schedule**: At any time (no schedule defined).\\n\\n🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.\\n\\n♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.\\n\\n🔕 **Ignore**: Close this PR and you won't be reminded about this update again.\\n\\n---\\n\\n - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, click this checkbox.\\n\\n---\\n\\nThis PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).", - "prTitle": "Update dependency dummy to v1.1.0", - "sourceBranch": "renovate/dummy-1.x", - "targetBranch": undefined, - }, -] -`; - -exports[`workers/repository/update/pr/index ensurePr should determine assignees from code owners 1`] = ` -Array [ - Array [ - undefined, - Array [ - "john", - "maria", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should determine reviewers from code owners 1`] = ` -Array [ - Array [ - undefined, - Array [ - "john", - "maria", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should filter assignees and reviewers based on their availability 1`] = ` -Array [ - Array [ - undefined, - Array [ - "foo", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should filter assignees and reviewers based on their availability 2`] = ` -Array [ - Array [ - undefined, - Array [ - "foo", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should filter assignees and reviewers based on their availability 3`] = ` -Array [ - Array [ - Array [ - "foo", - "bar", - ], - ], - Array [ - Array [ - "foo", - "bar", - "foo@bar.com", - ], - ], -] -`; - -exports[`workers/repository/update/pr/index ensurePr should return modified existing PR 1`] = ` -Object { - "body": "This PR contains the following updates:\\n\\n| Package | Type | Update | Change |\\n|---|---|---|---|\\n| [dummy](https://dummy.com) ([source](https://github.com/renovateapp/dummy), [changelog](https://github.com/renovateapp/dummy/changelog.md)) | devDependencies | minor | \`1.0.0\` -> \`1.1.0\` |\\n\\n---\\n\\n### Release Notes\\n\\n<details>\\n<summary>renovateapp/dummy</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n---\\n\\n### Configuration\\n\\n📅 **Schedule**: \\"before 5am\\" (UTC).\\n\\n🚦 **Automerge**: Enabled.\\n\\n♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.\\n\\n🔕 **Ignore**: Close this PR and you won't be reminded about this update again.\\n\\n---\\n\\n - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, click this checkbox.\\n\\n---\\n\\nThis PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).", - "displayNumber": "Existing PR", - "title": "Update dependency dummy to v1.1.0", -} -`; - -exports[`workers/repository/update/pr/index ensurePr should return modified existing PR title 1`] = ` -Object { - "body": "This PR contains the following updates:\\n\\n| Package | Type | Update | Change |\\n|---|---|---|---|\\n| [dummy](https://dummy.com) ([source](https://github.com/renovateapp/dummy), [changelog](https://github.com/renovateapp/dummy/changelog.md)) | devDependencies | minor | \`1.0.0\` -> \`1.1.0\` |\\n\\n---\\n\\n### Release Notes\\n\\n<details>\\n<summary>renovateapp/dummy</summary>\\n\\n### [\`v1.1.0\`](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n[Compare Source](https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0)\\n\\n</details>\\n\\n---\\n\\n### Configuration\\n\\n📅 **Schedule**: \\"before 5am\\" (UTC).\\n\\n🚦 **Automerge**: Enabled.\\n\\n♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.\\n\\n🔕 **Ignore**: Close this PR and you won't be reminded about this update again.\\n\\n---\\n\\n - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, click this checkbox.\\n\\n---\\n\\nThis PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).", - "displayNumber": "Existing PR", - "title": "wrong", -} -`; - -exports[`workers/repository/update/pr/index ensurePr should return unmodified existing PR 1`] = `Array []`; diff --git a/lib/workers/repository/update/pr/body/changelogs.spec.ts b/lib/workers/repository/update/pr/body/changelogs.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..cec36e1f7d26d3373c2802027a2f4a6ed5b71493 --- /dev/null +++ b/lib/workers/repository/update/pr/body/changelogs.spec.ts @@ -0,0 +1,78 @@ +import { mocked } from '../../../../../../test/util'; +import * as _template from '../../../../../util/template'; +import { getChangelogs } from './changelogs'; + +jest.mock('../../../../../util/template'); +const template = mocked(_template); + +describe('workers/repository/update/pr/body/changelogs', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns empty string when there is no release notes', () => { + const res = getChangelogs({ + branchName: 'some-branch', + upgrades: [], + hasReleaseNotes: false, + }); + + expect(res).toBe(''); + expect(template.compile).not.toHaveBeenCalled(); + }); + + it('returns release notes', () => { + template.compile.mockImplementationOnce((_, config): string => { + const upgrades: { releaseNotesSummaryTitle: string }[] = + config.upgrades as never; + return upgrades + .map((upgrade) => upgrade.releaseNotesSummaryTitle) + .join('\n') + .trim(); + }); + + const res = getChangelogs({ + branchName: 'some-branch', + upgrades: [ + { + depName: 'dep-1', + repoName: 'some/repo', + branchName: 'some-branch', + hasReleaseNotes: true, + }, + { + depName: 'dep-2', + repoName: 'some/repo', + branchName: 'some-branch', + hasReleaseNotes: true, + }, + { + depName: 'dep-3', + repoName: 'some/repo', + branchName: 'some-branch', + hasReleaseNotes: true, + }, + { + depName: 'dep-4', + repoName: 'other/repo', + branchName: 'some-branch', + hasReleaseNotes: true, + }, + ], + hasReleaseNotes: true, + }); + + expect(res).toMatchInlineSnapshot(` + " + + --- + + some/repo (dep-1) + some/repo (dep-2) + some/repo (dep-3) + other/repo + + " + `); + }); +}); diff --git a/lib/workers/repository/update/pr/body/changelogs.ts b/lib/workers/repository/update/pr/body/changelogs.ts index ceb2e079e91c200477968871263c1c34eac0cd2e..779a7c0009714a8e3e2e754afc1675c845374926 100644 --- a/lib/workers/repository/update/pr/body/changelogs.ts +++ b/lib/workers/repository/update/pr/body/changelogs.ts @@ -7,7 +7,6 @@ import releaseNotesHbs from '../changelog/hbs-template'; export function getChangelogs(config: BranchConfig): string { let releaseNotes = ''; - // istanbul ignore if if (!config.hasReleaseNotes) { return releaseNotes; } @@ -15,14 +14,14 @@ export function getChangelogs(config: BranchConfig): string { const countReleaseNodesByRepoName: Record<string, number> = {}; for (const upgrade of config.upgrades) { - if (upgrade.hasReleaseNotes) { + if (upgrade.hasReleaseNotes && upgrade.repoName) { countReleaseNodesByRepoName[upgrade.repoName] = (countReleaseNodesByRepoName[upgrade.repoName] || 0) + 1; } } for (const upgrade of config.upgrades) { - if (upgrade.hasReleaseNotes) { + if (upgrade.hasReleaseNotes && upgrade.repoName) { upgrade.releaseNotesSummaryTitle = `${upgrade.repoName}${ countReleaseNodesByRepoName[upgrade.repoName] > 1 ? ` (${upgrade.depName})` diff --git a/lib/workers/repository/update/pr/body/config-description.spec.ts b/lib/workers/repository/update/pr/body/config-description.spec.ts index 2ef83372618ec6e2c3519d07de9524ee162f188f..2d77a24a0a7af892927ed2503959ded187cb7967 100644 --- a/lib/workers/repository/update/pr/body/config-description.spec.ts +++ b/lib/workers/repository/update/pr/body/config-description.spec.ts @@ -1,24 +1,102 @@ -import { mock } from 'jest-mock-extended'; +import { mocked } from '../../../../../../test/util'; +import { BranchStatus } from '../../../../../types'; import type { BranchConfig } from '../../../../types'; +import * as _checks from '../../branch/status-checks'; import { getPrConfigDescription } from './config-description'; -jest.mock('../../../../../util/git'); +jest.mock('../../branch/status-checks'); +const checks = mocked(_checks); describe('workers/repository/update/pr/body/config-description', () => { describe('getPrConfigDescription', () => { - let branchConfig: BranchConfig; + const config: BranchConfig = { + branchName: 'some-branch', + upgrades: [], + }; beforeEach(() => { jest.resetAllMocks(); - branchConfig = mock<BranchConfig>(); - branchConfig.branchName = 'branchName'; }); - it('handles stopUpdatingLabel correctly', async () => { - branchConfig.stopUpdating = true; - expect(await getPrConfigDescription(branchConfig)).toContain( + it('renders stopUpdating=true', async () => { + const res = await getPrConfigDescription({ + ...config, + stopUpdating: true, + }); + + expect(res).toContain( + `**Rebasing**: Never, or you tick the rebase/retry checkbox.` + ); + }); + + it('renders rebaseWhen="never"', async () => { + const res = await getPrConfigDescription({ + ...config, + rebaseWhen: 'never', + }); + + expect(res).toContain( `**Rebasing**: Never, or you tick the rebase/retry checkbox.` ); }); + + it('renders rebaseWhen="behind-base-branch"', async () => { + const res = await getPrConfigDescription({ + ...config, + rebaseWhen: 'behind-base-branch', + }); + + expect(res).toContain(`Whenever PR is behind base branch`); + }); + + it('renders timezone', async () => { + const res = await getPrConfigDescription({ + ...config, + schedule: ['* 1 * * * *'], + timezone: 'Europe/Istanbul', + }); + expect(res).toContain(`in timezone Europe/Istanbul`); + }); + + it('renders UTC as the default timezone', async () => { + const res = await getPrConfigDescription({ + ...config, + schedule: ['* 1 * * * *'], + }); + expect(res).toContain(`**Schedule**: "* 1 * * * *" (UTC).`); + }); + + it('renders undefined schedule', async () => { + const res = await getPrConfigDescription(config); + expect(res).toContain(`At any time (no schedule defined).`); + }); + + it('renders recreateClosed', async () => { + const res = await getPrConfigDescription({ + ...config, + recreateClosed: true, + }); + expect(res).toContain(`**Immortal**`); + }); + + it('renders singular', async () => { + const res = await getPrConfigDescription({ + ...config, + upgrades: [config], + }); + expect(res).toContain(`this update`); + }); + + it('renders failed automerge', async () => { + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.red); + const res = await getPrConfigDescription({ ...config, automerge: true }); + expect(res).toContain(`Disabled due to failing status checks`); + }); + + it('renders automerge', async () => { + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.green); + const res = await getPrConfigDescription({ ...config, automerge: true }); + expect(res).toContain(`**Automerge**: Enabled.`); + }); }); }); diff --git a/lib/workers/repository/update/pr/body/config-description.ts b/lib/workers/repository/update/pr/body/config-description.ts index 3a2e8bb096a12351b471ea9ee28a15849d8e287d..952e30778de74bad00ab750b951fb7cb6739a5df 100644 --- a/lib/workers/repository/update/pr/body/config-description.ts +++ b/lib/workers/repository/update/pr/body/config-description.ts @@ -30,7 +30,6 @@ export async function getPrConfigDescription( config.branchName, config.ignoreTests ); - // istanbul ignore if if (branchStatus === BranchStatus.red) { prBody += 'Disabled due to failing status checks.'; } else { @@ -52,7 +51,7 @@ export async function getPrConfigDescription( prBody += `, or you tick the rebase/retry checkbox.\n\n`; if (config.recreateClosed) { prBody += emojify( - `:ghost: **Immortal**: This PR will be recreated if closed unmerged. Get [config help](${config.productLinks.help}) if that's undesired.\n\n` + `:ghost: **Immortal**: This PR will be recreated if closed unmerged. Get [config help](${config.productLinks?.help}) if that's undesired.\n\n` ); } else { prBody += emojify( diff --git a/lib/workers/repository/update/pr/body/footer.spec.ts b/lib/workers/repository/update/pr/body/footer.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9464491e1a73e8fa73c753a112621b2db6f0dc8 --- /dev/null +++ b/lib/workers/repository/update/pr/body/footer.spec.ts @@ -0,0 +1,28 @@ +import { mocked } from '../../../../../../test/util'; +import * as _template from '../../../../../util/template'; +import { getPrFooter } from './footer'; + +jest.mock('../../../../../util/template'); +const template = mocked(_template); + +describe('workers/repository/update/pr/body/footer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders empty footer', () => { + expect(getPrFooter({ branchName: 'branch', upgrades: [] })).toBe(''); + }); + + it('renders prFooter', () => { + template.compile.mockImplementation((x) => x); + expect( + getPrFooter({ branchName: 'branch', upgrades: [], prFooter: 'FOOTER' }) + ).toMatchInlineSnapshot(` + " + --- + + FOOTER" + `); + }); +}); diff --git a/lib/workers/repository/update/pr/body/footer.ts b/lib/workers/repository/update/pr/body/footer.ts index d2d250b0f161b6a4c9e9c31b63fb987034449896..5ba69e72d1f74ed1a062455b72848c2b3be6a987 100644 --- a/lib/workers/repository/update/pr/body/footer.ts +++ b/lib/workers/repository/update/pr/body/footer.ts @@ -1,7 +1,6 @@ import * as template from '../../../../../util/template'; import type { BranchConfig } from '../../../../types'; -// istanbul ignore next export function getPrFooter(config: BranchConfig): string { if (config.prFooter) { return '\n---\n\n' + template.compile(config.prFooter, config); diff --git a/lib/workers/repository/update/pr/body/header.spec.ts b/lib/workers/repository/update/pr/body/header.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbdcc2f5690c39ac4bbe53d313389ef2f3ff4c6d --- /dev/null +++ b/lib/workers/repository/update/pr/body/header.spec.ts @@ -0,0 +1,27 @@ +import { mocked } from '../../../../../../test/util'; +import * as _template from '../../../../../util/template'; +import { getPrHeader } from './header'; + +jest.mock('../../../../../util/template'); +const template = mocked(_template); + +describe('workers/repository/update/pr/body/header', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders empty header', () => { + expect(getPrHeader({ branchName: 'branch', upgrades: [] })).toBe(''); + }); + + it('renders prHeader', () => { + template.compile.mockImplementation((x) => x); + expect( + getPrHeader({ branchName: 'branch', upgrades: [], prHeader: 'HEADER' }) + ).toMatchInlineSnapshot(` + "HEADER + + " + `); + }); +}); diff --git a/lib/workers/repository/update/pr/body/header.ts b/lib/workers/repository/update/pr/body/header.ts index 9afc5b55c957ff1925e4cd2bcf4378c95c4d3dcc..99307cc66e6245d3bd91d966e6138e1b6afede25 100644 --- a/lib/workers/repository/update/pr/body/header.ts +++ b/lib/workers/repository/update/pr/body/header.ts @@ -1,7 +1,6 @@ import * as template from '../../../../../util/template'; import type { BranchConfig } from '../../../../types'; -// istanbul ignore next export function getPrHeader(config: BranchConfig): string { if (!config.prHeader) { return ''; diff --git a/lib/workers/repository/update/pr/body/index.spec.ts b/lib/workers/repository/update/pr/body/index.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c038132dd83bd88c511715ce6afb3ecfe484c45 --- /dev/null +++ b/lib/workers/repository/update/pr/body/index.spec.ts @@ -0,0 +1,111 @@ +import { mocked, platform } from '../../../../../../test/util'; +import * as _template from '../../../../../util/template'; +import * as _changelogs from './changelogs'; +import * as _configDescription from './config-description'; +import * as _controls from './controls'; +import * as _footer from './footer'; +import * as _header from './header'; +import * as _notes from './notes'; +import * as _table from './updates-table'; +import { getPrBody } from '.'; + +jest.mock('./changelogs'); +const changelogs = mocked(_changelogs); + +jest.mock('./config-description'); +const configDescription = mocked(_configDescription); + +jest.mock('./controls'); +const controls = mocked(_controls); + +jest.mock('./footer'); +const footer = mocked(_footer); + +jest.mock('./header'); +const header = mocked(_header); + +jest.mock('./notes'); +const notes = mocked(_notes); + +jest.mock('./updates-table'); +const table = mocked(_table); + +jest.mock('../../../../../util/template'); +const template = mocked(_template); + +describe('workers/repository/update/pr/body/index', () => { + describe('getPrBody', () => { + beforeEach(() => { + changelogs.getChangelogs.mockReturnValueOnce('getChangelogs'); + configDescription.getPrConfigDescription.mockResolvedValueOnce( + 'getPrConfigDescription' + ); + controls.getControls.mockResolvedValueOnce('getControls'); + footer.getPrFooter.mockReturnValueOnce('getPrFooter'); + header.getPrHeader.mockReturnValueOnce('getPrHeader'); + notes.getPrExtraNotes.mockReturnValueOnce('getPrExtraNotes'); + notes.getPrNotes.mockReturnValueOnce('getPrNotes'); + table.getPrUpdatesTable.mockReturnValueOnce('getPrUpdatesTable'); + }); + + it('handles empty template', async () => { + const res = await getPrBody({ branchName: 'some-branch', upgrades: [] }); + expect(res).toBeEmptyString(); + }); + + it('massages upgrades', async () => { + const upgrade = { + branchName: 'some-branch', + dependencyUrl: 'https://github.com/foo/bar', + sourceUrl: 'https://github.com/foo/bar.git', + sourceDirectory: '/baz', + changelogUrl: + 'https://raw.githubusercontent.com/foo/bar/tree/main/CHANGELOG.md', + homepage: 'https://example.com', + }; + + await getPrBody({ branchName: 'some-branch', upgrades: [upgrade] }); + + expect(upgrade).toMatchObject({ + branchName: 'some-branch', + changelogUrl: + 'https://raw.githubusercontent.com/foo/bar/tree/main/CHANGELOG.md', + depNameLinked: + '[undefined](https://example.com) ([source](https://github.com/foo/bar.git), [changelog](https://raw.githubusercontent.com/foo/bar/tree/main/CHANGELOG.md))', + dependencyUrl: 'https://github.com/foo/bar', + homepage: 'https://example.com', + references: + '[homepage](https://example.com), [source](https://github.com/foo/bar.git/tree/HEAD//baz), [changelog](https://raw.githubusercontent.com/foo/bar/tree/main/CHANGELOG.md)', + sourceDirectory: '/baz', + sourceUrl: 'https://github.com/foo/bar.git', + }); + }); + + it('uses dependencyUrl as primary link', async () => { + const upgrade = { + branchName: 'some-branch', + dependencyUrl: 'https://github.com/foo/bar', + }; + + await getPrBody({ branchName: 'some-branch', upgrades: [upgrade] }); + + expect(upgrade).toMatchObject({ + branchName: 'some-branch', + depNameLinked: '[undefined](https://github.com/foo/bar)', + dependencyUrl: 'https://github.com/foo/bar', + references: '', + }); + }); + + it('compiles template', async () => { + platform.massageMarkdown.mockImplementation((x) => x); + template.compile.mockImplementation((x) => x); + const res = await getPrBody({ + branchName: 'some-branch', + upgrades: [], + prBodyTemplate: 'PR BODY', + }); + expect(res).toBe('PR BODY'); + }); + }); +}); diff --git a/lib/workers/repository/update/pr/body/index.ts b/lib/workers/repository/update/pr/body/index.ts index 1b3de2321f632d4a6875deffce1313180a33f7f1..f0484e3a465e59a1a0026983a08b5cf8b8bf33e2 100644 --- a/lib/workers/repository/update/pr/body/index.ts +++ b/lib/workers/repository/update/pr/body/index.ts @@ -68,10 +68,14 @@ export async function getPrBody(config: BranchConfig): Promise<string> { controls: await getControls(config), footer: getPrFooter(config), }; - const prBodyTemplate = config.prBodyTemplate; - let prBody = template.compile(prBodyTemplate, content, false); - prBody = prBody.trim(); - prBody = prBody.replace(regEx(/\n\n\n+/g), '\n\n'); - prBody = platform.massageMarkdown(prBody); + + let prBody = ''; + if (config.prBodyTemplate) { + const prBodyTemplate = config.prBodyTemplate; + prBody = template.compile(prBodyTemplate, content, false); + prBody = prBody.trim(); + prBody = prBody.replace(regEx(/\n\n\n+/g), '\n\n'); + prBody = platform.massageMarkdown(prBody); + } return prBody; } diff --git a/lib/workers/repository/update/pr/body/notes.spec.ts b/lib/workers/repository/update/pr/body/notes.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d74852cc40e0fa5833930986c55bd9ce4bde405 --- /dev/null +++ b/lib/workers/repository/update/pr/body/notes.spec.ts @@ -0,0 +1,50 @@ +import { mocked } from '../../../../../../test/util'; +import * as _template from '../../../../../util/template'; +import { getPrExtraNotes, getPrNotes } from './notes'; + +jest.mock('../../../../../util/template'); +const template = mocked(_template); + +describe('workers/repository/update/pr/body/notes', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders notes', () => { + template.compile.mockImplementation((x) => x); + const res = getPrNotes({ + branchName: 'branch', + upgrades: [{ branchName: 'branch', prBodyNotes: ['NOTE'] }], + }); + expect(res).toContain('NOTE'); + }); + + it('handles render error', () => { + template.compile.mockImplementationOnce(() => { + throw new Error('unknown'); + }); + const res = getPrNotes({ + branchName: 'branch', + upgrades: [{ branchName: 'branch', prBodyNotes: ['NOTE'] }], + }); + expect(res).not.toContain('NOTE'); + }); + + it('handles extra notes', () => { + const res = getPrExtraNotes({ + branchName: 'branch', + upgrades: [{ branchName: 'branch', gitRef: true }], + updateType: 'lockFileMaintenance', + isPin: true, + }); + expect(res).toContain( + 'If you wish to disable git hash updates, add `":disableDigestUpdates"` to the extends array in your config.' + ); + expect(res).toContain( + 'This Pull Request updates lock files to use the latest dependency versions.' + ); + expect(res).toContain( + "Add the preset `:preserveSemverRanges` to your config if you don't want to pin your dependencies." + ); + }); +}); diff --git a/lib/workers/repository/update/pr/index.spec.ts b/lib/workers/repository/update/pr/index.spec.ts index d6dbf7d49f90648d118113dbabbccf11d655a593..5b7d10fd6b50629a809d2f302454f192c1427773 100644 --- a/lib/workers/repository/update/pr/index.spec.ts +++ b/lib/workers/repository/update/pr/index.spec.ts @@ -1,793 +1,691 @@ +import { DateTime } from 'luxon'; +import { git, logger, mocked, platform } from '../../../../../test/util'; +import { GlobalConfig } from '../../../../config/global'; import { - getConfig, - git, - mocked, - partial, - platform, -} from '../../../../../test/util'; -import { PlatformId } from '../../../../constants'; -import type { Pr } from '../../../../modules/platform'; -import { BranchStatus } from '../../../../types'; + PLATFORM_INTEGRATION_UNAUTHORIZED, + PLATFORM_RATE_LIMIT_EXCEEDED, + REPOSITORY_CHANGED, +} from '../../../../constants/error-messages'; +import * as _comment from '../../../../modules/platform/comment'; +import type { Pr } from '../../../../modules/platform/types'; +import { BranchStatus, PrState } from '../../../../types'; +import { ExternalHostError } from '../../../../types/errors/external-host-error'; import * as _limits from '../../../global/limits'; -import type { BranchConfig } from '../../../types'; -import * as _changelogHelper from './changelog'; -import type { ChangeLogResult } from './changelog'; -import * as codeOwners from './code-owners'; -import * as prWorker from '.'; -import type { EnsurePrResult, ResultWithPr, ResultWithoutPr } from '.'; - -const codeOwnersMock = mocked(codeOwners); -const changelogHelper = mocked(_changelogHelper); -const gitlabChangelogHelper = mocked(_changelogHelper); -const limits = mocked(_limits); +import type { BranchConfig, BranchUpgradeConfig } from '../../../types'; +import * as _statusChecks from '../branch/status-checks'; +import * as _prBody from './body'; +import { + ChangeLogChange, + ChangeLogError, + ChangeLogRelease, +} from './changelog/types'; +import * as _participants from './participants'; +import { ensurePr } from '.'; jest.mock('../../../../util/git'); -jest.mock('./changelog'); -jest.mock('./code-owners'); + jest.mock('../../../global/limits'); +const limits = mocked(_limits); -function setupChangelogMock() { - const resultValue = { - project: { - type: 'github', - baseUrl: 'https://github.com/', - repository: 'renovateapp/dummy', - sourceUrl: 'https://github.com/renovateapp/dummy', - }, - hasReleaseNotes: true, - versions: [ - { - date: new Date('2017-01-01'), - version: '1.1.0', - changes: [ - { - date: new Date('2017-01-01'), - sha: 'abcdefghijklmnopqrstuvwxyz', - message: 'foo #3\nbar', - }, - ], - releaseNotes: { - url: 'https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0', - }, - compare: { - url: 'https://github.com/renovateapp/dummy/compare/v1.0.0...v1.1.0', - }, - }, - ], - } as ChangeLogResult; - const errorValue = { - error: _changelogHelper.ChangeLogError.MissingGithubToken, - }; - changelogHelper.getChangeLogJSON.mockResolvedValueOnce(resultValue); - changelogHelper.getChangeLogJSON.mockResolvedValueOnce(errorValue); - changelogHelper.getChangeLogJSON.mockResolvedValue(resultValue); -} - -function setupGitlabChangelogMock() { - const resultValue = { - project: { - type: 'gitlab', - baseUrl: 'https://gitlab.com/', - repository: 'renovateapp/gitlabdummy', - sourceUrl: 'https://gitlab.com/renovateapp/gitlabdummy', - }, - hasReleaseNotes: true, - versions: [ - { - date: new Date('2017-01-01'), - version: '1.1.0', - changes: [ - { - date: new Date('2017-01-01'), - sha: 'abcdefghijklmnopqrstuvwxyz', - message: 'foo #3\nbar', - }, - ], - releaseNotes: { - url: 'https://gitlab.com/renovateapp/gitlabdummy/compare/v1.0.0...v1.1.0', - }, - compare: { - url: 'https://gitlab.com/renovateapp/gitlabdummy/compare/v1.0.0...v1.1.0', - }, - }, - ], - } as ChangeLogResult; - const errorValue = { - error: _changelogHelper.ChangeLogError.MissingGithubToken, - }; - gitlabChangelogHelper.getChangeLogJSON.mockResolvedValueOnce(resultValue); - gitlabChangelogHelper.getChangeLogJSON.mockResolvedValueOnce(errorValue); - gitlabChangelogHelper.getChangeLogJSON.mockResolvedValue(resultValue); -} - -function isResultWithPr(value: EnsurePrResult): asserts value is ResultWithPr { - if (value.type !== 'with-pr') { - throw new TypeError(); - } -} - -function isResultWithoutPr( - value: EnsurePrResult -): asserts value is ResultWithoutPr { - if (value.type !== 'without-pr') { - throw new TypeError(); - } -} +jest.mock('../branch/status-checks'); +const checks = mocked(_statusChecks); + +jest.mock('./body'); +const prBody = mocked(_prBody); + +jest.mock('./participants'); +const participants = mocked(_participants); + +jest.mock('../../../../modules/platform/comment'); +const comment = mocked(_comment); describe('workers/repository/update/pr/index', () => { describe('ensurePr', () => { - let config: BranchConfig; - // TODO fix type - const existingPr: Pr = { - displayNumber: 'Existing PR', - title: 'Update dependency dummy to v1.1.0', - body: 'Some body<!-- Reviewable:start -->something<!-- Reviewable:end -->\n\n', - } as never; + const number = 123; + const sourceBranch = 'renovate-branch'; + const prTitle = 'Some title'; + const body = 'Some body'; + + const pr: Pr = { + number, + sourceBranch, + title: prTitle, + body, + state: PrState.Open, + }; + + const config: BranchConfig = { + branchName: sourceBranch, + upgrades: [], + prTitle, + }; beforeEach(() => { jest.resetAllMocks(); - setupChangelogMock(); - config = partial<BranchConfig>({ - ...getConfig(), - }); - config.branchName = 'renovate/dummy-1.x'; - config.prTitle = 'Update dependency dummy to v1.1.0'; - config.depType = 'devDependencies'; - config.depName = 'dummy'; - config.privateRepo = true; - config.displayFrom = '1.0.0'; - config.displayTo = '1.1.0'; - config.updateType = 'minor'; - config.homepage = 'https://dummy.com'; - config.sourceUrl = 'https://github.com/renovateapp/dummy'; - config.sourceDirectory = 'packages/a'; - config.changelogUrl = 'https://github.com/renovateapp/dummy/changelog.md'; - // TODO fix type - platform.createPr.mockResolvedValue({ - displayNumber: 'New Pull Request', - } as never); - config.upgrades = [config]; - platform.massageMarkdown.mockImplementation((input) => input); + GlobalConfig.reset(); + prBody.getPrBody.mockResolvedValue(body); }); - afterEach(() => { - jest.clearAllMocks(); - }); + describe('Create', () => { + it('creates PR', async () => { + platform.createPr.mockResolvedValueOnce(pr); + + const res = await ensurePr(config); - it('should return PR if update fails', async () => { - platform.updatePr.mockImplementationOnce(() => { - throw new Error('oops'); + expect(res).toEqual({ type: 'with-pr', pr }); + expect(limits.incLimitedValue).toHaveBeenCalledOnce(); + expect(limits.incLimitedValue).toHaveBeenCalledWith( + limits.Limit.PullRequests + ); + expect(logger.logger.info).toHaveBeenCalledWith( + { pr: pr.number, prTitle }, + 'PR created' + ); }); - config.newValue = '1.2.0'; - platform.getBranchPr.mockResolvedValueOnce(existingPr); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toBeDefined(); - }); - it('should return null if waiting for success', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.red); - config.prCreation = 'status-success'; - const result = await prWorker.ensurePr(config); - isResultWithoutPr(result); - expect(result.prBlockedBy).toBe('AwaitingTests'); - }); + it('aborts PR creation once limit is exceeded', async () => { + platform.createPr.mockResolvedValueOnce(pr); + limits.isLimitReached.mockReturnValueOnce(true); - it('should return needs-approval if prCreation set to approval', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - config.prCreation = 'approval'; - const result = await prWorker.ensurePr(config); - isResultWithoutPr(result); - expect(result.prBlockedBy).toBe('NeedsApproval'); - }); + const res = await ensurePr(config); - it('should create PR if success for gitlab deps', async () => { - setupGitlabChangelogMock(); - config.branchName = 'renovate/gitlabdummy-1.x'; - config.depName = 'gitlabdummy'; - config.sourceUrl = 'https://gitlab.com/renovateapp/gitlabdummy'; - config.changelogUrl = - 'https://gitlab.com/renovateapp/gitlabdummy/changelog.md'; - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - config.prCreation = 'status-success'; - config.automerge = true; - config.schedule = ['before 5am']; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.createPr.mock.calls[0]).toMatchSnapshot([ - { - prTitle: 'Update dependency dummy to v1.1.0', - sourceBranch: 'renovate/gitlabdummy-1.x', - }, - ]); - existingPr.body = platform.createPr.mock.calls[0][0].prBody; - config.branchName = 'renovate/dummy-1.x'; - config.depName = 'dummy'; - config.sourceUrl = 'https://github.com/renovateapp/dummy'; - config.changelogUrl = 'https://github.com/renovateapp/dummy/changelog.md'; - }); + expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'RateLimited' }); + expect(platform.createPr).not.toHaveBeenCalled(); + }); - it('should create PR if success', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - config.logJSON = await changelogHelper.getChangeLogJSON(config); - config.prCreation = 'status-success'; - config.automerge = true; - config.schedule = ['before 5am']; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.createPr.mock.calls[0]).toMatchSnapshot([ - { - prTitle: 'Update dependency dummy to v1.1.0', - sourceBranch: 'renovate/dummy-1.x', - }, - ]); - existingPr.body = platform.createPr.mock.calls[0][0].prBody; - }); + it('ignores PR limits on vulnerability alert', async () => { + platform.createPr.mockResolvedValueOnce(pr); + limits.isLimitReached.mockReturnValueOnce(true); - it('should not create PR if limit is reached', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - config.logJSON = await changelogHelper.getChangeLogJSON(config); - config.prCreation = 'status-success'; - config.automerge = true; - config.schedule = ['before 5am']; - limits.isLimitReached.mockReturnValueOnce(true); - const result = await prWorker.ensurePr(config); - isResultWithoutPr(result); - expect(result.prBlockedBy).toBe('RateLimited'); - expect(platform.createPr.mock.calls).toBeEmpty(); - }); + const res = await ensurePr({ ...config, isVulnerabilityAlert: true }); - it('should create PR if limit is reached but dashboard checked', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - config.logJSON = await changelogHelper.getChangeLogJSON(config); - config.prCreation = 'status-success'; - config.automerge = true; - config.schedule = ['before 5am']; - limits.isLimitReached.mockReturnValueOnce(true); - await prWorker.ensurePr({ - ...config, - dependencyDashboardChecks: { 'renovate/dummy-1.x': 'true' }, + expect(res).toEqual({ type: 'with-pr', pr }); + expect(platform.createPr).toHaveBeenCalled(); }); - expect(platform.createPr).toHaveBeenCalled(); - }); - it('should create group PR', async () => { - const depsWithSameNotesSourceUrl = ['e', 'f']; - const depsWithSameSourceUrl = ['g', 'h']; - config.upgrades = config.upgrades.concat([ - { - depName: 'a', - displayFrom: 'zzzzzz', - displayTo: 'aaaaaaa', - prBodyNotes: ['note 1', 'note 2'], - prBodyDefinitions: { - Package: '{{{depNameLinked}}}', - Change: '`{{{displayFrom}}}` -> `{{{displayTo}}}`', - }, - }, - { - depName: 'b', - newDigestShort: 'bbbbbbb', - displayFrom: 'some_old_value', - displayTo: 'some_new_value', - updateType: 'pin', - prBodyDefinitions: { - Package: '{{{depNameLinked}}}', - Change: '`{{{displayFrom}}}` -> `{{{displayTo}}}`', - Update: '{{{updateType}}}', - }, - }, - { - depName: 'c', - gitRef: 'ccccccc', - prBodyDefinitions: { - Package: '{{{depNameLinked}}}', - }, - }, - { - depName: 'd', - updateType: 'lockFileMaintenance', - prBodyNotes: ['{{#if foo}}'], - prBodyDefinitions: { - Package: '{{{depNameLinked}}}', - Update: '{{{updateType}}}', - Change: 'All locks refreshed', - }, - }, - { - depName: depsWithSameNotesSourceUrl[0], - updateType: 'lockFileMaintenance', - prBodyNotes: ['{{#if foo}}'], - prBodyDefinitions: { - Package: '{{{depNameLinked}}}', - Update: '{{{updateType}}}', - Change: 'All locks refreshed', - }, - }, - { - depName: depsWithSameNotesSourceUrl[1], - updateType: 'lockFileMaintenance', - prBodyNotes: ['{{#if foo}}'], - prBodyDefinitions: { - Package: '{{{depNameLinked}}}', - Update: '{{{updateType}}}', - Change: 'All locks refreshed', - }, - }, - { - depName: depsWithSameSourceUrl[0], - updateType: 'lockFileMaintenance', - prBodyNotes: ['{{#if foo}}'], - prBodyDefinitions: { - Package: '{{{depNameLinked}}}', - Update: '{{{updateType}}}', - Change: 'All locks refreshed', - }, - }, - { - depName: depsWithSameSourceUrl[1], - updateType: 'lockFileMaintenance', - prBodyNotes: ['{{#if foo}}'], - prBodyDefinitions: { - Package: '{{{depNameLinked}}}', - Update: '{{{updateType}}}', - Change: 'All locks refreshed', - }, - }, - ] as never); - config.updateType = 'lockFileMaintenance'; - config.recreateClosed = true; - config.rebaseWhen = 'never'; - for (const upgrade of config.upgrades) { - upgrade.logJSON = await changelogHelper.getChangeLogJSON(upgrade); - - if (depsWithSameNotesSourceUrl.includes(upgrade.depName)) { - upgrade.sourceDirectory = `packages/${upgrade.depName}`; - - upgrade.logJSON = { - ...upgrade.logJSON, - project: { - ...upgrade.logJSON.project, - repository: 'renovateapp/dummymonorepo', - }, - versions: upgrade.logJSON.versions.map((V) => { - return { - ...V, - releaseNotes: { - ...V.releaseNotes, - notesSourceUrl: - 'https://github.com/renovateapp/dummymonorepo/blob/changelogfile.md', - }, - }; - }), - }; - } + it('creates rollback PR', async () => { + platform.createPr.mockResolvedValueOnce(pr); - if (depsWithSameSourceUrl.includes(upgrade.depName)) { - upgrade.sourceDirectory = `packages/${upgrade.depName}`; + const res = await ensurePr({ ...config, updateType: 'rollback' }); - upgrade.logJSON = { - ...upgrade.logJSON, - project: { - ...upgrade.logJSON.project, - repository: 'renovateapp/anotherdummymonorepo', - }, - versions: upgrade.logJSON.versions.map((V) => { - return { - ...V, - releaseNotes: { - ...V.releaseNotes, - notesSourceUrl: null, - }, - }; - }), + expect(res).toEqual({ type: 'with-pr', pr }); + expect(logger.logger.info).toHaveBeenCalledWith('Creating Rollback PR'); + }); + + it('skips PR creation due to non-green branch check', async () => { + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); + + const res = await ensurePr({ ...config, prCreation: 'status-success' }); + + expect(res).toEqual({ + type: 'without-pr', + prBlockedBy: 'AwaitingTests', + }); + }); + + it('creates PR for green branch checks', async () => { + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.green); + platform.createPr.mockResolvedValueOnce(pr); + + const res = await ensurePr({ ...config, prCreation: 'status-success' }); + + expect(res).toEqual({ type: 'with-pr', pr }); + expect(platform.createPr).toHaveBeenCalled(); + }); + + it('skips PR creation for unapproved dependencies', async () => { + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); + + const res = await ensurePr({ ...config, prCreation: 'approval' }); + + expect(res).toEqual({ + type: 'without-pr', + prBlockedBy: 'NeedsApproval', + }); + }); + + it('skips PR creation before prNotPendingHours is hit', async () => { + const now = DateTime.now(); + const then = now.minus({ hours: 1 }); + + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); + git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate()); + + const res = await ensurePr({ + ...config, + prCreation: 'not-pending', + prNotPendingHours: 2, + }); + + expect(res).toEqual({ + type: 'without-pr', + prBlockedBy: 'AwaitingTests', + }); + }); + + it('skips PR creation due to stabilityStatus', async () => { + const now = DateTime.now(); + const then = now.minus({ hours: 1 }); + + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); + git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate()); + + const res = await ensurePr({ + ...config, + prCreation: 'not-pending', + stabilityStatus: BranchStatus.green, + }); + + expect(res).toEqual({ + type: 'without-pr', + prBlockedBy: 'AwaitingTests', + }); + }); + + it('creates PR after prNotPendingHours is hit', async () => { + const now = DateTime.now(); + const then = now.minus({ hours: 2 }); + + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); + git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate()); + platform.createPr.mockResolvedValueOnce(pr); + + const res = await ensurePr({ + ...config, + prCreation: 'not-pending', + prNotPendingHours: 1, + }); + + expect(res).toEqual({ type: 'with-pr', pr }); + }); + + describe('Error handling', () => { + it('handles unknown error', async () => { + const err = new Error('unknown'); + platform.createPr.mockRejectedValueOnce(err); + + const res = await ensurePr(config); + + expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' }); + }); + + it('handles error for PR that already exists', async () => { + const err: Error & { body?: unknown } = new Error('unknown'); + err.body = { + message: 'Validation failed', + errors: [{ message: 'A pull request already exists' }], }; - } - } - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.createPr.mock.calls[0]).toMatchSnapshot([ - { - prTitle: 'Update dependency dummy to v1.1.0', - sourceBranch: 'renovate/dummy-1.x', - }, - ]); - }); + platform.createPr.mockRejectedValueOnce(err); - it('should add note about Pin', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - config.prCreation = 'status-success'; - config.isPin = true; - config.updateType = 'pin'; - config.schedule = ['before 5am']; - config.timezone = 'some timezone'; - config.rebaseWhen = 'behind-base-branch'; - config.logJSON = await changelogHelper.getChangeLogJSON(config); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.createPr.mock.calls[0]).toMatchSnapshot([ - { - prTitle: 'Update dependency dummy to v1.1.0', - sourceBranch: 'renovate/dummy-1.x', - }, - ]); - expect(platform.createPr.mock.calls[0][0].prBody).toContain( - "you don't want to pin your dependencies" - ); - }); + const res = await ensurePr(config); + + expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' }); + expect(logger.logger.warn).toHaveBeenCalledWith( + 'A pull requests already exists' + ); + }); + + it('deletes branch on 502 error', async () => { + const err: Error & { statusCode?: number } = new Error('unknown'); + err.statusCode = 502; + platform.createPr.mockRejectedValueOnce(err); + + const res = await ensurePr(config); - it('should return null if creating PR fails', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - platform.createPr.mockImplementationOnce(() => { - throw new Error('Validation Failed (422)'); + expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' }); + expect(git.deleteBranch).toHaveBeenCalledWith('renovate-branch'); + }); }); - config.prCreation = 'status-success'; - const result = await prWorker.ensurePr(config); - isResultWithoutPr(result); - expect(result.prBlockedBy).toBe('Error'); }); - it('should return null if waiting for not pending', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); - git.getBranchLastCommitTime.mockImplementationOnce(() => - Promise.resolve(new Date()) - ); - config.prCreation = 'not-pending'; - const result = await prWorker.ensurePr(config); - isResultWithoutPr(result); - expect(result.prBlockedBy).toBe('AwaitingTests'); - }); + describe('Update', () => { + it('updates PR due to title change', async () => { + const changedPr: Pr = { ...pr, title: 'Another title' }; + platform.getBranchPr.mockResolvedValueOnce(changedPr); - it('should not create PR if waiting for not pending with stabilityStatus yellow', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); - git.getBranchLastCommitTime.mockImplementationOnce(() => - Promise.resolve(new Date()) - ); - config.prCreation = 'not-pending'; - config.stabilityStatus = BranchStatus.yellow; - const result = await prWorker.ensurePr(config); - isResultWithoutPr(result); - expect(result.prBlockedBy).toBe('AwaitingTests'); - }); + const res = await ensurePr(config); - it('should create PR if pending timeout hit', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); - git.getBranchLastCommitTime.mockImplementationOnce(() => - Promise.resolve(new Date('2017-01-01')) - ); - config.prCreation = 'not-pending'; - config.stabilityStatus = BranchStatus.yellow; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - }); + expect(res).toEqual({ type: 'with-pr', pr: changedPr }); + expect(platform.updatePr).toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); + expect(logger.logger.info).toHaveBeenCalledWith( + { pr: changedPr.number, prTitle }, + `PR updated` + ); + }); - it('should create PR if no longer pending', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.red); - config.prCreation = 'not-pending'; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - }); + it('updates PR due to body change', async () => { + const changedPr: Pr = { ...pr, body: `${body} updated` }; + platform.getBranchPr.mockResolvedValueOnce(changedPr); - it('should create new branch if none exists', async () => { - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - }); + const res = await ensurePr(config); - it('should add assignees and reviewers to new PR', async () => { - config.assignees = ['@foo', 'bar']; - config.reviewers = ['baz', '@boo']; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addAssignees).toHaveBeenCalledTimes(1); - expect(platform.addAssignees.mock.calls).toMatchSnapshot(); - expect(platform.addReviewers).toHaveBeenCalledTimes(1); - expect(platform.addReviewers.mock.calls).toMatchSnapshot(); - }); + expect(res).toEqual({ type: 'with-pr', pr: changedPr }); + expect(platform.updatePr).toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); + }); - it('should filter assignees and reviewers based on their availability', async () => { - config.assignees = ['@foo', 'bar']; - config.reviewers = ['foo', '@bar', 'foo@bar.com']; - config.filterUnavailableUsers = true; - // optional function is undefined by jest - platform.filterUnavailableUsers = jest.fn(); - platform.filterUnavailableUsers.mockResolvedValue(['foo']); - await prWorker.ensurePr(config); - expect(platform.addAssignees.mock.calls).toMatchSnapshot(); - expect(platform.addReviewers.mock.calls).toMatchSnapshot(); - expect(platform.filterUnavailableUsers.mock.calls).toMatchSnapshot(); - }); + it('ignores eviewable content ', async () => { + // See: https://reviewable.io/ - it('should determine assignees from code owners', async () => { - config.assigneesFromCodeOwners = true; - codeOwnersMock.codeOwnersForPr.mockResolvedValueOnce(['@john', '@maria']); - await prWorker.ensurePr(config); - expect(platform.addAssignees).toHaveBeenCalledTimes(1); - expect(platform.addAssignees.mock.calls).toMatchSnapshot(); - }); + const reviewableContent = + '<!-- Reviewable:start -->something<!-- Reviewable:end -->'; + const changedPr: Pr = { ...pr, body: `${body}${reviewableContent}` }; + platform.getBranchPr.mockResolvedValueOnce(changedPr); - it('should determine reviewers from code owners', async () => { - config.reviewersFromCodeOwners = true; - codeOwnersMock.codeOwnersForPr.mockResolvedValueOnce(['@john', '@maria']); - await prWorker.ensurePr(config); - expect(platform.addReviewers).toHaveBeenCalledTimes(1); - expect(platform.addReviewers.mock.calls).toMatchSnapshot(); - }); + const res = await ensurePr(config); - it('should combine assignees from code owners and config', async () => { - codeOwnersMock.codeOwnersForPr.mockResolvedValueOnce(['@jimmy']); - config.assignees = ['@mike', '@julie']; - config.assigneesFromCodeOwners = true; - await prWorker.ensurePr(config); - expect(platform.addAssignees).toHaveBeenCalledTimes(1); - expect(platform.addAssignees.mock.calls).toMatchSnapshot(); + expect(res).toEqual({ type: 'with-pr', pr: changedPr }); + expect(platform.updatePr).not.toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); + }); }); - it('should add reviewers even if assignees fails', async () => { - platform.addAssignees.mockImplementationOnce(() => { - throw new Error('some error'); + describe('dry-run', () => { + beforeEach(() => { + GlobalConfig.set({ dryRun: true }); }); - config.assignees = ['@foo', 'bar']; - config.reviewers = ['baz', '@boo']; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addAssignees).toHaveBeenCalledTimes(1); - expect(platform.addReviewers).toHaveBeenCalledTimes(1); - }); - it('should handled failed reviewers add', async () => { - platform.addReviewers.mockImplementationOnce(() => { - throw new Error('some error'); + it('dry-runs PR creation', async () => { + platform.createPr.mockResolvedValueOnce(pr); + + const res = await ensurePr(config); + + expect(res).toEqual({ + type: 'with-pr', + pr: { displayNumber: 'Dry run PR', number: 0 }, + }); + expect(platform.updatePr).not.toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); + expect(logger.logger.info).toHaveBeenCalledWith( + `DRY-RUN: Would create PR: ${prTitle}` + ); }); - config.assignees = ['@foo', 'bar']; - config.reviewers = ['baz', '@boo']; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addAssignees).toHaveBeenCalledTimes(1); - expect(platform.addReviewers).toHaveBeenCalledTimes(1); - }); - it('should not add assignees and reviewers to new PR if automerging enabled regularly', async () => { - config.assignees = ['bar']; - config.reviewers = ['baz']; - config.automerge = true; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addAssignees).toHaveBeenCalledTimes(0); - expect(platform.addReviewers).toHaveBeenCalledTimes(0); - }); + it('dry-runs PR update', async () => { + const changedPr: Pr = { ...pr, title: 'Another title' }; + platform.getBranchPr.mockResolvedValueOnce(changedPr); - it('should add assignees and reviewers to new PR if automerging enabled but configured to always assign', async () => { - config.assignees = ['bar']; - config.reviewers = ['baz']; - config.automerge = true; - config.assignAutomerge = true; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addAssignees).toHaveBeenCalledTimes(1); - expect(platform.addReviewers).toHaveBeenCalledTimes(1); - }); + const res = await ensurePr(config); - it('should add random sample of assignees and reviewers to new PR', async () => { - config.assignees = ['foo', 'bar', 'baz']; - config.assigneesSampleSize = 2; - config.reviewers = ['baz', 'boo', 'bor']; - config.reviewersSampleSize = 2; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addAssignees).toHaveBeenCalledTimes(1); - const assignees = platform.addAssignees.mock.calls[0][1]; - expect(assignees).toHaveLength(2); - expect(config.assignees).toEqual(expect.arrayContaining(assignees)); - - expect(platform.addReviewers).toHaveBeenCalledTimes(1); - const reviewers = platform.addReviewers.mock.calls[0][1]; - expect(reviewers).toHaveLength(2); - expect(config.reviewers).toEqual(expect.arrayContaining(reviewers)); - }); + expect(res).toEqual({ type: 'with-pr', pr: changedPr }); + expect(platform.updatePr).not.toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); + expect(logger.logger.info).toHaveBeenCalledWith( + `DRY-RUN: Would update PR #${pr.number}` + ); + }); - it('should not add any assignees or reviewers to new PR', async () => { - config.assignees = ['foo', 'bar', 'baz']; - config.assigneesSampleSize = 0; - config.reviewers = ['baz', 'boo', 'bor']; - config.reviewersSampleSize = 0; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addAssignees).toHaveBeenCalledTimes(0); - expect(platform.addReviewers).toHaveBeenCalledTimes(0); - }); + it('skips automerge failure comment', async () => { + platform.createPr.mockResolvedValueOnce(pr); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.red); + platform.massageMarkdown.mockReturnValueOnce('markdown content'); - it('should add and deduplicate additionalReviewers on new PR', async () => { - config.reviewers = ['@foo', 'bar']; - config.additionalReviewers = ['bar', 'baz', '@boo']; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addReviewers).toHaveBeenCalledTimes(1); - expect(platform.addReviewers.mock.calls).toMatchSnapshot(); - }); + await ensurePr({ + ...config, + automerge: true, + automergeType: 'branch', + branchAutomergeFailureMessage: 'branch status error', + suppressNotifications: [], + }); - it('should add and deduplicate additionalReviewers to empty reviewers on new PR', async () => { - config.reviewers = []; - config.additionalReviewers = ['bar', 'baz', '@boo', '@foo', 'bar']; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.addReviewers).toHaveBeenCalledTimes(1); - expect(platform.addReviewers.mock.calls).toMatchSnapshot(); + expect(comment.ensureComment).not.toHaveBeenCalled(); + }); }); - it('should return unmodified existing PR', async () => { - platform.getBranchPr.mockResolvedValueOnce(existingPr); - config.semanticCommitScope = null; - config.automerge = true; - config.schedule = ['before 5am']; - config.logJSON = await changelogHelper.getChangeLogJSON(config); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(platform.updatePr.mock.calls).toMatchSnapshot(); - expect(platform.updatePr).toHaveBeenCalledTimes(0); - expect(result.pr).toMatchObject(existingPr); - }); + describe('Automerge', () => { + it('handles branch automerge', async () => { + platform.getBranchPr.mockResolvedValueOnce(pr); - it('should return unmodified existing PR if only whitespace changes', async () => { - const modifiedPr = JSON.parse( - JSON.stringify(existingPr).replace(' ', ' ').replace('\n', '\r\n') - ); - platform.getBranchPr.mockResolvedValueOnce(modifiedPr); - config.semanticCommitScope = null; - config.automerge = true; - config.schedule = ['before 5am']; - config.logJSON = await changelogHelper.getChangeLogJSON(config); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(platform.updatePr).toHaveBeenCalledTimes(0); - expect(result.pr).toMatchObject(modifiedPr); - }); + const res = await ensurePr({ + ...config, + automerge: true, + automergeType: 'branch', + }); - it('should return modified existing PR', async () => { - config.newValue = '1.2.0'; - config.automerge = true; - config.schedule = ['before 5am']; - config.logJSON = await changelogHelper.getChangeLogJSON(config); - platform.getBranchPr.mockResolvedValueOnce(existingPr); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchSnapshot({ - displayNumber: 'Existing PR', - title: 'Update dependency dummy to v1.1.0', + expect(res).toEqual({ + type: 'without-pr', + prBlockedBy: 'BranchAutomerge', + }); + expect(platform.updatePr).not.toHaveBeenCalled(); + expect(platform.createPr).not.toHaveBeenCalled(); }); - }); - it('should return modified existing PR title', async () => { - config.newValue = '1.2.0'; - platform.getBranchPr.mockResolvedValueOnce({ - ...existingPr, - title: 'wrong', + it('adds assignees for PR automerge with red status', async () => { + const changedPr: Pr = { + ...pr, + hasAssignees: false, + hasReviewers: false, + }; + platform.getBranchPr.mockResolvedValueOnce(changedPr); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.red); + + const res = await ensurePr({ + ...config, + automerge: true, + automergeType: 'pr', + assignAutomerge: false, + }); + + expect(res).toEqual({ type: 'with-pr', pr: changedPr }); + expect(participants.addParticipants).toHaveBeenCalled(); }); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchSnapshot({ - displayNumber: 'Existing PR', - title: 'wrong', + + it('skips branch automerge and forces PR creation due to artifact errors', async () => { + platform.createPr.mockResolvedValueOnce(pr); + + const res = await ensurePr({ + ...config, + automerge: true, + automergeType: 'branch', + artifactErrors: [{ lockFile: 'foo', stderr: 'bar' }], + }); + + expect(res).toEqual({ type: 'with-pr', pr }); + expect(platform.createPr).toHaveBeenCalled(); + expect(participants.addParticipants).not.toHaveBeenCalled(); }); - }); - it('should create PR if branch tests failed', async () => { - config.automerge = true; - config.automergeType = 'branch'; - config.branchAutomergeFailureMessage = 'branch status error'; - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.red); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - }); + it('skips branch automerge and forces PR creation due to prNotPendingHours exceeded', async () => { + const now = DateTime.now(); + const then = now.minus({ hours: 2 }); - it('should create PR if branch automerging failed', async () => { - config.automerge = true; - config.automergeType = 'branch'; - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - config.forcePr = true; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - }); + git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate()); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); + platform.createPr.mockResolvedValueOnce(pr); - it('should return no PR if branch automerging not failed', async () => { - config.automerge = true; - config.automergeType = 'branch'; - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); - git.getBranchLastCommitTime.mockResolvedValueOnce(new Date()); - const result = await prWorker.ensurePr(config); - isResultWithoutPr(result); - expect(result.prBlockedBy).toBe('BranchAutomerge'); - }); + const res = await ensurePr({ + ...config, + automerge: true, + automergeType: 'branch', + stabilityStatus: BranchStatus.green, + prNotPendingHours: 1, + }); - it('should return PR if branch automerging taking too long', async () => { - config.automerge = true; - config.automergeType = 'branch'; - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); - git.getBranchLastCommitTime.mockResolvedValueOnce(new Date('2018-01-01')); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toBeDefined(); - }); + expect(res).toEqual({ type: 'with-pr', pr }); + expect(platform.createPr).toHaveBeenCalled(); + }); - it('should return no PR if stabilityStatus yellow', async () => { - config.automerge = true; - config.automergeType = 'branch'; - config.stabilityStatus = BranchStatus.yellow; - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); - git.getBranchLastCommitTime.mockResolvedValueOnce(new Date('2018-01-01')); - const result = await prWorker.ensurePr(config); - isResultWithoutPr(result); - expect(result.prBlockedBy).toBe('BranchAutomerge'); - }); + it('automerges branch when prNotPendingHours are not exceeded', async () => { + const now = DateTime.now(); + const then = now.minus({ hours: 1 }); + + git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate()); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); + platform.createPr.mockResolvedValueOnce(pr); + + const res = await ensurePr({ + ...config, + automerge: true, + automergeType: 'branch', + stabilityStatus: BranchStatus.green, + prNotPendingHours: 2, + }); + + expect(res).toEqual({ + type: 'without-pr', + prBlockedBy: 'BranchAutomerge', + }); + expect(platform.createPr).not.toHaveBeenCalled(); + }); - it('handles duplicate upgrades', async () => { - config.upgrades.push(config.upgrades[0]); - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - }); + it('comments on automerge failure', async () => { + platform.createPr.mockResolvedValueOnce(pr); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.red); + platform.massageMarkdown.mockReturnValueOnce('markdown content'); + + await ensurePr({ + ...config, + automerge: true, + automergeType: 'branch', + branchAutomergeFailureMessage: 'branch status error', + suppressNotifications: [], + }); + + expect(platform.createPr).toHaveBeenCalled(); + expect(platform.massageMarkdown).toHaveBeenCalled(); + expect(comment.ensureComment).toHaveBeenCalledWith({ + content: 'markdown content', + number: 123, + topic: 'Branch automerge failure', + }); + }); - it('should create privateRepo PR if success', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.green); - config.prCreation = 'status-success'; - config.privateRepo = false; - config.logJSON = await changelogHelper.getChangeLogJSON(config); - config.logJSON.project.repository = 'someproject'; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - expect(platform.createPr.mock.calls[0]).toMatchSnapshot(); - existingPr.body = platform.createPr.mock.calls[0][0].prBody; - }); + it('handles ensureComment error', async () => { + platform.createPr.mockResolvedValueOnce(pr); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.red); + platform.massageMarkdown.mockReturnValueOnce('markdown content'); + comment.ensureComment.mockRejectedValueOnce(new Error('unknown')); + + const res = await ensurePr({ + ...config, + automerge: true, + automergeType: 'branch', + branchAutomergeFailureMessage: 'branch status error', + suppressNotifications: [], + }); + + expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' }); + }); - it('should create PR if waiting for not pending but artifactErrors', async () => { - platform.getBranchStatus.mockResolvedValueOnce(BranchStatus.yellow); - git.getBranchLastCommitTime.mockResolvedValueOnce(new Date()); - config.prCreation = 'not-pending'; - config.artifactErrors = [{}]; - config.platform = PlatformId.Gitlab; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toMatchObject({ displayNumber: 'New Pull Request' }); - }); + it('logs unknown error', async () => { + const changedPr: Pr = { + ...pr, + hasAssignees: false, + hasReviewers: false, + }; + platform.getBranchPr.mockResolvedValueOnce(changedPr); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.red); + + const err = new Error('unknown'); + participants.addParticipants.mockRejectedValueOnce(err); + + await ensurePr({ + ...config, + automerge: true, + automergeType: 'pr', + assignAutomerge: false, + }); + + expect(logger.logger.error).toHaveBeenCalledWith( + { err }, + 'Failed to ensure PR: ' + prTitle + ); + }); - it('should trigger GitLab automerge when configured', async () => { - config.platformAutomerge = true; - config.gitLabIgnoreApprovals = true; - config.automerge = true; - await prWorker.ensurePr(config); - const args = platform.createPr.mock.calls[0]; - expect(args[0].platformOptions).toMatchObject({ - usePlatformAutomerge: true, - gitLabIgnoreApprovals: true, + it('re-throws ExternalHostError', async () => { + const changedPr: Pr = { + ...pr, + hasAssignees: false, + hasReviewers: false, + }; + platform.getBranchPr.mockResolvedValueOnce(changedPr); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.red); + + const err = new ExternalHostError(new Error('unknown')); + participants.addParticipants.mockRejectedValueOnce(err); + + await expect( + ensurePr({ + ...config, + automerge: true, + automergeType: 'pr', + assignAutomerge: false, + }) + ).rejects.toThrow(err); }); + + it.each` + message + ${REPOSITORY_CHANGED} + ${PLATFORM_RATE_LIMIT_EXCEEDED} + ${PLATFORM_INTEGRATION_UNAUTHORIZED} + `( + 're-throws error with specific message: "$message"', + async ({ message }) => { + const changedPr: Pr = { + ...pr, + hasAssignees: false, + hasReviewers: false, + }; + platform.getBranchPr.mockResolvedValueOnce(changedPr); + checks.resolveBranchStatus.mockResolvedValueOnce(BranchStatus.red); + + const err = new Error(message); + participants.addParticipants.mockRejectedValueOnce(err); + + await expect( + ensurePr({ + ...config, + automerge: true, + automergeType: 'pr', + assignAutomerge: false, + }) + ).rejects.toThrow(err); + } + ); }); - it('should create a PR with set of labels and mergeable addLabels', async () => { - config.labels = ['deps', 'renovate']; - config.addLabels = ['deps', 'js']; - const result = await prWorker.ensurePr(config); - isResultWithPr(result); - expect(result.pr).toBeDefined(); - expect(platform.createPr.mock.calls[0][0]).toMatchObject({ - labels: ['deps', 'renovate', 'js'], + describe('Changelog', () => { + const dummyChanges: ChangeLogChange[] = [ + { + date: DateTime.fromISO('2000-01-01').toJSDate(), + message: '', + sha: '', + }, + ]; + + const dummyRelease: ChangeLogRelease = { + version: '', + changes: dummyChanges, + compare: {}, + date: '', + }; + + const dummyUpgrade: BranchUpgradeConfig = { + branchName: sourceBranch, + depType: 'foo', + depName: 'bar', + manager: 'npm', + currentValue: '1.2.3', + newVersion: '4.5.6', + logJSON: { + hasReleaseNotes: true, + project: { + type: 'github', + repository: 'some/repo', + baseUrl: 'https://github.com', + sourceUrl: 'https://github.com/some/repo', + }, + versions: [ + { ...dummyRelease, version: '1.2.3' }, + { ...dummyRelease, version: '2.3.4' }, + { ...dummyRelease, version: '3.4.5' }, + { ...dummyRelease, version: '4.5.6' }, + ], + }, + }; + + it('processes changelogs', async () => { + platform.createPr.mockResolvedValueOnce(pr); + + const res = await ensurePr({ + ...config, + upgrades: [dummyUpgrade], + }); + + expect(res).toEqual({ type: 'with-pr', pr }); + const [[bodyConfig]] = prBody.getPrBody.mock.calls; + expect(bodyConfig).toMatchObject({ + hasReleaseNotes: true, + upgrades: [ + { + hasReleaseNotes: true, + releases: [ + { version: '1.2.3' }, + { version: '2.3.4' }, + { version: '3.4.5' }, + { version: '4.5.6' }, + ], + }, + ], + }); + }); + + it('handles missing GitHub token', async () => { + platform.createPr.mockResolvedValueOnce(pr); + + const res = await ensurePr({ + ...config, + upgrades: [ + { + ...dummyUpgrade, + logJSON: { error: ChangeLogError.MissingGithubToken }, + prBodyNotes: [], + }, + ], + }); + + expect(res).toEqual({ type: 'with-pr', pr }); + + const { + upgrades: [{ prBodyNotes }], + } = prBody.getPrBody.mock.calls[0][0]; + expect(prBodyNotes).toBeNonEmptyArray(); + }); + + it('removes duplicate changelogs', async () => { + platform.createPr.mockResolvedValueOnce(pr); + + const upgrade: BranchUpgradeConfig = { + ...dummyUpgrade, + sourceUrl: 'https://github.com/foo/bar', + sourceDirectory: '/src', + }; + const res = await ensurePr({ + ...config, + upgrades: [upgrade, upgrade, { ...upgrade, depType: 'test' }], + }); + + expect(res).toEqual({ type: 'with-pr', pr }); + const [[bodyConfig]] = prBody.getPrBody.mock.calls; + expect(bodyConfig).toMatchObject({ + branchName: 'renovate-branch', + hasReleaseNotes: true, + prTitle: 'Some title', + upgrades: [ + { depType: 'foo', hasReleaseNotes: true }, + { depType: 'test', hasReleaseNotes: false }, + ], + }); + }); + + it('remove duplicates release notes', async () => { + platform.createPr.mockResolvedValueOnce(pr); + const upgrade = { + ...dummyUpgrade, + logJSON: undefined, + sourceUrl: 'https://github.com/foo/bar', + hasReleaseNotes: true, + }; + delete upgrade.logJSON; + + const res = await ensurePr({ + ...config, + upgrades: [upgrade, { ...upgrade, depType: 'test' }], + }); + + expect(res).toEqual({ type: 'with-pr', pr }); + const [[bodyConfig]] = prBody.getPrBody.mock.calls; + expect(bodyConfig).toMatchObject({ + branchName: 'renovate-branch', + hasReleaseNotes: true, + prTitle: 'Some title', + upgrades: [ + { depType: 'foo', hasReleaseNotes: true }, + { depType: 'test', hasReleaseNotes: false }, + ], + }); }); }); }); diff --git a/lib/workers/repository/update/pr/index.ts b/lib/workers/repository/update/pr/index.ts index 2b8b01b932850e26469147db04bcf05319e725c3..523f0ed4c1ef328b55a35409c3df6807db6e9b55 100644 --- a/lib/workers/repository/update/pr/index.ts +++ b/lib/workers/repository/update/pr/index.ts @@ -1,3 +1,4 @@ +import is from '@sindresorhus/is'; import { GlobalConfig } from '../../../../config/global'; import type { RenovateConfig } from '../../../../config/types'; import { @@ -72,7 +73,7 @@ export async function ensurePr( logger.trace({ config }, 'ensurePr'); // If there is a group, it will use the config of the first upgrade in the array - const { branchName, ignoreTests, prTitle, upgrades } = config; + const { branchName, ignoreTests, prTitle = '', upgrades } = config; const dependencyDashboardCheck = config.dependencyDashboardChecks?.[config.branchName]; // Check if existing PR exists @@ -90,13 +91,14 @@ export async function ensurePr( // Only create a PR if a branch automerge has failed if ( config.automerge === true && - config.automergeType.startsWith('branch') && + config.automergeType?.startsWith('branch') && !config.forcePr ) { logger.debug(`Branch automerge is enabled`); if ( config.stabilityStatus !== BranchStatus.yellow && - (await getBranchStatus()) === BranchStatus.yellow + (await getBranchStatus()) === BranchStatus.yellow && + is.number(config.prNotPendingHours) ) { logger.debug('Checking how long this branch has been pending'); const lastCommitTime = await getBranchLastCommitTime(branchName); @@ -148,7 +150,8 @@ export async function ensurePr( !dependencyDashboardCheck && ((config.stabilityStatus && config.stabilityStatus !== BranchStatus.yellow) || - elapsedHours < config.prNotPendingHours) + (is.number(config.prNotPendingHours) && + elapsedHours < config.prNotPendingHours)) ) { logger.debug( `Branch is ${elapsedHours} hours old - skipping PR creation` @@ -204,13 +207,14 @@ export async function ensurePr( commitRepos.push(getRepoNameWithSourceDirectory(upgrade)); upgrade.hasReleaseNotes = logJSON.hasReleaseNotes; if (logJSON.versions) { - logJSON.versions.forEach((version) => { + for (const version of logJSON.versions) { const release = { ...version }; upgrade.releases.push(release); - }); + } } } } else if (logJSON.error === ChangeLogError.MissingGithubToken) { + upgrade.prBodyNotes ??= []; upgrade.prBodyNotes = [ ...upgrade.prBodyNotes, [ @@ -254,7 +258,6 @@ export async function ensurePr( try { if (existingPr) { logger.debug('Processing existing PR'); - // istanbul ignore if if ( !existingPr.hasAssignees && !existingPr.hasReviewers && @@ -266,6 +269,7 @@ export async function ensurePr( await addParticipants(config, existingPr); } // Check if existing PR needs updating + existingPr.body ??= ''; const reviewableIndex = existingPr.body.indexOf( '<!-- Reviewable:start -->' ); @@ -303,7 +307,6 @@ export async function ensurePr( 'PR body changed' ); } - // istanbul ignore if if (GlobalConfig.get('dryRun')) { logger.info(`DRY-RUN: Would update PR #${existingPr.number}`); } else { @@ -318,17 +321,15 @@ export async function ensurePr( return { type: 'with-pr', pr: existingPr }; } logger.debug({ branch: branchName, prTitle }, `Creating PR`); - // istanbul ignore if if (config.updateType === 'rollback') { logger.info('Creating Rollback PR'); } - let pr: Pr; - try { - // istanbul ignore if - if (GlobalConfig.get('dryRun')) { - logger.info('DRY-RUN: Would create PR: ' + prTitle); - pr = { number: 0, displayNumber: 'Dry run PR' } as never; - } else { + let pr: Pr | null; + if (GlobalConfig.get('dryRun')) { + logger.info('DRY-RUN: Would create PR: ' + prTitle); + pr = { number: 0, displayNumber: 'Dry run PR' } as never; + } else { + try { if ( !dependencyDashboardCheck && isLimitReached(Limit.PullRequests) && @@ -339,7 +340,7 @@ export async function ensurePr( } pr = await platform.createPr({ sourceBranch: branchName, - targetBranch: config.baseBranch, + targetBranch: config.baseBranch ?? '', prTitle, prBody, labels: prepareLabels(config), @@ -347,36 +348,33 @@ export async function ensurePr( draftPR: config.draftPR, }); incLimitedValue(Limit.PullRequests); - logger.info({ pr: pr.number, prTitle }, 'PR created'); - } - } catch (err) /* istanbul ignore next */ { - logger.debug({ err }, 'Pull request creation error'); - if ( - err.body?.message === 'Validation failed' && - err.body.errors?.length && - err.body.errors.some((error: { message?: string }) => - error.message?.startsWith('A pull request already exists') - ) - ) { - logger.warn('A pull requests already exists'); - return { type: 'without-pr', prBlockedBy: 'Error' }; - } - if (err.statusCode === 502) { - logger.warn( - { branch: branchName }, - 'Deleting branch due to server error' - ); - if (GlobalConfig.get('dryRun')) { - logger.info('DRY-RUN: Would delete branch: ' + config.branchName); - } else { + logger.info({ pr: pr?.number, prTitle }, 'PR created'); + } catch (err) { + logger.debug({ err }, 'Pull request creation error'); + if ( + err.body?.message === 'Validation failed' && + err.body.errors?.length && + err.body.errors.some((error: { message?: string }) => + error.message?.startsWith('A pull request already exists') + ) + ) { + logger.warn('A pull requests already exists'); + return { type: 'without-pr', prBlockedBy: 'Error' }; + } + if (err.statusCode === 502) { + logger.warn( + { branch: branchName }, + 'Deleting branch due to server error' + ); await deleteBranch(branchName); } + return { type: 'without-pr', prBlockedBy: 'Error' }; } - return { type: 'without-pr', prBlockedBy: 'Error' }; } if ( + pr && config.branchAutomergeFailureMessage && - !config.suppressNotifications.includes('branchAutomergeFailure') + !config.suppressNotifications?.includes('branchAutomergeFailure') ) { const topic = 'Branch automerge failure'; let content = @@ -386,7 +384,6 @@ export async function ensurePr( } content = platform.massageMarkdown(content); logger.debug('Adding branch automerge failure message to PR'); - // istanbul ignore if if (GlobalConfig.get('dryRun')) { logger.info(`DRY-RUN: Would add comment to PR #${pr.number}`); } else { @@ -398,21 +395,22 @@ export async function ensurePr( } } // Skip assign and review if automerging PR - if ( - config.automerge && - !config.assignAutomerge && - (await getBranchStatus()) !== BranchStatus.red - ) { - logger.debug( - `Skipping assignees and reviewers as automerge=${config.automerge}` - ); - } else { - await addParticipants(config, pr); + if (pr) { + if ( + config.automerge && + !config.assignAutomerge && + (await getBranchStatus()) !== BranchStatus.red + ) { + logger.debug( + `Skipping assignees and reviewers as automerge=${config.automerge}` + ); + } else { + await addParticipants(config, pr); + } + logger.debug(`Created ${pr.displayNumber}`); + return { type: 'with-pr', pr }; } - logger.debug(`Created ${pr.displayNumber}`); - return { type: 'with-pr', pr }; } catch (err) { - // istanbul ignore if if ( err instanceof ExternalHostError || err.message === REPOSITORY_CHANGED || @@ -427,6 +425,5 @@ export async function ensurePr( if (existingPr) { return { type: 'with-pr', pr: existingPr }; } - // istanbul ignore next return { type: 'without-pr', prBlockedBy: 'Error' }; } diff --git a/tsconfig.strict.json b/tsconfig.strict.json index 29af7dbaeb637523911016450b5b7ef44479f99a..22c7ecca3af373b30b496d10d3257ff1c6702aef 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -89,12 +89,8 @@ "lib/workers/repository/update/branch/index.ts", "lib/workers/repository/update/branch/reuse.ts", "lib/workers/repository/update/branch/schedule.ts", - "lib/workers/repository/update/branch/status-checks.ts", "lib/workers/repository/update/pr/automerge.ts", - "lib/workers/repository/update/pr/body/changelogs.ts", - "lib/workers/repository/update/pr/body/config-description.ts", "lib/workers/repository/update/pr/body/controls.ts", - "lib/workers/repository/update/pr/body/index.ts", "lib/workers/repository/update/pr/changelog/github/index.ts", "lib/workers/repository/update/pr/changelog/gitlab/index.ts", "lib/workers/repository/update/pr/changelog/index.ts", @@ -102,7 +98,6 @@ "lib/workers/repository/update/pr/changelog/releases.ts", "lib/workers/repository/update/pr/changelog/source-github.ts", "lib/workers/repository/update/pr/changelog/source-gitlab.ts", - "lib/workers/repository/update/pr/code-owners.ts", - "lib/workers/repository/update/pr/index.ts" + "lib/workers/repository/update/pr/code-owners.ts" ] }