From 42d93b632c5a743b33ec61b26b08350bc7e534ce Mon Sep 17 00:00:00 2001
From: Sergei Zharinov <zharinov@users.noreply.github.com>
Date: Thu, 5 May 2022 12:17:11 +0300
Subject: [PATCH] refactor: `ensurePr` improved testability (#15443)

---
 .../repository/update/branch/schedule.spec.ts |    8 +
 .../repository/update/branch/status-checks.ts |   13 +-
 .../pr/__snapshots__/index.spec.ts.snap       |  248 ---
 .../update/pr/body/changelogs.spec.ts         |   78 +
 .../repository/update/pr/body/changelogs.ts   |    5 +-
 .../update/pr/body/config-description.spec.ts |   94 +-
 .../update/pr/body/config-description.ts      |    3 +-
 .../repository/update/pr/body/footer.spec.ts  |   28 +
 .../repository/update/pr/body/footer.ts       |    1 -
 .../repository/update/pr/body/header.spec.ts  |   27 +
 .../repository/update/pr/body/header.ts       |    1 -
 .../repository/update/pr/body/index.spec.ts   |  111 ++
 .../repository/update/pr/body/index.ts        |   14 +-
 .../repository/update/pr/body/notes.spec.ts   |   50 +
 .../repository/update/pr/index.spec.ts        | 1334 ++++++++---------
 lib/workers/repository/update/pr/index.ts     |  109 +-
 tsconfig.strict.json                          |    7 +-
 17 files changed, 1079 insertions(+), 1052 deletions(-)
 delete mode 100644 lib/workers/repository/update/pr/__snapshots__/index.spec.ts.snap
 create mode 100644 lib/workers/repository/update/pr/body/changelogs.spec.ts
 create mode 100644 lib/workers/repository/update/pr/body/footer.spec.ts
 create mode 100644 lib/workers/repository/update/pr/body/header.spec.ts
 create mode 100644 lib/workers/repository/update/pr/body/index.spec.ts
 create mode 100644 lib/workers/repository/update/pr/body/notes.spec.ts

diff --git a/lib/workers/repository/update/branch/schedule.spec.ts b/lib/workers/repository/update/branch/schedule.spec.ts
index 3c7f819a4d..ca3338350e 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 33d7dd3090..023cd083ee 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 8453638980..0000000000
--- 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 0000000000..cec36e1f7d
--- /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 ceb2e079e9..779a7c0009 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 2ef8337261..2d77a24a0a 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 3a2e8bb096..952e30778d 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 0000000000..e9464491e1
--- /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 d2d250b0f1..5ba69e72d1 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 0000000000..dbdcc2f569
--- /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 9afc5b55c9..99307cc66e 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 0000000000..2c038132dd
--- /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 1b3de2321f..f0484e3a46 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 0000000000..0d74852cc4
--- /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 d6dbf7d49f..5b7d10fd6b 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 2b8b01b932..523f0ed4c1 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 29af7dbaeb..22c7ecca3a 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"
   ]
 }
-- 
GitLab