Files
renovate/lib/workers/repository/update/pr/index.spec.ts
Adam Setch 4afe14fb19 refactor: emojify pr body notes (#45646)
* refactor: emojify pr body notes

Signed-off-by: Adam Setch <adam.setch@outlook.com>

* refactor: emojify pr body notes

Signed-off-by: Adam Setch <adam.setch@outlook.com>

* refactor: emojify pr body notes

Signed-off-by: Adam Setch <adam.setch@outlook.com>

---------

Signed-off-by: Adam Setch <adam.setch@outlook.com>
2026-09-10 12:17:28 +00:00

1399 lines
46 KiB
TypeScript

import { codeBlock } from 'common-tags';
import { DateTime } from 'luxon';
import { git, logger, partial, platform, scm } from '~test/util.ts';
import { GlobalConfig } from '../../../../config/global.ts';
import {
PLATFORM_INTEGRATION_UNAUTHORIZED,
PLATFORM_RATE_LIMIT_EXCEEDED,
REPOSITORY_CHANGED,
} from '../../../../constants/error-messages.ts';
import * as _comment from '../../../../modules/platform/comment.ts';
import { getPrBodyStruct } from '../../../../modules/platform/pr-body.ts';
import type { Pr } from '../../../../modules/platform/types.ts';
import { ExternalHostError } from '../../../../types/errors/external-host-error.ts';
import type { PrCache } from '../../../../util/cache/repository/types.ts';
import { fingerprint } from '../../../../util/fingerprint.ts';
import { toBase64 } from '../../../../util/string.ts';
import * as _limits from '../../../global/limits.ts';
import type { BranchConfig, BranchUpgradeConfig } from '../../../types.ts';
import { embedChangelogs } from '../../changelog/index.ts';
import * as _statusChecks from '../branch/status-checks.ts';
import * as _prBody from './body/index.ts';
import type { ChangeLogChange, ChangeLogRelease } from './changelog/types.ts';
import { ensurePr } from './index.ts';
import * as _participants from './participants.ts';
import * as _prCache from './pr-cache.ts';
import { generatePrBodyFingerprintConfig } from './pr-fingerprint.ts';
vi.mock('../../changelog/index.ts');
vi.mock('../../../global/limits.ts');
const limits = vi.mocked(_limits);
vi.mock('../branch/status-checks.ts');
const checks = vi.mocked(_statusChecks);
vi.mock('./body/index.ts');
const prBody = vi.mocked(_prBody);
vi.mock('./participants.ts');
const participants = vi.mocked(_participants);
vi.mock('../../../../modules/platform/comment.ts');
const comment = vi.mocked(_comment);
vi.mock('./pr-cache.ts');
const prCache = vi.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.incCountValue).toHaveBeenCalledTimes(2);
expect(limits.incCountValue).toHaveBeenNthCalledWith(
1,
'ConcurrentPRs',
);
expect(limits.incCountValue).toHaveBeenNthCalledWith(2, 'HourlyPRs');
expect(logger.logger.info).toHaveBeenCalledWith(
{ pr: pr.number, prTitle },
'PR created',
);
expect(prCache.setPrCache).toHaveBeenCalled();
});
it('fetches changelogs for the "pr" stage', async () => {
platform.createPr.mockResolvedValueOnce(pr);
await ensurePr(config);
expect(embedChangelogs).toHaveBeenCalledExactlyOnceWith({
upgrades: config.upgrades,
stage: 'pr',
});
});
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('aborts PR creation once vulnerability alert limit is exceeded', 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: 'without-pr', prBlockedBy: 'RateLimited' });
expect(platform.createPr).not.toHaveBeenCalled();
});
it('counts vulnerability alert PRs against their own limit', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({ ...config, isVulnerabilityAlert: true });
expect(res).toEqual({ type: 'with-pr', pr });
expect(limits.incCountValue).toHaveBeenNthCalledWith(
1,
'VulnerabilityConcurrentPRs',
);
expect(limits.incCountValue).toHaveBeenNthCalledWith(2, 'HourlyPRs');
});
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('creates PR for unapproved dependencies which have been unpended', async () => {
checks.resolveBranchStatus.mockResolvedValueOnce('yellow');
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({
...config,
prCreation: 'approval',
dependencyDashboardChecks: {
'renovate-branch': 'unpend',
},
});
expect(res).toEqual({ type: 'with-pr', pr });
expect(prCache.setPrCache).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).toHaveBeenCalledExactlyOnceWith(
'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.toHaveBeenCalledExactlyOnceWith(
{
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.toHaveBeenCalledExactlyOnceWith(
{
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(
{ labels: ['new_label'] },
`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('forces PR on dashboard unpend check', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({
...config,
automerge: true,
automergeType: 'branch',
reviewers: ['somebody'],
dependencyDashboardChecks: {
'renovate-branch': 'unpend',
},
});
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: [{ fileName: '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');
vi.spyOn(platform, 'massageMarkdown').mockImplementation(
() => '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).toHaveBeenCalledExactlyOnceWith({
content: 'markdown content',
number: 123,
topic: 'Branch automerge failure',
});
});
it('comments on automerge failure due to merge queue', async () => {
platform.createPr.mockResolvedValueOnce(pr);
checks.resolveBranchStatus.mockResolvedValueOnce('red');
platform.massageMarkdown.mockReturnValueOnce('markdown content');
await ensurePr({
...config,
automerge: true,
automergeType: 'branch',
branchAutomergeFailureMessage: 'automerge aborted - merge queue',
suppressNotifications: [],
});
expect(platform.massageMarkdown).toHaveBeenCalledWith(
expect.stringContaining(
'The base branch only accepts changes through its merge queue and rejected the direct push, so branch automerge is not possible. Please set `automergeType=pr` instead, or allow Renovate to bypass the merge queue.',
),
undefined,
);
expect(comment.ensureComment).toHaveBeenCalledExactlyOnceWith({
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.warn).toHaveBeenCalledWith(
{ err, prTitle },
'Failed to ensure PR',
);
});
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).toEqual([
codeBlock`
> ❗ **Important**
>
> Release Notes retrieval for this PR were skipped because no github.com credentials were available.
> If 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).
`,
]);
});
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('Warnings', () => {
describe('Attestations', () => {
describe('when attestation is not removed', () => {
describe.each([
[true, true],
[false, true],
[false, false],
[undefined, undefined],
])(
'current attestation %s, new attestation %s',
(currentAttestation, newAttestation) => {
const dummyUpgrade = partial<BranchUpgradeConfig>({
branchName: sourceBranch,
depType: 'foo',
depName: 'bar',
manager: 'npm',
currentVersion: '1.2.3',
newVersion: '2.3.4',
releases: [{ version: '2.3.4', attestation: newAttestation }],
});
it('does not warn the user', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({
...config,
hasAttestation: currentAttestation,
upgrades: [dummyUpgrade],
});
expect(res).toEqual({ type: 'with-pr', pr });
const [[bodyConfig]] = prBody.getPrBody.mock.calls;
expect(bodyConfig.upgrades[0].prBodyNotes).toBeUndefined();
});
},
);
});
describe('when attestation is removed', () => {
const dummyUpgrade = partial<BranchUpgradeConfig>({
branchName: sourceBranch,
depType: 'foo',
depName: 'bar',
manager: 'npm',
currentVersion: '1.2.3',
newVersion: '2.3.4',
releases: [
// but the update we're updating to does not
{ version: '2.3.4', attestation: false },
],
});
it('warns the user', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({
...config,
// the current release has an attestation
hasAttestation: true,
upgrades: [dummyUpgrade],
});
expect(res).toEqual({ type: 'with-pr', pr });
const [[bodyConfig]] = prBody.getPrBody.mock.calls;
expect(bodyConfig).toMatchObject({
upgrades: [
{
prBodyNotes: [
codeBlock`
> 🛑 **Caution**
>
> bar 1.2.3 was released with an attestation, but 2.3.4 has no attestation.
> Verify that release 2.3.4 was published by the expected author.
`,
],
},
],
});
});
});
// TODO #42312
describe('when attestation is removed in an intermediate version', () => {
const dummyUpgrade = partial<BranchUpgradeConfig>({
branchName: sourceBranch,
depType: 'foo',
depName: 'bar',
manager: 'npm',
currentVersion: '1.2.3',
newVersion: '2.3.4',
releases: [
// previous versions between our currentVersion and newVersion have gaps in attestations
{ version: '1.2.4', attestation: false },
{ version: '1.3.0', attestation: false },
{ version: '2.0.0', attestation: false },
// but the update we're updating to has attestation information
{ version: '2.3.4', attestation: true },
],
});
it('does not warn the user', async () => {
platform.createPr.mockResolvedValueOnce(pr);
const res = await ensurePr({
...config,
// the current release has an attestation
hasAttestation: true,
upgrades: [dummyUpgrade],
});
expect(res).toEqual({ type: 'with-pr', pr });
const [[bodyConfig]] = prBody.getPrBody.mock.calls;
expect(bodyConfig.upgrades[0].prBodyNotes).toBeUndefined();
});
});
});
});
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).toHaveBeenCalledExactlyOnceWith(
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.toHaveBeenCalledExactlyOnceWith(
`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.toHaveBeenCalledExactlyOnceWith(
'PR cache not found',
);
});
it('skips cache early return when autoApprove is set', async () => {
platform.getBranchPr.mockResolvedValue(existingPr);
cachedPr = {
bodyFingerprint: fingerprint(generatePrBodyFingerprintConfig(config)),
lastEdited: '2020-01-20T00:00:00Z',
};
prCache.getPrCache.mockReturnValueOnce(cachedPr);
const res = await ensurePr({ ...config, autoApprove: true });
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',
);
// updatePr should be called to re-trigger approval
expect(platform.updatePr).toHaveBeenCalled();
});
});
describe('autoApprove', () => {
it('updates PR when autoApprove is set even if PR does not need updating', async () => {
platform.getBranchPr.mockResolvedValueOnce(pr);
const res = await ensurePr({ ...config, autoApprove: true });
expect(res).toEqual({
type: 'with-pr',
pr: { ...pr, bodyStruct },
});
expect(platform.updatePr).toHaveBeenCalled();
expect(platform.createPr).not.toHaveBeenCalled();
expect(logger.logger.debug).toHaveBeenCalledWith(
{ prTitle },
'PR approval required',
);
});
});
});
});