renovate/lib/workers/repository/update/pr/index.spec.ts

1134 lines
37 KiB
TypeScript

import { DateTime } from 'luxon';
import {
git,
logger,
mocked,
partial,
platform,
scm,
} from '../../../../../test/util';
import { GlobalConfig } from '../../../../config/global';
import {
PLATFORM_INTEGRATION_UNAUTHORIZED,
PLATFORM_RATE_LIMIT_EXCEEDED,
REPOSITORY_CHANGED,
} from '../../../../constants/error-messages';
import * as _comment from '../../../../modules/platform/comment';
import { getPrBodyStruct } from '../../../../modules/platform/pr-body';
import type { Pr } from '../../../../modules/platform/types';
import { ExternalHostError } from '../../../../types/errors/external-host-error';
import type { PrCache } from '../../../../util/cache/repository/types';
import { fingerprint } from '../../../../util/fingerprint';
import { toBase64 } from '../../../../util/string';
import * as _limits from '../../../global/limits';
import type { BranchConfig, BranchUpgradeConfig } from '../../../types';
import { embedChangelogs } from '../../changelog';
import * as _statusChecks from '../branch/status-checks';
import * as _prBody from './body';
import type { ChangeLogChange, ChangeLogRelease } from './changelog/types';
import * as _participants from './participants';
import * as _prCache from './pr-cache';
import { generatePrBodyFingerprintConfig } from './pr-fingerprint';
import { ensurePr } from '.';
jest.mock('../../../../util/git');
jest.mock('../../changelog');
jest.mock('../../../global/limits');
const limits = mocked(_limits);
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);
jest.mock('./pr-cache');
const prCache = mocked(_prCache);
describe('workers/repository/update/pr/index', () => {
describe('ensurePr', () => {
const number = 123;
const sourceBranch = 'renovate-branch';
const prTitle = 'Some title';
const body = 'Some body';
const bodyStruct = getPrBodyStruct(body);
const pr: Pr = {
number,
sourceBranch,
title: prTitle,
bodyStruct,
state: 'open',
targetBranch: 'base',
};
const config: BranchConfig = {
manager: 'some-manager',
branchName: sourceBranch,
baseBranch: 'base',
upgrades: [],
prTitle,
};
beforeEach(() => {
GlobalConfig.reset();
prBody.getPrBody.mockReturnValue(body);
});
describe('Create', () => {
it('creates PR', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr(config);
expect(res).toEqual({ type: 'with-pr', pr });
expect(limits.incLimitedValue).toHaveBeenCalledOnce();
expect(limits.incLimitedValue).toHaveBeenCalledWith('PullRequests');
expect(logger.logger.info).toHaveBeenCalledWith(
{ pr: pr.number, prTitle },
'PR created',
);
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('aborts PR creation once limit is exceeded', async () => {
platform.createPr.mockResolvedValueOnce(pr);
limits.isLimitReached.mockReturnValueOnce(true);
config.fetchChangeLogs = 'pr';
const res = await ensurePr(config);
expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'RateLimited' });
expect(platform.createPr).not.toHaveBeenCalled();
expect(prCache.setPrCache).not.toHaveBeenCalled();
});
it('ignores PR limits on vulnerability alert', async () => {
platform.createPr.mockResolvedValueOnce(pr);
limits.isLimitReached.mockReturnValueOnce(true);
const prConfig = { ...config, isVulnerabilityAlert: true };
delete prConfig.prTitle; // for coverage
const res = await ensurePr(prConfig);
expect(res).toEqual({ type: 'with-pr', pr });
expect(platform.createPr).toHaveBeenCalled();
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('creates rollback PR', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({ ...config, updateType: 'rollback' });
expect(res).toEqual({ type: 'with-pr', pr });
expect(logger.logger.info).toHaveBeenCalledWith('Creating Rollback PR');
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('skips PR creation due to non-green branch check', async () => {
checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
const res = await ensurePr({ ...config, prCreation: 'status-success' });
expect(res).toEqual({
type: 'without-pr',
prBlockedBy: 'AwaitingTests',
});
expect(prCache.setPrCache).not.toHaveBeenCalled();
});
it('creates PR for green branch checks', async () => {
checks.resolveBranchStatus.mockResolvedValueOnce('green');
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({ ...config, prCreation: 'status-success' });
expect(res).toEqual({ type: 'with-pr', pr });
expect(platform.createPr).toHaveBeenCalled();
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('skips PR creation for unapproved dependencies', async () => {
checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
const res = await ensurePr({ ...config, prCreation: 'approval' });
expect(res).toEqual({
type: 'without-pr',
prBlockedBy: 'NeedsApproval',
});
expect(prCache.setPrCache).not.toHaveBeenCalled();
});
it('skips PR creation before prNotPendingHours is hit', async () => {
const now = DateTime.now();
const then = now.minus({ hours: 1 });
checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate());
const res = await ensurePr({
...config,
prCreation: 'not-pending',
prNotPendingHours: 2,
});
expect(res).toEqual({
type: 'without-pr',
prBlockedBy: 'AwaitingTests',
});
expect(prCache.setPrCache).not.toHaveBeenCalled();
});
it('skips PR creation due to stabilityStatus', async () => {
const now = DateTime.now();
const then = now.minus({ hours: 1 });
checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate());
const res = await ensurePr({
...config,
prCreation: 'not-pending',
stabilityStatus: 'green',
});
expect(res).toEqual({
type: 'without-pr',
prBlockedBy: 'AwaitingTests',
});
expect(prCache.setPrCache).not.toHaveBeenCalled();
});
it('creates PR after prNotPendingHours is hit', async () => {
const now = DateTime.now();
const then = now.minus({ hours: 2 });
checks.resolveBranchStatus.mockResolvedValueOnce('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 });
expect(prCache.setPrCache).toHaveBeenCalled();
});
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' });
expect(prCache.setPrCache).not.toHaveBeenCalled();
});
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' }],
};
platform.createPr.mockRejectedValueOnce(err);
const res = await ensurePr(config);
expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' });
expect(logger.logger.warn).toHaveBeenCalledWith(
'A pull requests already exists',
);
expect(prCache.setPrCache).not.toHaveBeenCalled();
});
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);
expect(res).toEqual({ type: 'without-pr', prBlockedBy: 'Error' });
expect(prCache.setPrCache).not.toHaveBeenCalled();
expect(scm.deleteBranch).toHaveBeenCalledWith('renovate-branch');
});
});
});
describe('Update', () => {
it('updates PR if labels have changed in config', async () => {
const prDebugData = {
createdInVer: '1.0.0',
targetBranch: 'main',
labels: ['old_label'],
};
const existingPr: Pr = {
...pr,
bodyStruct: getPrBodyStruct(
`\n<!--renovate-debug:${toBase64(
JSON.stringify(prDebugData),
)}-->\n Some body`,
),
labels: ['old_label'],
};
platform.getBranchPr.mockResolvedValueOnce(existingPr);
prBody.getPrBody.mockReturnValueOnce(
`\n<!--renovate-debug:${toBase64(
JSON.stringify({ ...prDebugData, labels: ['new_label'] }),
)}-->\n Some body`,
);
config.labels = ['new_label'];
const res = await ensurePr(config);
expect(res).toEqual({
type: 'with-pr',
pr: {
...pr,
labels: ['old_label'],
bodyStruct: {
hash: expect.any(String),
debugData: {
createdInVer: '1.0.0',
labels: ['new_label'],
targetBranch: 'main',
},
},
},
});
expect(platform.updatePr).toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(logger.logger.debug).toHaveBeenCalledWith(
{
branchName: 'renovate-branch',
prCurrentLabels: ['old_label'],
configuredLabels: ['new_label'],
},
`PR labels have changed`,
);
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('skips pr update if existing pr does not have labels in debugData', async () => {
const existingPr: Pr = {
...pr,
labels: ['old_label'],
};
platform.getBranchPr.mockResolvedValueOnce(existingPr);
config.labels = ['new_label'];
const res = await ensurePr(config);
expect(res).toEqual({
type: 'with-pr',
pr: { ...pr, labels: ['old_label'] },
});
expect(platform.updatePr).not.toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(logger.logger.debug).not.toHaveBeenCalledWith(
{
branchName: 'renovate-branch',
oldLabels: ['old_label'],
newLabels: ['new_label'],
},
`PR labels have changed`,
);
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('skips pr update if pr labels have been modified by user', async () => {
const prDebugData = {
createdInVer: '1.0.0',
targetBranch: 'main',
labels: ['old_label'],
};
const existingPr: Pr = {
...pr,
bodyStruct: getPrBodyStruct(
`\n<!--renovate-debug:${toBase64(
JSON.stringify(prDebugData),
)}-->\n Some body`,
),
};
platform.getBranchPr.mockResolvedValueOnce(existingPr);
config.labels = ['new_label'];
const res = await ensurePr(config);
expect(res).toEqual({
type: 'with-pr',
pr: {
...pr,
bodyStruct: {
hash: expect.any(String),
debugData: {
createdInVer: '1.0.0',
labels: ['old_label'],
targetBranch: 'main',
},
},
},
});
expect(platform.updatePr).not.toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(logger.logger.debug).not.toHaveBeenCalledWith(
{
branchName: 'renovate-branch',
prCurrentLabels: ['old_label'],
configuredLabels: ['new_label'],
},
`PR labels have changed`,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
{ prInitialLabels: ['old_label'], prCurrentLabels: [] },
'PR labels have been modified by user, skipping labels update',
);
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('updates PR due to title change', async () => {
const changedPr: Pr = { ...pr, title: 'Another title' }; // user changed the prTitle
platform.getBranchPr.mockResolvedValueOnce(changedPr);
const res = await ensurePr(config);
expect(res).toEqual({ type: 'with-pr', pr }); // we redo the prTitle as per config
expect(platform.updatePr).toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(logger.logger.info).toHaveBeenCalledWith(
{ pr: changedPr.number, prTitle },
`PR updated`,
);
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('updates PR due to body change', async () => {
const changedPr: Pr = {
...pr,
bodyStruct: getPrBodyStruct(`${body} updated`), // user changed prBody
};
platform.getBranchPr.mockResolvedValueOnce(changedPr);
const res = await ensurePr(config);
expect(res).toEqual({ type: 'with-pr', pr }); // we redo the prBody as per config
expect(platform.updatePr).toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(prCache.setPrCache).toHaveBeenCalled();
expect(logger.logger.info).toHaveBeenCalledWith(
{ pr: changedPr.number, prTitle },
`PR updated`,
);
});
it('updates PR target branch if base branch changed in config', async () => {
platform.getBranchPr.mockResolvedValueOnce(pr);
const res = await ensurePr({ ...config, baseBranch: 'new_base' }); // user changed base branch in config
expect(platform.updatePr).toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(prCache.setPrCache).toHaveBeenCalled();
expect(logger.logger.info).toHaveBeenCalledWith(
{ pr: pr.number, prTitle },
`PR updated`,
);
expect(logger.logger.debug).toHaveBeenCalledWith(
{
branchName: 'renovate-branch',
oldBaseBranch: 'base',
newBaseBranch: 'new_base',
},
'PR base branch has changed',
);
expect(res).toEqual({
type: 'with-pr',
pr: { ...pr, targetBranch: 'new_base' }, // updated target branch of pr
});
});
it('ignores reviewable content ', async () => {
// See: https://reviewable.io/
const reviewableContent =
'<!-- Reviewable:start -->something<!-- Reviewable:end -->';
const changedPr: Pr = {
...pr,
bodyStruct: getPrBodyStruct(`${body}${reviewableContent}`),
};
platform.getBranchPr.mockResolvedValueOnce(changedPr);
const res = await ensurePr(config);
expect(res).toEqual({ type: 'with-pr', pr: changedPr });
expect(platform.updatePr).not.toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(prCache.setPrCache).toHaveBeenCalled();
expect(logger.logger.debug).toHaveBeenCalledWith(
'Pull Request #123 does not need updating',
);
});
});
describe('dry-run', () => {
beforeEach(() => {
GlobalConfig.set({ dryRun: 'full' });
});
it('dry-runs PR creation', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr(config);
expect(res).toEqual({
type: 'with-pr',
pr: { number: 0 },
});
expect(platform.updatePr).not.toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(logger.logger.info).toHaveBeenCalledWith(
`DRY-RUN: Would create PR: ${prTitle}`,
);
});
it('dry-runs PR update', async () => {
const changedPr: Pr = { ...pr, title: 'Another title' };
platform.getBranchPr.mockResolvedValueOnce(changedPr);
const res = await ensurePr(config);
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('skips automerge failure comment', async () => {
platform.createPr.mockResolvedValueOnce(pr);
checks.resolveBranchStatus.mockResolvedValueOnce('red');
platform.massageMarkdown.mockReturnValueOnce('markdown content');
await ensurePr({
...config,
automerge: true,
automergeType: 'branch',
branchAutomergeFailureMessage: 'branch status error',
suppressNotifications: [],
});
expect(comment.ensureComment).not.toHaveBeenCalled();
});
});
describe('Automerge', () => {
it('handles branch automerge', async () => {
const res = await ensurePr({
...config,
automerge: true,
automergeType: 'branch',
});
expect(res).toEqual({
type: 'without-pr',
prBlockedBy: 'BranchAutomerge',
});
expect(platform.updatePr).not.toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(prCache.setPrCache).not.toHaveBeenCalled();
});
it('forces PR on dashboard check', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({
...config,
automerge: true,
automergeType: 'branch',
reviewers: ['somebody'],
dependencyDashboardChecks: {
'renovate-branch': 'approvePr',
},
});
expect(res).toEqual({ type: 'with-pr', pr });
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('adds assignees for PR automerge with red status', async () => {
const changedPr: Pr = {
...pr,
hasAssignees: false,
};
platform.getBranchPr.mockResolvedValueOnce(changedPr);
checks.resolveBranchStatus.mockResolvedValueOnce('red');
const res = await ensurePr({
...config,
automerge: true,
automergeType: 'pr',
assignAutomerge: false,
});
expect(res).toEqual({ type: 'with-pr', pr: changedPr });
expect(participants.addParticipants).toHaveBeenCalled();
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('adds reviewers for PR automerge with red status and existing ignorable reviewers that can be ignored', async () => {
const changedPr: Pr = {
...pr,
hasAssignees: false,
reviewers: ['renovate-approve'],
};
platform.getBranchPr.mockResolvedValueOnce(changedPr);
checks.resolveBranchStatus.mockResolvedValueOnce('red');
const res = await ensurePr({
...config,
automerge: true,
automergeType: 'pr',
assignAutomerge: false,
ignoreReviewers: ['renovate-approve'],
});
expect(res).toEqual({ type: 'with-pr', pr: changedPr });
expect(participants.addParticipants).toHaveBeenCalled();
});
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();
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('skips branch automerge and forces PR creation due to prNotPendingHours exceeded', async () => {
const now = DateTime.now();
const then = now.minus({ hours: 2 });
git.getBranchLastCommitTime.mockResolvedValueOnce(then.toJSDate());
checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({
...config,
automerge: true,
automergeType: 'branch',
stabilityStatus: 'green',
prNotPendingHours: 1,
});
expect(res).toEqual({ type: 'with-pr', pr });
expect(platform.createPr).toHaveBeenCalled();
expect(prCache.setPrCache).toHaveBeenCalled();
});
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('yellow');
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({
...config,
automerge: true,
automergeType: 'branch',
stabilityStatus: 'green',
prNotPendingHours: 2,
});
expect(res).toEqual({
type: 'without-pr',
prBlockedBy: 'BranchAutomerge',
});
expect(platform.createPr).not.toHaveBeenCalled();
});
it('comments on automerge failure', async () => {
platform.createPr.mockResolvedValueOnce(pr);
checks.resolveBranchStatus.mockResolvedValueOnce('red');
jest
.spyOn(platform, 'massageMarkdown')
.mockImplementation((prBody) => '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('handles ensureComment error', async () => {
platform.createPr.mockResolvedValueOnce(pr);
checks.resolveBranchStatus.mockResolvedValueOnce('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('logs unknown error', async () => {
const changedPr: Pr = {
...pr,
hasAssignees: false,
};
platform.getBranchPr.mockResolvedValueOnce(changedPr);
checks.resolveBranchStatus.mockResolvedValueOnce('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('re-throws ExternalHostError', async () => {
const changedPr: Pr = {
...pr,
hasAssignees: false,
};
platform.getBranchPr.mockResolvedValueOnce(changedPr);
checks.resolveBranchStatus.mockResolvedValueOnce('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,
};
platform.getBranchPr.mockResolvedValueOnce(changedPr);
checks.resolveBranchStatus.mockResolvedValueOnce('red');
const err = new Error(message);
participants.addParticipants.mockRejectedValueOnce(err);
await expect(
ensurePr({
...config,
automerge: true,
automergeType: 'pr',
assignAutomerge: false,
}),
).rejects.toThrow(err);
},
);
});
describe('Changelog', () => {
const dummyChanges: ChangeLogChange[] = [
{
date: DateTime.fromISO('2000-01-01').toJSDate(),
message: '',
sha: '',
},
];
const dummyRelease: ChangeLogRelease = {
version: '',
gitRef: '',
changes: dummyChanges,
compare: {},
date: '',
};
const dummyUpgrade = partial<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',
apiBaseUrl: 'https://api.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: '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 = partial<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 },
],
});
});
// compares currentVersion and currentValue separately to
// prevent removal false duplicates
it('stricter de-deuplication of changelogs', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const upgrade = {
...dummyUpgrade,
currentValue:
'1.21.5-alpine3.18@sha256:d8b99943fb0587b79658af03d4d4e8b57769b21dcf08a8401352a9f2a7228754',
newValue:
'1.21.6-alpine3.18@sha256:3354c3a94c3cf67cb37eb93a8e9474220b61a196b13c26f1c01715c301b22a69',
currentVersion: '1.21.5-alpine3.18',
newVersion: '1.21.6-alpine3.18',
logJSON: undefined,
sourceUrl: 'https://github.com/foo/bar',
hasReleaseNotes: true,
};
delete upgrade.logJSON;
const res = await ensurePr({
...config,
upgrades: [
upgrade,
{
...upgrade,
currentValue:
'1.21.5-alpine3.19@sha256:d8b99943fb0587b79658af03d4d4e8b57769b21dcf08a8401352a9f2a7228754',
newValue:
'1.21.6-alpine3.19@sha256:3354c3a94c3cf67cb37eb93a8e9474220b61a196b13c26f1c01715c301b22a69',
currentVersion: '1.21.5-alpine3.19',
newVersion: '1.21.6-alpine3.19',
},
// adding this object for coverage
{
...upgrade,
currentValue: undefined,
newValue:
'1.21.6-alpine3.19@sha256:3354c3a94c3cf67cb37eb93a8e9474220b61a196b13c26f1c01715c301b22a69',
currentVersion: '1.21.5-alpine3.19',
newVersion: undefined,
},
],
});
expect(res).toEqual({ type: 'with-pr', pr });
const [[bodyConfig]] = prBody.getPrBody.mock.calls;
expect(bodyConfig.upgrades).toHaveLength(3);
});
});
describe('prCache', () => {
const existingPr: Pr = {
...pr,
};
let cachedPr: PrCache | null = null;
it('adds pr-cache when not present', async () => {
platform.getBranchPr.mockResolvedValue(existingPr);
cachedPr = null;
prCache.getPrCache.mockReturnValueOnce(cachedPr);
const res = await ensurePr(config);
expect(res).toEqual({
type: 'with-pr',
pr: existingPr,
});
expect(logger.logger.debug).toHaveBeenCalledWith(
'Pull Request #123 does not need updating',
);
expect(prCache.setPrCache).toHaveBeenCalledTimes(1);
});
it('does not update lastEdited pr-cache when pr fingerprint is same but pr was edited within 24hrs', async () => {
platform.getBranchPr.mockResolvedValue(existingPr);
cachedPr = {
bodyFingerprint: fingerprint(generatePrBodyFingerprintConfig(config)),
lastEdited: new Date().toISOString(),
};
prCache.getPrCache.mockReturnValueOnce(cachedPr);
const res = await ensurePr(config);
expect(res).toEqual({
type: 'with-pr',
pr: existingPr,
});
expect(logger.logger.debug).toHaveBeenCalledWith(
'Pull Request #123 does not need updating',
);
expect(logger.logger.debug).toHaveBeenCalledWith(
'PR cache matches but it has been edited in the past 24hrs, so processing PR',
);
expect(prCache.setPrCache).toHaveBeenCalledWith(
sourceBranch,
cachedPr.bodyFingerprint,
false,
);
});
it('updates pr-cache when pr fingerprint is different', async () => {
platform.getBranchPr.mockResolvedValue(existingPr);
cachedPr = {
bodyFingerprint: 'old',
lastEdited: new Date('2020-01-20T00:00:00Z').toISOString(),
};
prCache.getPrCache.mockReturnValueOnce(cachedPr);
const res = await ensurePr(config);
expect(res).toEqual({
type: 'with-pr',
pr: existingPr,
});
expect(logger.logger.debug).toHaveBeenCalledWith(
'PR fingerprints mismatch, processing PR',
);
expect(prCache.setPrCache).toHaveBeenCalledTimes(1);
});
it('skips fetching changelogs when cache is valid and pr was lastEdited before 24hrs', async () => {
config.repositoryCache = 'enabled';
platform.getBranchPr.mockResolvedValue(existingPr);
cachedPr = {
bodyFingerprint: fingerprint(
generatePrBodyFingerprintConfig({
...config,
fetchChangeLogs: 'pr',
}),
),
lastEdited: new Date('2020-01-20T00:00:00Z').toISOString(),
};
prCache.getPrCache.mockReturnValueOnce(cachedPr);
const res = await ensurePr({ ...config, fetchChangeLogs: 'pr' });
expect(res).toEqual({
type: 'with-pr',
pr: existingPr,
});
expect(logger.logger.debug).toHaveBeenCalledWith(
'PR cache matches and no PR changes in last 24hrs, so skipping PR body check',
);
expect(embedChangelogs).toHaveBeenCalledTimes(0);
});
it('updates PR when rebase requested by user regardless of pr-cache state', async () => {
config.repositoryCache = 'enabled';
platform.getBranchPr.mockResolvedValue({
number,
sourceBranch,
title: prTitle,
bodyStruct: {
hash: 'hash-with-checkbox-checked',
rebaseRequested: true,
},
state: 'open',
});
cachedPr = {
bodyFingerprint: fingerprint(
generatePrBodyFingerprintConfig({
...config,
fetchChangeLogs: 'pr',
}),
),
lastEdited: new Date('2020-01-20T00:00:00Z').toISOString(),
};
prCache.getPrCache.mockReturnValueOnce(cachedPr);
const res = await ensurePr({ ...config, fetchChangeLogs: 'pr' });
expect(res).toEqual({
type: 'with-pr',
pr: {
number,
sourceBranch,
title: prTitle,
bodyStruct,
state: 'open',
targetBranch: 'base',
},
});
expect(logger.logger.debug).toHaveBeenCalledWith(
'PR rebase requested, so skipping cache check',
);
expect(logger.logger.debug).not.toHaveBeenCalledWith(
`Pull Request #${number} does not need updating`,
);
expect(embedChangelogs).toHaveBeenCalledTimes(1);
});
it('logs when cache is enabled but pr-cache is absent', async () => {
config.repositoryCache = 'enabled';
platform.getBranchPr.mockResolvedValue(existingPr);
prCache.getPrCache.mockReturnValueOnce(null);
await ensurePr(config);
expect(logger.logger.debug).toHaveBeenCalledWith('PR cache not found');
});
it('does not log when cache is disabled and pr-cache is absent', async () => {
config.repositoryCache = 'disabled';
platform.getBranchPr.mockResolvedValue(existingPr);
prCache.getPrCache.mockReturnValueOnce(null);
await ensurePr(config);
expect(logger.logger.debug).not.toHaveBeenCalledWith(
'PR cache not found',
);
});
});
});
});