mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-25 06:51:11 +00:00
5b9a6b62c5
* fix(prune): refresh PR state before autoclosing Signed-off-by: Adam Setch <adam.setch@outlook.com> * refactor(platform): align getPr refresh param with bitbucket-server Signed-off-by: Adam Setch <adam.setch@outlook.com> --------- Signed-off-by: Adam Setch <adam.setch@outlook.com>
6790 lines
210 KiB
TypeScript
6790 lines
210 KiB
TypeScript
import { codeBlock } from 'common-tags';
|
||
import { RequestError } from 'got';
|
||
import { DateTime } from 'luxon';
|
||
import { hostRules } from '~test/host-rules.ts';
|
||
import * as httpMock from '~test/http-mock.ts';
|
||
import { fakeSha, logger } from '~test/util.ts';
|
||
import { GlobalConfig } from '../../../config/global.ts';
|
||
import {
|
||
CONFIG_GIT_URL_UNAVAILABLE,
|
||
PLATFORM_RATE_LIMIT_EXCEEDED,
|
||
PLATFORM_UNKNOWN_ERROR,
|
||
PR_ALREADY_IN_MERGE_QUEUE,
|
||
REPOSITORY_CANNOT_FORK,
|
||
REPOSITORY_FORKED,
|
||
REPOSITORY_FORK_MISSING,
|
||
REPOSITORY_NOT_FOUND,
|
||
REPOSITORY_RENAMED,
|
||
} from '../../../constants/error-messages.ts';
|
||
import { ExternalHostError } from '../../../types/errors/external-host-error.ts';
|
||
import * as repository from '../../../util/cache/repository/index.ts';
|
||
import * as _git from '../../../util/git/index.ts';
|
||
import { setBaseUrl } from '../../../util/http/github.ts';
|
||
import { toBase64 } from '../../../util/string.ts';
|
||
import { hashBody } from '../pr-body.ts';
|
||
import type {
|
||
CreatePRConfig,
|
||
ReattemptPlatformAutomergeConfig,
|
||
UpdatePrConfig,
|
||
} from '../types.ts';
|
||
import * as branch from './branch.ts';
|
||
import {
|
||
enableAutoMergeMutation,
|
||
enqueuePullRequestMutation,
|
||
repoInfoQuery,
|
||
} from './graphql.ts';
|
||
import * as github from './index.ts';
|
||
import type { ApiPageCache, GhRestPr } from './types.ts';
|
||
|
||
const githubApiHost = 'https://api.github.com';
|
||
const graphqlRateLimitResponse = {
|
||
errors: [
|
||
{
|
||
type: 'RATE_LIMIT',
|
||
code: 'graphql_rate_limit',
|
||
message: 'API rate limit already exceeded for installation ID XXXXXXX.',
|
||
},
|
||
],
|
||
};
|
||
|
||
vi.mock('timers/promises');
|
||
|
||
vi.mock('../../../util/http/queue.ts');
|
||
|
||
const git = vi.mocked(_git);
|
||
|
||
const branchCommitSha = fakeSha('0d9c7726c3d628b7e28af234595cfd20febdbf8e');
|
||
|
||
describe('modules/platform/github/index', () => {
|
||
beforeEach(() => {
|
||
GlobalConfig.reset();
|
||
github.resetConfigs();
|
||
|
||
setBaseUrl(githubApiHost);
|
||
|
||
git.isBranchBehindBase.mockResolvedValue(true);
|
||
git.getBranchCommit.mockReturnValue(branchCommitSha);
|
||
hostRules.add({
|
||
token: '123test',
|
||
});
|
||
|
||
const repoCache = repository.getCache();
|
||
delete repoCache.platform;
|
||
vi.stubEnv('RENOVATE_X_GITHUB_HOST_RULES', undefined);
|
||
});
|
||
|
||
describe('initPlatform()', () => {
|
||
it('should throw if no token', async () => {
|
||
await expect(github.initPlatform({})).rejects.toThrow(
|
||
'Init: You must configure a GitHub token',
|
||
);
|
||
});
|
||
|
||
it('should throw if endpoint is invalid URL', async () => {
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://[invalid',
|
||
token: 'abc',
|
||
}),
|
||
).rejects.toThrow('Invalid GitHub endpoint URL: https://[invalid');
|
||
});
|
||
|
||
it('should throw if using fine-grained token with GHE <3.10', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.9.0' });
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
token: 'github_pat_XXXXXX',
|
||
}),
|
||
).rejects.toThrow(
|
||
'Init: Fine-grained Personal Access Tokens do not support GitHub Enterprise Server API version <3.10 and cannot be used with Renovate.',
|
||
);
|
||
});
|
||
|
||
it('should throw if using fine-grained token with GHE unknown version', async () => {
|
||
httpMock.scope('https://ghe.renovatebot.com').head('/').reply(200);
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
token: 'github_pat_XXXXXX',
|
||
}),
|
||
).rejects.toThrow(
|
||
'Init: Fine-grained Personal Access Tokens do not support GitHub Enterprise Server API version <3.10 and cannot be used with Renovate.',
|
||
);
|
||
});
|
||
|
||
it('should support fine-grained token with GHE >=3.10', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.10.0' })
|
||
.get('/user')
|
||
.reply(200, { login: 'renovate-bot' })
|
||
.get('/user/emails')
|
||
.reply(200, [{ email: 'user@domain.com' }]);
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
token: 'github_pat_XXXXXX',
|
||
}),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://ghe.renovatebot.com/',
|
||
gitAuthor: 'undefined <user@domain.com>',
|
||
renovateUsername: 'renovate-bot',
|
||
token: 'github_pat_XXXXXX',
|
||
});
|
||
expect(git.setPlatformIgnoredAuthors).toHaveBeenCalledWith([
|
||
'noreply@ghe.renovatebot.com',
|
||
]);
|
||
});
|
||
|
||
it('should support fine-grained personal access tokens on GHEC', async () => {
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://api.octocorp.ghe.com',
|
||
token: 'github_pat_XXXXXX',
|
||
username: 'renovate-bot',
|
||
gitAuthor: 'Renovate Bot <renovate@example.com>',
|
||
}),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://api.octocorp.ghe.com/',
|
||
gitAuthor: 'Renovate Bot <renovate@example.com>',
|
||
renovateUsername: 'renovate-bot',
|
||
token: 'github_pat_XXXXXX',
|
||
});
|
||
});
|
||
|
||
it('should throw if user failure', async () => {
|
||
httpMock.scope(githubApiHost).get('/user').reply(404);
|
||
await expect(github.initPlatform({ token: '123test' })).rejects.toThrow(
|
||
'Init: Authentication failure',
|
||
);
|
||
});
|
||
|
||
it('should support default endpoint no email access', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(400);
|
||
await expect(github.initPlatform({ token: '123test' })).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
});
|
||
|
||
it('should support default endpoint no email result', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, [{}]);
|
||
await expect(github.initPlatform({ token: '123test' })).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
});
|
||
|
||
it('should support gitAuthor and username', async () => {
|
||
await expect(
|
||
github.initPlatform({
|
||
token: '123test',
|
||
username: 'renovate-bot',
|
||
gitAuthor: 'renovate@whitesourcesoftware.com',
|
||
}),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
gitAuthor: 'renovate@whitesourcesoftware.com',
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
expect(git.setPlatformIgnoredAuthors).toHaveBeenCalledWith([
|
||
'noreply@github.com',
|
||
]);
|
||
});
|
||
|
||
describe('when using the default gitAuthor', () => {
|
||
describe('when gitAuthor is not set', () => {
|
||
describe('when no email access', () => {
|
||
it('if on GitHub.com, a warning is shown', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'some-other-user',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(400);
|
||
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
|
||
gitAuthor: undefined,
|
||
});
|
||
|
||
expect(logger.logger.once.warn).toHaveBeenCalledWith(
|
||
{
|
||
documentationUrl:
|
||
'https://github.com/renovatebot/renovate/discussions/39309',
|
||
},
|
||
'Using the default gitAuthor email address, renovate@whitesourcesoftware.com, is not recommended on GitHub.com, as this corresponds to a user owned by Mend and used by users of the forking-renovate[bot] GitHub App. For security and authenticity reasons, Mend enables "Vigilant Mode" on this account to visibly flag unsigned commits. As an account you do not control, you will not be able to sign commits. If you are comfortable with the `Unverified` signatures on each commit, no work is needed. Otherwise, it is recommended to migrate to a user account you own',
|
||
);
|
||
});
|
||
|
||
it('if on GitHub Enterprise, a warning is not shown', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.10.0' })
|
||
.get('/user')
|
||
.reply(200, { login: 'renovate-bot' })
|
||
.get('/user/emails')
|
||
.reply(400);
|
||
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
gitAuthor: undefined,
|
||
});
|
||
|
||
expect(logger.logger.once.warn).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('if on GHEC, a warning is not shown', async () => {
|
||
httpMock
|
||
.scope('https://api.octocorp.ghe.com')
|
||
.get('/user')
|
||
.reply(200, { login: 'renovate-bot' })
|
||
.get('/user/emails')
|
||
.reply(400);
|
||
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
endpoint: 'https://api.octocorp.ghe.com',
|
||
gitAuthor: undefined,
|
||
});
|
||
|
||
expect(logger.logger.once.warn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('when email access', () => {
|
||
it('no warning is shown', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'some-other-user',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, [
|
||
{
|
||
email: 'user@domain.com',
|
||
},
|
||
]);
|
||
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
|
||
gitAuthor: undefined,
|
||
});
|
||
|
||
expect(logger.logger.once.warn).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('if on GitHub Enterprise, a warning is not shown', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.10.0' })
|
||
.get('/user')
|
||
.reply(200, { login: 'renovate-bot' })
|
||
.get('/user/emails')
|
||
.reply(200, [
|
||
{
|
||
email: 'user@domain.com',
|
||
},
|
||
]);
|
||
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
gitAuthor: undefined,
|
||
});
|
||
|
||
expect(logger.logger.once.warn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('when explicitly set to only email address', () => {
|
||
it('if on GitHub.com, a warning is shown', async () => {
|
||
httpMock.scope(githubApiHost).get('/user').reply(200, {
|
||
login: 'renovate-bot',
|
||
});
|
||
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
|
||
gitAuthor: 'renovate@whitesourcesoftware.com',
|
||
});
|
||
|
||
expect(logger.logger.once.warn).toHaveBeenCalledWith(
|
||
{
|
||
documentationUrl:
|
||
'https://github.com/renovatebot/renovate/discussions/39309',
|
||
},
|
||
'Using the default gitAuthor email address, renovate@whitesourcesoftware.com, is not recommended on GitHub.com, as this corresponds to a user owned by Mend and used by users of the forking-renovate[bot] GitHub App. For security and authenticity reasons, Mend enables "Vigilant Mode" on this account to visibly flag unsigned commits. As an account you do not control, you will not be able to sign commits. If you are comfortable with the `Unverified` signatures on each commit, no work is needed. Otherwise, it is recommended to migrate to a user account you own',
|
||
);
|
||
});
|
||
|
||
it('if on GitHub Enterprise, a warning is not shown', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.10.0' })
|
||
.get('/user')
|
||
.reply(200, { login: 'renovate-bot' });
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
gitAuthor: 'Mend Renovate <renovate@whitesourcesoftware.com>',
|
||
});
|
||
|
||
expect(logger.logger.once.warn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('when explicitly set to RFC-RFC5322 format', () => {
|
||
it('if on GitHub.com, a warning is shown', async () => {
|
||
httpMock.scope(githubApiHost).get('/user').reply(200, {
|
||
login: 'renovate-bot',
|
||
});
|
||
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
|
||
gitAuthor: 'Mend Renovate <renovate@whitesourcesoftware.com>',
|
||
});
|
||
|
||
expect(logger.logger.once.warn).toHaveBeenCalledWith(
|
||
{
|
||
documentationUrl:
|
||
'https://github.com/renovatebot/renovate/discussions/39309',
|
||
},
|
||
'Using the default gitAuthor email address, renovate@whitesourcesoftware.com, is not recommended on GitHub.com, as this corresponds to a user owned by Mend and used by users of the forking-renovate[bot] GitHub App. For security and authenticity reasons, Mend enables "Vigilant Mode" on this account to visibly flag unsigned commits. As an account you do not control, you will not be able to sign commits. If you are comfortable with the `Unverified` signatures on each commit, no work is needed. Otherwise, it is recommended to migrate to a user account you own',
|
||
);
|
||
});
|
||
|
||
it('if on GitHub Enterprise, a warning is not shown', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.10.0' })
|
||
.get('/user')
|
||
.reply(200, { login: 'renovate-bot' });
|
||
await github.initPlatform({
|
||
token: 'anything',
|
||
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
gitAuthor: 'Mend Renovate <renovate@whitesourcesoftware.com>',
|
||
});
|
||
|
||
expect(logger.logger.once.warn).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|
||
|
||
it('should support default endpoint with email', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, [
|
||
{
|
||
email: 'user@domain.com',
|
||
},
|
||
]);
|
||
await expect(github.initPlatform({ token: '123test' })).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
gitAuthor: 'undefined <user@domain.com>',
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
});
|
||
|
||
it('should use public email from user profile when available', async () => {
|
||
httpMock.scope(githubApiHost).get('/user').reply(200, {
|
||
login: 'renovate-bot',
|
||
name: 'Example User',
|
||
email: 'user@domain.com',
|
||
});
|
||
await expect(github.initPlatform({ token: '123test' })).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
gitAuthor: 'Example User <user@domain.com>',
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
expect(git.setPlatformIgnoredAuthors).toHaveBeenCalledWith([
|
||
'noreply@github.com',
|
||
]);
|
||
});
|
||
|
||
it('should fall back to user/emails when there is no public email', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
name: 'Example User',
|
||
email: null,
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, [{ email: 'user@differentdomain.com' }]);
|
||
await expect(github.initPlatform({ token: '123test' })).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
gitAuthor: 'Example User <user@differentdomain.com>',
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
});
|
||
|
||
it('should fall back gracefully when user/emails returns an error (no user:email scope)', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
name: 'Example User',
|
||
email: null,
|
||
})
|
||
.get('/user/emails')
|
||
.reply(403);
|
||
await expect(github.initPlatform({ token: '123test' })).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
gitAuthor: undefined,
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
});
|
||
|
||
it('should autodetect email/user on default endpoint with GitHub App', async () => {
|
||
vi.stubEnv('RENOVATE_X_GITHUB_HOST_RULES', 'true');
|
||
httpMock
|
||
.scope(githubApiHost, {
|
||
reqheaders: {
|
||
authorization: 'Bearer ghs_123test',
|
||
},
|
||
})
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: { viewer: { login: 'my-app[bot]', databaseId: 12345 } },
|
||
});
|
||
await expect(
|
||
github.initPlatform({ token: 'x-access-token:ghs_123test' }),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
gitAuthor: 'my-app[bot] <12345+my-app[bot]@users.noreply.github.com>',
|
||
hostRules: [
|
||
{
|
||
hostType: 'docker',
|
||
matchHost: 'ghcr.io',
|
||
password: 'ghs_123test',
|
||
username: 'USERNAME',
|
||
},
|
||
{
|
||
hostType: 'npm',
|
||
matchHost: 'npm.pkg.github.com',
|
||
token: 'ghs_123test',
|
||
},
|
||
{
|
||
hostType: 'rubygems',
|
||
matchHost: 'rubygems.pkg.github.com',
|
||
password: 'ghs_123test',
|
||
username: 'my-app[bot]',
|
||
},
|
||
{
|
||
hostType: 'maven',
|
||
matchHost: 'maven.pkg.github.com',
|
||
password: 'ghs_123test',
|
||
username: 'my-app[bot]',
|
||
},
|
||
{
|
||
hostType: 'nuget',
|
||
matchHost: 'nuget.pkg.github.com',
|
||
password: 'ghs_123test',
|
||
username: 'my-app[bot]',
|
||
},
|
||
],
|
||
renovateUsername: 'my-app[bot]',
|
||
token: 'x-access-token:ghs_123test',
|
||
});
|
||
expect(git.setPlatformIgnoredAuthors).toHaveBeenCalledWith([
|
||
'noreply@github.com',
|
||
]);
|
||
await expect(
|
||
github.initPlatform({ token: 'ghs_123test' }),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://api.github.com/',
|
||
gitAuthor: 'my-app[bot] <12345+my-app[bot]@users.noreply.github.com>',
|
||
hostRules: [
|
||
{
|
||
hostType: 'docker',
|
||
matchHost: 'ghcr.io',
|
||
password: 'ghs_123test',
|
||
username: 'USERNAME',
|
||
},
|
||
{
|
||
hostType: 'npm',
|
||
matchHost: 'npm.pkg.github.com',
|
||
token: 'ghs_123test',
|
||
},
|
||
{
|
||
hostType: 'rubygems',
|
||
matchHost: 'rubygems.pkg.github.com',
|
||
password: 'ghs_123test',
|
||
username: 'my-app[bot]',
|
||
},
|
||
{
|
||
hostType: 'maven',
|
||
matchHost: 'maven.pkg.github.com',
|
||
password: 'ghs_123test',
|
||
username: 'my-app[bot]',
|
||
},
|
||
{
|
||
hostType: 'nuget',
|
||
matchHost: 'nuget.pkg.github.com',
|
||
password: 'ghs_123test',
|
||
username: 'my-app[bot]',
|
||
},
|
||
],
|
||
renovateUsername: 'my-app[bot]',
|
||
token: 'x-access-token:ghs_123test',
|
||
});
|
||
});
|
||
|
||
it('should throw error when cant request App information on default endpoint with GitHub App', async () => {
|
||
httpMock.scope(githubApiHost).post('/graphql').reply(200, {});
|
||
await expect(
|
||
github.initPlatform({ token: 'x-access-token:ghs_123test' }),
|
||
).rejects.toThrowWithMessage(Error, 'Init: Authentication failure');
|
||
});
|
||
|
||
it('should report a spent App budget as rate limiting, not as authentication failure', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post('/graphql')
|
||
.reply(200, graphqlRateLimitResponse);
|
||
await expect(
|
||
github.initPlatform({ token: 'x-access-token:ghs_123test' }),
|
||
).rejects.toThrowWithMessage(Error, PLATFORM_RATE_LIMIT_EXCEEDED);
|
||
});
|
||
|
||
it('should autodetect email/user on custom endpoint with GitHub App', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com', {
|
||
reqheaders: {
|
||
authorization: 'Bearer ghs_123test',
|
||
},
|
||
})
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.0.15' })
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: { viewer: { login: 'my-app[bot]', databaseId: 12345 } },
|
||
});
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
token: 'x-access-token:ghs_123test',
|
||
}),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://ghe.renovatebot.com/',
|
||
gitAuthor:
|
||
'my-app[bot] <12345+my-app[bot]@users.noreply.ghe.renovatebot.com>',
|
||
renovateUsername: 'my-app[bot]',
|
||
token: 'x-access-token:ghs_123test',
|
||
});
|
||
expect(git.setPlatformIgnoredAuthors).toHaveBeenCalledWith([
|
||
'noreply@ghe.renovatebot.com',
|
||
]);
|
||
});
|
||
|
||
it('should autodetect email/user on GHE Cloud endpoint with GitHub App', async () => {
|
||
httpMock
|
||
.scope('https://api.octocorp.ghe.com', {
|
||
reqheaders: {
|
||
authorization: 'Bearer ghs_123test',
|
||
},
|
||
})
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: { viewer: { login: 'my-app[bot]', databaseId: 12345 } },
|
||
});
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://api.octocorp.ghe.com',
|
||
token: 'x-access-token:ghs_123test',
|
||
}),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://api.octocorp.ghe.com/',
|
||
gitAuthor: 'my-app[bot] <12345+my-app[bot]@users.noreply.ghe.com>',
|
||
renovateUsername: 'my-app[bot]',
|
||
token: 'x-access-token:ghs_123test',
|
||
});
|
||
expect(git.setPlatformIgnoredAuthors).toHaveBeenCalledWith([
|
||
'noreply@ghe.com',
|
||
]);
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
'Detected GitHub Enterprise Cloud',
|
||
);
|
||
});
|
||
|
||
it('should support custom endpoint', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.0.15' })
|
||
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, [
|
||
{
|
||
email: 'user@domain.com',
|
||
},
|
||
]);
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
token: '123test',
|
||
}),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://ghe.renovatebot.com/',
|
||
gitAuthor: 'undefined <user@domain.com>',
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
});
|
||
|
||
it('should support custom endpoint without version', async () => {
|
||
httpMock
|
||
.scope('https://ghe.renovatebot.com')
|
||
.head('/')
|
||
.reply(200)
|
||
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, [
|
||
{
|
||
email: 'user@domain.com',
|
||
},
|
||
]);
|
||
await expect(
|
||
github.initPlatform({
|
||
endpoint: 'https://ghe.renovatebot.com',
|
||
token: '123test',
|
||
}),
|
||
).resolves.toEqual({
|
||
endpoint: 'https://ghe.renovatebot.com/',
|
||
gitAuthor: 'undefined <user@domain.com>',
|
||
renovateUsername: 'renovate-bot',
|
||
token: '123test',
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('getRepos', () => {
|
||
it('should return an array of repos', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user/repos?per_page=100')
|
||
.reply(200, [
|
||
{
|
||
full_name: 'a/b',
|
||
archived: false,
|
||
},
|
||
{
|
||
full_name: 'c/d',
|
||
archived: false,
|
||
},
|
||
{
|
||
full_name: 'e/f',
|
||
archived: true,
|
||
},
|
||
null,
|
||
]);
|
||
const repos = await github.getRepos();
|
||
expect(repos).toEqual(['a/b', 'c/d']);
|
||
});
|
||
|
||
it('should filters repositories by topics', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/user/repos?per_page=100')
|
||
.reply(200, [
|
||
{
|
||
full_name: 'a/b',
|
||
archived: false,
|
||
topics: [],
|
||
},
|
||
{
|
||
full_name: 'c/d',
|
||
archived: false,
|
||
topics: ['managed-by-renovate'],
|
||
},
|
||
{
|
||
full_name: 'e/f',
|
||
archived: true,
|
||
topics: ['managed-by-renovate'],
|
||
},
|
||
null,
|
||
]);
|
||
|
||
const repos = await github.getRepos({ topics: ['managed-by-renovate'] });
|
||
expect(repos).toEqual(['c/d']);
|
||
});
|
||
|
||
it('should return an array of repos when using Github App endpoint', async () => {
|
||
//Use Github App token
|
||
await github.initPlatform({
|
||
endpoint: githubApiHost,
|
||
username: 'renovate-bot',
|
||
gitAuthor: 'Renovate Bot',
|
||
token: 'x-access-token:123test',
|
||
});
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/installation/repositories?per_page=100')
|
||
.reply(200, {
|
||
repositories: [
|
||
{
|
||
full_name: 'a/b',
|
||
},
|
||
{
|
||
full_name: 'c/d',
|
||
},
|
||
null,
|
||
],
|
||
});
|
||
|
||
const repos = await github.getRepos();
|
||
expect(repos).toEqual(['a/b', 'c/d']);
|
||
});
|
||
|
||
it('should return an array of repos when using GitHub App Installation Token', async () => {
|
||
//Use Github App token
|
||
await github.initPlatform({
|
||
endpoint: githubApiHost,
|
||
username: 'self-hosted-renovate[bot]',
|
||
gitAuthor:
|
||
'Self-hosted Renovate Bot <123456+self-hosted-renovate[bot]@users.noreply.github.com>',
|
||
token: 'ghs_123test',
|
||
});
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/installation/repositories?per_page=100')
|
||
.reply(200, {
|
||
repositories: [
|
||
{
|
||
full_name: 'a/b',
|
||
archived: false,
|
||
},
|
||
{
|
||
full_name: 'c/d',
|
||
archived: false,
|
||
},
|
||
{
|
||
full_name: 'e/f',
|
||
archived: true,
|
||
},
|
||
null,
|
||
],
|
||
});
|
||
|
||
const repos = await github.getRepos();
|
||
expect(repos).toEqual(['a/b', 'c/d']);
|
||
});
|
||
});
|
||
|
||
function initRepoMock(
|
||
scope: httpMock.Scope,
|
||
repository: string,
|
||
other: any = {},
|
||
requestBody?: httpMock.Body,
|
||
): void {
|
||
scope.post(`/graphql`, requestBody).reply(200, {
|
||
data: {
|
||
repository: {
|
||
isFork: false,
|
||
sshUrl: `git@github.com:${repository}.git`,
|
||
isArchived: false,
|
||
nameWithOwner: repository,
|
||
autoMergeAllowed: true,
|
||
hasIssuesEnabled: true,
|
||
mergeCommitAllowed: true,
|
||
rebaseMergeAllowed: true,
|
||
squashMergeAllowed: true,
|
||
defaultBranchRef: {
|
||
name: 'master',
|
||
target: {
|
||
oid: '1234',
|
||
},
|
||
},
|
||
...other,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function prListMock(scope: httpMock.Scope, prNo: number): void {
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: prNo,
|
||
base: { ref: 'master' },
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
state: 'open',
|
||
title: 'Some PR',
|
||
},
|
||
]);
|
||
}
|
||
|
||
function forkInitRepoMock(
|
||
scope: httpMock.Scope,
|
||
repository: string,
|
||
forkExisted: boolean,
|
||
forkResult = 200,
|
||
forkDefaultBranch = 'master',
|
||
isFork = false,
|
||
): void {
|
||
scope
|
||
// repo info
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
isFork,
|
||
sshUrl: `git@github.com:${repository}.git`,
|
||
isArchived: false,
|
||
nameWithOwner: repository,
|
||
hasIssuesEnabled: true,
|
||
mergeCommitAllowed: true,
|
||
rebaseMergeAllowed: true,
|
||
squashMergeAllowed: true,
|
||
defaultBranchRef: {
|
||
name: 'master',
|
||
target: {
|
||
oid: '1234',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!isFork) {
|
||
scope.get(`/repos/${repository}/forks?per_page=100`).reply(
|
||
forkResult,
|
||
forkExisted
|
||
? [
|
||
{
|
||
full_name: 'forked/repo',
|
||
owner: { login: 'forked' },
|
||
default_branch: forkDefaultBranch,
|
||
},
|
||
]
|
||
: [],
|
||
);
|
||
}
|
||
}
|
||
|
||
describe('initRepo', () => {
|
||
it('should squash', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
const config = await github.initRepo({ repository: 'some/repo' });
|
||
expect(config).toEqual({
|
||
defaultBranch: 'master',
|
||
isFork: false,
|
||
repoFingerprint:
|
||
'cd69e13f03c1f8d5399903d4d5e8973cb05218f8b392011be98dd52f0faf2b28541830a9a847716aab9b56be6f4c8e0e4b7cb6d726e7cc992f0b02168bc53f11',
|
||
});
|
||
});
|
||
|
||
it('queries all version-gated fields if the GHES version is unknown', async () => {
|
||
const scope = httpMock
|
||
.scope('https://github.company.com')
|
||
.head('/')
|
||
.reply(200);
|
||
initRepoMock(
|
||
scope,
|
||
'some/repo',
|
||
{},
|
||
{
|
||
query: repoInfoQuery,
|
||
variables: {
|
||
name: 'repo',
|
||
owner: 'some',
|
||
},
|
||
},
|
||
);
|
||
await github.initPlatform({
|
||
endpoint: 'https://github.company.com',
|
||
token: '123test',
|
||
username: 'renovate-bot',
|
||
gitAuthor: 'Renovate Bot <renovate@example.com>',
|
||
});
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
expect(httpMock.allUsed()).toBeTrue();
|
||
});
|
||
|
||
// for coverage
|
||
it('no token', async () => {
|
||
hostRules.clear();
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await expect(github.initRepo({ repository: 'some/repo' })).toResolve();
|
||
});
|
||
|
||
// for coverage
|
||
it('app token', async () => {
|
||
hostRules.clear();
|
||
hostRules.add({
|
||
token: 'x-access-token:123test',
|
||
});
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await expect(github.initRepo({ repository: 'some/repo' })).toResolve();
|
||
});
|
||
|
||
it('should fork when using forkToken', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, 'some/repo', false);
|
||
scope.get('/user').reply(200, {
|
||
login: 'forked',
|
||
});
|
||
scope.post('/repos/some/repo/forks').reply(200, {
|
||
full_name: 'forked/repo',
|
||
default_branch: 'master',
|
||
});
|
||
const config = await github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'token',
|
||
forkCreation: true,
|
||
});
|
||
expect(config).toEqual({
|
||
defaultBranch: 'master',
|
||
isFork: false,
|
||
repoFingerprint:
|
||
'cd69e13f03c1f8d5399903d4d5e8973cb05218f8b392011be98dd52f0faf2b28541830a9a847716aab9b56be6f4c8e0e4b7cb6d726e7cc992f0b02168bc53f11',
|
||
});
|
||
});
|
||
|
||
it('should throw if fork needed but forkCreation=false', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, 'some/repo', false);
|
||
scope.get('/user').reply(200, {
|
||
login: 'forked',
|
||
});
|
||
await expect(
|
||
github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: false,
|
||
}),
|
||
).rejects.toThrow(REPOSITORY_FORK_MISSING);
|
||
});
|
||
|
||
it('should use sshUrl if gitUrl is set to ssh', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
gitUrl: 'ssh',
|
||
});
|
||
expect(git.initRepo).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
url: 'git@github.com:some/repo.git',
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('should throw if sshUrl is not present but gitUrl is set to ssh', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo', { sshUrl: null });
|
||
await expect(
|
||
github.initRepo({
|
||
repository: 'some/repo',
|
||
gitUrl: 'ssh',
|
||
}),
|
||
).rejects.toThrow(CONFIG_GIT_URL_UNAVAILABLE);
|
||
});
|
||
|
||
it('should use fork sshUrl when gitUrl is ssh in fork mode', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, 'some/repo', false);
|
||
scope.get('/user').reply(200, {
|
||
login: 'forked',
|
||
});
|
||
scope.post('/repos/some/repo/forks').reply(200, {
|
||
full_name: 'forked/repo',
|
||
default_branch: 'master',
|
||
ssh_url: 'git@github.com:forked/repo.git',
|
||
});
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'token',
|
||
forkCreation: true,
|
||
gitUrl: 'ssh',
|
||
});
|
||
expect(git.initRepo).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
url: 'git@github.com:forked/repo.git',
|
||
upstreamUrl: 'git@github.com:some/repo.git',
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('throws if the repo is a fork', async () => {
|
||
const repo = 'some/repo';
|
||
const branch = 'master';
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, repo, false, 200, branch, true);
|
||
await expect(
|
||
github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: true,
|
||
}),
|
||
).rejects.toThrow(REPOSITORY_FORKED);
|
||
});
|
||
|
||
it('throws when cannot fork due to username error', async () => {
|
||
const repo = 'some/repo';
|
||
const branch = 'master';
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, repo, false, 200, branch);
|
||
scope.get('/user').reply(404);
|
||
await expect(
|
||
github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: true,
|
||
}),
|
||
).rejects.toThrow(REPOSITORY_CANNOT_FORK);
|
||
});
|
||
|
||
it('throws when listing forks with 404', async () => {
|
||
const repo = 'some/repo';
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, repo, false, 404);
|
||
await expect(
|
||
github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: true,
|
||
}),
|
||
).rejects.toThrow(REPOSITORY_CANNOT_FORK);
|
||
});
|
||
|
||
it('throws when listing forks with 500', async () => {
|
||
const repo = 'some/repo';
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, repo, false, 500);
|
||
await expect(
|
||
github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: true,
|
||
}),
|
||
).rejects.toThrow(REPOSITORY_CANNOT_FORK);
|
||
});
|
||
|
||
it('throws when error creating fork', async () => {
|
||
const repo = 'some/repo';
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, repo, false);
|
||
scope.get('/user').reply(200, {
|
||
login: 'forked',
|
||
});
|
||
scope.post(`/repos/${repo}/forks`).reply(500);
|
||
await expect(
|
||
github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: true,
|
||
forkOrg: 'forked',
|
||
}),
|
||
).rejects.toThrow(REPOSITORY_CANNOT_FORK);
|
||
});
|
||
|
||
it('should update fork when using forkToken and forkOrg', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, 'some/repo', true);
|
||
const config = await github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: true,
|
||
forkOrg: 'forked',
|
||
});
|
||
expect(config).toEqual({
|
||
defaultBranch: 'master',
|
||
isFork: false,
|
||
repoFingerprint:
|
||
'cd69e13f03c1f8d5399903d4d5e8973cb05218f8b392011be98dd52f0faf2b28541830a9a847716aab9b56be6f4c8e0e4b7cb6d726e7cc992f0b02168bc53f11',
|
||
});
|
||
});
|
||
|
||
it('detects fork default branch mismatch', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, 'some/repo', true, 200, 'not_master');
|
||
scope.get('/user').reply(200, {
|
||
login: 'forked',
|
||
});
|
||
scope.post('/repos/forked/repo/git/refs').reply(200);
|
||
scope.patch('/repos/forked/repo').reply(200);
|
||
const config = await github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: true,
|
||
});
|
||
expect(config).toEqual({
|
||
defaultBranch: 'master',
|
||
isFork: false,
|
||
repoFingerprint:
|
||
'cd69e13f03c1f8d5399903d4d5e8973cb05218f8b392011be98dd52f0faf2b28541830a9a847716aab9b56be6f4c8e0e4b7cb6d726e7cc992f0b02168bc53f11',
|
||
});
|
||
});
|
||
|
||
it('should merge', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
isFork: false,
|
||
isArchived: false,
|
||
nameWithOwner: 'some/repo',
|
||
hasIssuesEnabled: true,
|
||
mergeCommitAllowed: true,
|
||
rebaseMergeAllowed: true,
|
||
squashMergeAllowed: false,
|
||
defaultBranchRef: {
|
||
name: 'master',
|
||
target: {
|
||
oid: '1234',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const config = await github.initRepo({
|
||
repository: 'some/repo',
|
||
});
|
||
expect(config).toEqual({
|
||
defaultBranch: 'master',
|
||
isFork: false,
|
||
repoFingerprint:
|
||
'cd69e13f03c1f8d5399903d4d5e8973cb05218f8b392011be98dd52f0faf2b28541830a9a847716aab9b56be6f4c8e0e4b7cb6d726e7cc992f0b02168bc53f11',
|
||
});
|
||
});
|
||
|
||
it('should rebase', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
isFork: false,
|
||
isArchived: false,
|
||
nameWithOwner: 'some/repo',
|
||
hasIssuesEnabled: true,
|
||
mergeCommitAllowed: false,
|
||
rebaseMergeAllowed: true,
|
||
squashMergeAllowed: false,
|
||
defaultBranchRef: {
|
||
name: 'master',
|
||
target: {
|
||
oid: '1234',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const config = await github.initRepo({ repository: 'some/repo' });
|
||
expect(config).toEqual({
|
||
defaultBranch: 'master',
|
||
isFork: false,
|
||
repoFingerprint:
|
||
'cd69e13f03c1f8d5399903d4d5e8973cb05218f8b392011be98dd52f0faf2b28541830a9a847716aab9b56be6f4c8e0e4b7cb6d726e7cc992f0b02168bc53f11',
|
||
});
|
||
});
|
||
|
||
it('should not guess at merge', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
defaultBranchRef: {
|
||
name: 'master',
|
||
target: {
|
||
oid: '1234',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const config = await github.initRepo({ repository: 'some/repo' });
|
||
expect(config).toEqual({
|
||
defaultBranch: 'master',
|
||
isFork: false,
|
||
repoFingerprint:
|
||
'cd69e13f03c1f8d5399903d4d5e8973cb05218f8b392011be98dd52f0faf2b28541830a9a847716aab9b56be6f4c8e0e4b7cb6d726e7cc992f0b02168bc53f11',
|
||
});
|
||
});
|
||
|
||
it('should throw error if archived', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
isArchived: true,
|
||
nameWithOwner: 'some/repo',
|
||
hasIssuesEnabled: true,
|
||
defaultBranchRef: {
|
||
name: 'master',
|
||
target: {
|
||
oid: '1234',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
await expect(
|
||
github.initRepo({ repository: 'some/repo' }),
|
||
).rejects.toThrow('archived');
|
||
});
|
||
|
||
it('throws not-found', async () => {
|
||
httpMock.scope(githubApiHost).post(`/graphql`).reply(404);
|
||
await expect(
|
||
github.initRepo({ repository: 'some/repo' }),
|
||
).rejects.toThrow(REPOSITORY_NOT_FOUND);
|
||
});
|
||
|
||
it('throws unexpected graphql errors', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
errors: [
|
||
{
|
||
type: 'SOME_ERROR_TYPE',
|
||
message: 'Some error message',
|
||
},
|
||
],
|
||
});
|
||
await expect(
|
||
github.initRepo({ repository: 'some/repo' }),
|
||
).rejects.toThrow(PLATFORM_UNKNOWN_ERROR);
|
||
});
|
||
|
||
it('throws graphql rate limit error', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
errors: [
|
||
{
|
||
type: 'RATE_LIMITED',
|
||
message: 'API rate limit exceeded for installation ID XXXXXXX.',
|
||
},
|
||
],
|
||
});
|
||
await expect(
|
||
github.initRepo({ repository: 'some/repo' }),
|
||
).rejects.toThrow(PLATFORM_RATE_LIMIT_EXCEEDED);
|
||
});
|
||
|
||
it('should abort when graphql_rate_limit is returned', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, graphqlRateLimitResponse);
|
||
await expect(
|
||
github.initRepo({ repository: 'some/repo' }),
|
||
).rejects.toThrow(PLATFORM_RATE_LIMIT_EXCEEDED);
|
||
});
|
||
|
||
it('should throw error if renamed', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
nameWithOwner: 'some/other',
|
||
hasIssuesEnabled: true,
|
||
defaultBranchRef: {
|
||
name: 'master',
|
||
target: {
|
||
oid: '1234',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
await expect(
|
||
github.initRepo({ repository: 'some/repo' }),
|
||
).rejects.toThrow(REPOSITORY_RENAMED);
|
||
});
|
||
|
||
it('should not be case sensitive', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post(`/graphql`)
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
nameWithOwner: 'Some/repo',
|
||
hasIssuesEnabled: true,
|
||
defaultBranchRef: {
|
||
name: 'master',
|
||
target: {
|
||
oid: '1234',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const result = await github.initRepo({
|
||
repository: 'some/Repo',
|
||
});
|
||
expect(result.defaultBranch).toBe('master');
|
||
expect(result.isFork).toBeFalse();
|
||
});
|
||
});
|
||
|
||
describe('getBranchForceRebase', () => {
|
||
it('should detect repoForceRebase', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(404);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/main/protection')
|
||
.reply(200, {
|
||
required_pull_request_reviews: {
|
||
dismiss_stale_reviews: false,
|
||
require_code_owner_reviews: false,
|
||
required_approving_review_count: 1,
|
||
},
|
||
required_status_checks: {
|
||
strict: true,
|
||
contexts: [],
|
||
},
|
||
restrictions: {
|
||
users: [
|
||
{
|
||
login: 'rarkins',
|
||
id: 6311784,
|
||
type: 'User',
|
||
site_admin: false,
|
||
},
|
||
],
|
||
teams: [],
|
||
},
|
||
});
|
||
const res = await github.getBranchForceRebase('main');
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should handle 404', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/dev')
|
||
.reply(404);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/dev/protection')
|
||
.reply(404);
|
||
const res = await github.getBranchForceRebase('dev');
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should handle 403', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(404);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/main/protection')
|
||
.reply(403);
|
||
const res = await github.getBranchForceRebase('main');
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should throw 401', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(404);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/main/protection')
|
||
.reply(401);
|
||
await expect(github.getBranchForceRebase('main')).rejects.toThrow(
|
||
'Request failed with status code 401 (Unauthorized): GET https://api.github.com/repos/undefined/branches/main/protection',
|
||
);
|
||
});
|
||
|
||
it('should return empty object when parentRepo is set', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, 'some/repo', false);
|
||
scope.get('/user').reply(200, {
|
||
login: 'forked',
|
||
});
|
||
scope.post('/repos/some/repo/forks').reply(200, {
|
||
full_name: 'forked/repo',
|
||
default_branch: 'master',
|
||
});
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'fork-token',
|
||
forkCreation: true,
|
||
});
|
||
|
||
const res = await github.getBranchForceRebase('main');
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should ignore non_fast_forward ruleset for determining rebase', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(200, [
|
||
{
|
||
type: 'non_fast_forward',
|
||
ruleset_source_type: 'Repository',
|
||
ruleset_source: 'owner/repo',
|
||
ruleset_id: 12345,
|
||
},
|
||
]);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/main/protection')
|
||
.reply(200, {
|
||
required_status_checks: {
|
||
strict: false,
|
||
},
|
||
});
|
||
const res = await github.getBranchForceRebase('main');
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should detect strict required status checks ruleset', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(200, [
|
||
{
|
||
type: 'required_status_checks',
|
||
parameters: {
|
||
strict_required_status_checks_policy: true,
|
||
},
|
||
ruleset_source_type: 'Repository',
|
||
ruleset_source: 'owner/repo',
|
||
ruleset_id: 12345,
|
||
},
|
||
]);
|
||
const res = await github.getBranchForceRebase('main');
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should continue if no expected rulesets have been found', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(200, [
|
||
{
|
||
type: 'deletion',
|
||
},
|
||
]);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/main/protection')
|
||
.reply(200, {
|
||
required_status_checks: {
|
||
strict: true,
|
||
},
|
||
});
|
||
const res = await github.getBranchForceRebase('main');
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should abort and throws on internal error', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(500);
|
||
|
||
await expect(github.getBranchForceRebase('main')).rejects.toThrow(
|
||
ExternalHostError,
|
||
);
|
||
});
|
||
|
||
it('should fallback to legacy branch protection when rulesets not found', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(404);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/main/protection')
|
||
.reply(200, {
|
||
required_status_checks: {
|
||
strict: true,
|
||
},
|
||
});
|
||
const res = await github.getBranchForceRebase('main');
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should return false when no force rebase rules found', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(200, [
|
||
{
|
||
type: 'pull_request',
|
||
parameters: {
|
||
required_approving_review_count: 1,
|
||
},
|
||
ruleset_source_type: 'Repository',
|
||
ruleset_source: 'owner/repo',
|
||
ruleset_id: 12345,
|
||
},
|
||
]);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/main/protection')
|
||
.reply(404);
|
||
const res = await github.getBranchForceRebase('main');
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should return cached result on subsequent calls', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/main')
|
||
.reply(404);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/main/protection')
|
||
.reply(200, {
|
||
required_status_checks: {
|
||
strict: true,
|
||
contexts: [],
|
||
},
|
||
});
|
||
|
||
// First call should make HTTP request and cache the result
|
||
const firstResult = await github.getBranchForceRebase('main');
|
||
expect(firstResult).toBeTrue();
|
||
|
||
// Second call should return cached result without making HTTP request
|
||
// If cache is working, no additional HTTP requests should be made
|
||
const secondResult = await github.getBranchForceRebase('main');
|
||
expect(secondResult).toBeTrue();
|
||
});
|
||
|
||
it('should return cached false result on subsequent calls', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/rules/branches/dev')
|
||
.reply(404);
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.get('/repos/undefined/branches/dev/protection')
|
||
.reply(404);
|
||
|
||
// First call should make HTTP request and cache the result
|
||
const firstResult = await github.getBranchForceRebase('dev');
|
||
expect(firstResult).toBeFalse();
|
||
|
||
// Second call should return cached result without making HTTP request
|
||
const secondResult = await github.getBranchForceRebase('dev');
|
||
expect(secondResult).toBeFalse();
|
||
});
|
||
});
|
||
|
||
describe('isBranchMergeQueueEnabled', () => {
|
||
it('should return true if the branch has a merge queue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/graphql').reply(200, {
|
||
data: { repository: { mergeQueue: { id: 'MQ_kwDOBJLedM0dmQ' } } },
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.isBranchMergeQueueEnabled('main');
|
||
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should return false if the branch has no merge queue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/graphql').reply(200, {
|
||
data: { repository: { mergeQueue: null } },
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.isBranchMergeQueueEnabled('main');
|
||
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should return cached result on subsequent calls', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/graphql').reply(200, {
|
||
data: { repository: { mergeQueue: { id: 'MQ_kwDOBJLedM0dmQ' } } },
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
// First call should make the HTTP request and cache the result
|
||
const firstResult = await github.isBranchMergeQueueEnabled('main');
|
||
// Second call should return cached result without making HTTP request
|
||
const secondResult = await github.isBranchMergeQueueEnabled('main');
|
||
|
||
expect(firstResult).toBeTrue();
|
||
expect(secondResult).toBeTrue();
|
||
});
|
||
|
||
it('should reuse the default branch result from initRepo', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo', {
|
||
mergeQueue: { id: 'MQ_kwDOBJLedM0dmQ' },
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.isBranchMergeQueueEnabled('master');
|
||
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should assume a merge queue if the query returns errors', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/graphql').reply(200, {
|
||
errors: [
|
||
{
|
||
message: "Field 'mergeQueue' doesn't exist on type 'Repository'",
|
||
},
|
||
],
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.isBranchMergeQueueEnabled('main');
|
||
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should assume a merge queue on request error', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/graphql').replyWithError('unknown error');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.isBranchMergeQueueEnabled('main');
|
||
|
||
expect(res).toBeTrue();
|
||
});
|
||
});
|
||
|
||
describe('isPrInMergeQueue', () => {
|
||
it.each`
|
||
isInMergeQueue
|
||
${true}
|
||
${false}
|
||
`(
|
||
'returns $isInMergeQueue from the pull request merge queue status',
|
||
async ({ isInMergeQueue }) => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.post('/graphql').reply(200, {
|
||
data: { repository: { pullRequest: { isInMergeQueue } } },
|
||
});
|
||
|
||
const res = await github.isPrInMergeQueue(1234);
|
||
|
||
expect(res).toBe(isInMergeQueue);
|
||
},
|
||
);
|
||
});
|
||
|
||
describe('getPrList()', () => {
|
||
const t = DateTime.fromISO('2000-01-01T00:00:00.000+00:00');
|
||
const t1 = t.plus({ minutes: 1 }).toISO()!;
|
||
const t2 = t.plus({ minutes: 2 }).toISO()!;
|
||
const t3 = t.plus({ minutes: 3 }).toISO()!;
|
||
const t4 = t.plus({ minutes: 4 }).toISO()!;
|
||
|
||
const pr1: GhRestPr = {
|
||
number: 1,
|
||
head: {
|
||
ref: 'branch-1',
|
||
sha: fakeSha('111'),
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
base: { repo: { pushed_at: '' }, ref: 'repo/fork_branch' },
|
||
state: 'open',
|
||
title: 'PR #1',
|
||
created_at: t1,
|
||
updated_at: t1,
|
||
mergeable_state: 'clean',
|
||
node_id: '12345',
|
||
};
|
||
|
||
const pr2: GhRestPr = {
|
||
...pr1,
|
||
number: 2,
|
||
head: {
|
||
ref: 'branch-2',
|
||
sha: fakeSha('222'),
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
state: 'open',
|
||
title: 'PR #2',
|
||
updated_at: t2,
|
||
};
|
||
|
||
const pr3: GhRestPr = {
|
||
...pr1,
|
||
number: 3,
|
||
head: {
|
||
ref: 'branch-3',
|
||
sha: fakeSha('333'),
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
state: 'open',
|
||
title: 'PR #3',
|
||
updated_at: t3,
|
||
};
|
||
|
||
function pagePath(x: number, perPage = 100) {
|
||
return `/repos/some/repo/pulls?per_page=${perPage}&state=all&sort=updated&direction=desc&page=${x}`;
|
||
}
|
||
function pageLink(x: number) {
|
||
return `<${githubApiHost}${pagePath(x)}>; rel="next"`;
|
||
}
|
||
|
||
it('fetches single page', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [pr1]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toMatchObject([{ number: 1, title: 'PR #1' }]);
|
||
});
|
||
|
||
it('fetches multiple pages', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(pagePath(1))
|
||
.reply(200, [pr3], {
|
||
link: `${pageLink(2)}, ${pageLink(3).replace('next', 'last')}`,
|
||
})
|
||
.get(pagePath(2))
|
||
.reply(200, [pr2], { link: pageLink(3) })
|
||
.get(pagePath(3))
|
||
.reply(200, [pr1]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toMatchObject([{ number: 3 }, { number: 2 }, { number: 1 }]);
|
||
});
|
||
|
||
it('synchronizes cache', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
initRepoMock(scope, 'some/repo');
|
||
|
||
scope
|
||
.get(pagePath(1))
|
||
.reply(200, [pr3], {
|
||
link: `${pageLink(2)}, ${pageLink(3).replace('next', 'last')}`,
|
||
})
|
||
.get(pagePath(2))
|
||
.reply(200, [pr2])
|
||
.get(pagePath(3))
|
||
.reply(200, [pr1]);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res1 = await github.getPrList();
|
||
|
||
scope
|
||
.get(pagePath(1, 20))
|
||
.reply(200, [{ ...pr3, updated_at: t4, title: 'PR #3 (updated)' }], {
|
||
link: `${pageLink(2)}`,
|
||
})
|
||
.get(pagePath(2, 20))
|
||
.reply(200, [{ ...pr2, updated_at: t4, title: 'PR #2 (updated)' }], {
|
||
link: `${pageLink(3)}`,
|
||
})
|
||
.get(pagePath(3, 20))
|
||
.reply(200, [{ ...pr1, updated_at: t4, title: 'PR #1 (updated)' }]);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res2 = await github.getPrList();
|
||
|
||
expect(res1).toMatchObject([
|
||
{ number: 3, title: 'PR #3' },
|
||
{ number: 2, title: 'PR #2' },
|
||
{ number: 1, title: 'PR #1' },
|
||
]);
|
||
expect(res2).toMatchObject([
|
||
{ number: 3, title: 'PR #3 (updated)' },
|
||
{ number: 2, title: 'PR #2 (updated)' },
|
||
{ number: 1, title: 'PR #1 (updated)' },
|
||
]);
|
||
});
|
||
|
||
describe('Body compaction', () => {
|
||
type PrCache = ApiPageCache<GhRestPr>;
|
||
|
||
function prWithBody(body: string): GhRestPr {
|
||
return {
|
||
...pr1,
|
||
body,
|
||
};
|
||
}
|
||
|
||
it('compacts body from response', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [prWithBody('foo')]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await github.getPrList();
|
||
|
||
const repoCache = repository.getCache();
|
||
const pullRequestsCache = repoCache.platform?.github
|
||
?.pullRequestsCache as PrCache;
|
||
expect(pullRequestsCache).toMatchObject({ items: {} });
|
||
|
||
const item = pullRequestsCache.items[1];
|
||
expect(item).toBeDefined();
|
||
expect(item.body).toBeUndefined();
|
||
expect(item.bodyStruct).toEqual({ hash: hashBody('foo') });
|
||
});
|
||
});
|
||
|
||
describe('PR author filtering', () => {
|
||
const renovatePr: GhRestPr = {
|
||
...pr1,
|
||
number: 1,
|
||
head: {
|
||
ref: 'renovate-branch',
|
||
sha: fakeSha('111'),
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
title: 'Renovate PR',
|
||
user: { login: 'renovate-bot' },
|
||
};
|
||
|
||
const otherPr: GhRestPr = {
|
||
...pr1,
|
||
number: 2,
|
||
head: {
|
||
ref: 'other-branch',
|
||
sha: fakeSha('222'),
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
title: 'Other PR',
|
||
user: { login: 'other-user' },
|
||
};
|
||
|
||
it('filters PRs by renovate username when no forkToken or ignorePrAuthor', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [renovatePr, otherPr]);
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(1);
|
||
expect(res).toMatchObject([{ number: 1, title: 'Renovate PR' }]);
|
||
});
|
||
|
||
it('fetches all PRs when forkToken is set', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, 'some/repo', false);
|
||
scope.get('/user').reply(200, {
|
||
login: 'forked',
|
||
});
|
||
scope.post('/repos/some/repo/forks').reply(200, {
|
||
full_name: 'forked/repo',
|
||
default_branch: 'master',
|
||
});
|
||
scope.get(pagePath(1)).reply(200, [renovatePr, otherPr]);
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
forkToken: 'some-token',
|
||
forkCreation: true,
|
||
});
|
||
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(2);
|
||
expect(res).toMatchObject([
|
||
{ number: 2, title: 'Other PR' },
|
||
{ number: 1, title: 'Renovate PR' },
|
||
]);
|
||
});
|
||
|
||
it('fetches all PRs when ignorePrAuthor is set', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [renovatePr, otherPr]);
|
||
GlobalConfig.set({ ignorePrAuthor: true });
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(2);
|
||
expect(res).toMatchObject([
|
||
{ number: 2, title: 'Other PR' },
|
||
{ number: 1, title: 'Renovate PR' },
|
||
]);
|
||
});
|
||
|
||
it('stops sync early when non-Renovate PRs dominate', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
const t0 = t.toISO()!;
|
||
|
||
// Run 1: initial fetch — cache gets lastModified = t1
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [renovatePr]);
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
await github.getPrList();
|
||
|
||
// Run 2: sync — pages full of non-Renovate PRs
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(pagePath(1, 20))
|
||
.reply(
|
||
200,
|
||
[
|
||
{ ...pr1, number: 10, updated_at: t4, user: { login: 'other' } },
|
||
{ ...pr1, number: 11, updated_at: t3, user: { login: 'other' } },
|
||
],
|
||
{ link: pageLink(2) },
|
||
)
|
||
.get(pagePath(2, 20))
|
||
.reply(
|
||
200,
|
||
[
|
||
{ ...pr1, number: 12, updated_at: t2, user: { login: 'other' } },
|
||
{ ...pr1, number: 13, updated_at: t0, user: { login: 'other' } },
|
||
],
|
||
{ link: pageLink(3) },
|
||
);
|
||
// Page 3 is NOT mocked — sync must stop before requesting it
|
||
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(1);
|
||
expect(res).toMatchObject([{ number: 1, title: 'Renovate PR' }]);
|
||
});
|
||
|
||
it('advances watermark from unfiltered page so next sync is cheaper', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
const t5 = t.plus({ minutes: 5 }).toISO()!;
|
||
|
||
// Run 1: initial fetch — cache gets lastModified = t1
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [renovatePr]);
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
await github.getPrList();
|
||
|
||
// Run 2: sync — non-Renovate PRs advance watermark to t4
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1, 20)).reply(
|
||
200,
|
||
[
|
||
{ ...pr1, number: 10, updated_at: t4, user: { login: 'other' } },
|
||
{
|
||
...pr1,
|
||
number: 11,
|
||
updated_at: t.toISO(),
|
||
user: { login: 'other' },
|
||
},
|
||
],
|
||
{ link: pageLink(2) },
|
||
);
|
||
// Stops after page 1 — oldest (t0) < cutoff (t1)
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
await github.getPrList();
|
||
|
||
// Run 3: sync — watermark is now t4, page has item at t5 and t3
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1, 20)).reply(
|
||
200,
|
||
[
|
||
{ ...pr1, number: 12, updated_at: t5, user: { login: 'other' } },
|
||
{ ...pr1, number: 13, updated_at: t3, user: { login: 'other' } },
|
||
],
|
||
{ link: pageLink(2) },
|
||
);
|
||
// Page 2 NOT mocked — oldest (t3) < watermark (t4) → stops
|
||
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(1);
|
||
expect(res).toMatchObject([{ number: 1, title: 'Renovate PR' }]);
|
||
});
|
||
|
||
it('derives cutoff from cached items when lastModified is missing', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
// Seed cache with multiple items but no lastModified (simulates updateItem usage).
|
||
const repoCache = repository.getCache();
|
||
repoCache.platform = {
|
||
github: {
|
||
pullRequestsCache: {
|
||
items: {
|
||
1: {
|
||
number: 1,
|
||
sourceBranch: 'renovate-branch',
|
||
title: 'Renovate PR',
|
||
state: 'open',
|
||
updated_at: t1,
|
||
node_id: '12345',
|
||
},
|
||
2: {
|
||
number: 2,
|
||
sourceBranch: 'renovate-branch-2',
|
||
title: 'Renovate PR 2',
|
||
state: 'open',
|
||
updated_at: t2,
|
||
node_id: '12346',
|
||
},
|
||
3: {
|
||
number: 3,
|
||
sourceBranch: 'renovate-branch-3',
|
||
title: 'Renovate PR 3',
|
||
state: 'open',
|
||
updated_at: t1,
|
||
node_id: '12347',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
// Sync: page has non-Renovate PRs, oldest predates derived cutoff (t2)
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1, 20)).reply(
|
||
200,
|
||
[
|
||
{ ...pr1, number: 10, updated_at: t3, user: { login: 'other' } },
|
||
{ ...pr1, number: 11, updated_at: t1, user: { login: 'other' } },
|
||
],
|
||
{ link: pageLink(2) },
|
||
);
|
||
// Page 2 NOT mocked — cutoff derived from max(item.updated_at) = t2 stops sync
|
||
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(3);
|
||
expect(res).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ number: 1, title: 'Renovate PR' }),
|
||
expect.objectContaining({ number: 2, title: 'Renovate PR 2' }),
|
||
expect.objectContaining({ number: 3, title: 'Renovate PR 3' }),
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('stops at max sync pages', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
// Run 1: initial fetch — cache gets lastModified = t1
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [renovatePr]);
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
await github.getPrList();
|
||
|
||
// Run 2: sync — 100 pages of non-Renovate PRs all newer than lastModified
|
||
initRepoMock(scope, 'some/repo');
|
||
for (let i = 1; i <= 100; i++) {
|
||
scope.get(pagePath(i, 20)).reply(
|
||
200,
|
||
[
|
||
{
|
||
...pr1,
|
||
number: 100 + i,
|
||
updated_at: t4,
|
||
user: { login: 'other' },
|
||
},
|
||
],
|
||
{ link: pageLink(i + 1) },
|
||
);
|
||
}
|
||
// Page 101 NOT mocked — must stop at 100
|
||
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(1);
|
||
expect(res).toMatchObject([{ number: 1, title: 'Renovate PR' }]);
|
||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||
{ repo: 'some/repo', pages: 100 },
|
||
'PR cache: hit max sync pages, stopping',
|
||
);
|
||
});
|
||
|
||
it('stops at custom max sync pages', async () => {
|
||
GlobalConfig.set({ prCacheSyncMaxPages: 2 });
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
// Run 1: initial fetch — cache gets lastModified = t1
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [renovatePr]);
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
await github.getPrList();
|
||
|
||
// Run 2: sync — 2 pages of non-Renovate PRs all newer than lastModified
|
||
initRepoMock(scope, 'some/repo');
|
||
for (let i = 1; i <= 2; i++) {
|
||
scope.get(pagePath(i, 20)).reply(
|
||
200,
|
||
[
|
||
{
|
||
...pr1,
|
||
number: 100 + i,
|
||
updated_at: t4,
|
||
user: { login: 'other' },
|
||
},
|
||
],
|
||
{ link: pageLink(i + 1) },
|
||
);
|
||
}
|
||
// Page 3 NOT mocked — must stop at 2
|
||
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(1);
|
||
expect(res).toMatchObject([{ number: 1, title: 'Renovate PR' }]);
|
||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||
{ repo: 'some/repo', pages: 2 },
|
||
'PR cache: hit max sync pages, stopping',
|
||
);
|
||
});
|
||
|
||
it('reconciles mixed pages with both Renovate and non-Renovate PRs', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
// Run 1: initial fetch — cache gets lastModified = t1
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [renovatePr]);
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
await github.getPrList();
|
||
|
||
// Run 2: sync — mixed page with Renovate PR among non-Renovate PRs
|
||
initRepoMock(scope, 'some/repo');
|
||
const renovatePr2: GhRestPr = {
|
||
...renovatePr,
|
||
number: 2,
|
||
title: 'Renovate PR 2',
|
||
updated_at: t3,
|
||
};
|
||
scope.get(pagePath(1, 20)).reply(
|
||
200,
|
||
[
|
||
{ ...pr1, number: 10, updated_at: t4, user: { login: 'other' } },
|
||
renovatePr2,
|
||
{
|
||
...pr1,
|
||
number: 11,
|
||
updated_at: t.toISO(),
|
||
user: { login: 'other' },
|
||
},
|
||
],
|
||
{ link: pageLink(2) },
|
||
);
|
||
// Page 2 NOT mocked — oldest (t) < lastModified (t1) → stops
|
||
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(2);
|
||
expect(res).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ number: 1, title: 'Renovate PR' }),
|
||
expect.objectContaining({ number: 2, title: 'Renovate PR 2' }),
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('continues past timestamp tie at page boundary', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
const renovatePrT2: GhRestPr = { ...renovatePr, updated_at: t2 };
|
||
|
||
// Run 1: initial fetch — cache gets lastModified = t2
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get(pagePath(1)).reply(200, [renovatePrT2]);
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
await github.getPrList();
|
||
|
||
// Run 2: sync — oldest on page 1 equals lastModified (t2), must continue
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(pagePath(1, 20))
|
||
.reply(
|
||
200,
|
||
[
|
||
{ ...pr1, number: 10, updated_at: t3, user: { login: 'other' } },
|
||
{ ...pr1, number: 11, updated_at: t2, user: { login: 'other' } },
|
||
],
|
||
{ link: pageLink(2) },
|
||
)
|
||
.get(pagePath(2, 20))
|
||
.reply(
|
||
200,
|
||
[{ ...pr1, number: 12, updated_at: t1, user: { login: 'other' } }],
|
||
{ link: pageLink(3) },
|
||
);
|
||
// Page 3 is NOT mocked — sync must stop after page 2
|
||
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'renovate-bot',
|
||
});
|
||
const res = await github.getPrList();
|
||
|
||
expect(res).toHaveLength(1);
|
||
expect(res).toMatchObject([{ number: 1, title: 'Renovate PR' }]);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('getBranchPr(branchName)', () => {
|
||
beforeEach(() => {
|
||
GlobalConfig.reset();
|
||
});
|
||
|
||
it('should return null if no PR exists', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, []);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.getBranchPr('somebranch');
|
||
expect(pr).toBeNull();
|
||
});
|
||
|
||
it('should cache and return the PR object', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 90,
|
||
head: { ref: 'somebranch', repo: { full_name: 'other/repo' } },
|
||
state: 'open',
|
||
title: 'PR from another repo',
|
||
updated_at: '01-09-2022',
|
||
},
|
||
{
|
||
number: 91,
|
||
base: { sha: '1234' },
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
state: 'open',
|
||
title: 'Some title',
|
||
updated_at: '01-09-2022',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const pr = await github.getBranchPr('somebranch');
|
||
const pr2 = await github.getBranchPr('somebranch');
|
||
|
||
expect(pr).toMatchObject({ number: 91, sourceBranch: 'somebranch' });
|
||
expect(pr2).toEqual(pr);
|
||
});
|
||
});
|
||
|
||
describe('tryReuseAutoclosedPr()', () => {
|
||
const prSha = fakeSha('1234');
|
||
|
||
it('should reopen autoclosed PR', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.head(`/repos/some/repo/git/commits/${prSha}`)
|
||
.reply(200)
|
||
.post('/repos/some/repo/git/refs')
|
||
.reply(201)
|
||
.patch('/repos/some/repo/pulls/91')
|
||
.reply(200, {
|
||
number: 91,
|
||
base: { sha: prSha },
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
state: 'open',
|
||
title: 'old title',
|
||
updated_at: '01-09-2022',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
vi.spyOn(branch, 'remoteBranchExists').mockResolvedValueOnce(false);
|
||
|
||
const pr = await github.tryReuseAutoclosedPr(
|
||
{
|
||
number: 91,
|
||
title: 'old title - autoclosed',
|
||
state: 'closed',
|
||
closedAt: DateTime.now().minus({ days: 6 }).toISO(),
|
||
sourceBranch: 'somebranch',
|
||
sha: prSha,
|
||
},
|
||
'new title',
|
||
);
|
||
|
||
expect(pr).toMatchObject({ number: 91, sourceBranch: 'somebranch' });
|
||
});
|
||
|
||
it('force pushes when local SHA differs from PR SHA', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.head(`/repos/some/repo/git/commits/${prSha}`)
|
||
.reply(200)
|
||
.post('/repos/some/repo/git/refs')
|
||
.reply(201)
|
||
.patch('/repos/some/repo/pulls/91')
|
||
.reply(200, {
|
||
number: 91,
|
||
base: { sha: prSha },
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
state: 'open',
|
||
title: 'old title',
|
||
updated_at: '01-09-2022',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
vi.spyOn(branch, 'remoteBranchExists').mockResolvedValueOnce(false);
|
||
const localSha = fakeSha('5678');
|
||
git.getBranchCommit.mockReturnValueOnce(localSha);
|
||
|
||
const pr = await github.tryReuseAutoclosedPr(
|
||
{
|
||
number: 91,
|
||
title: 'old title - autoclosed',
|
||
state: 'closed',
|
||
closedAt: DateTime.now().minus({ days: 6 }).toISO(),
|
||
sourceBranch: 'somebranch',
|
||
sha: prSha,
|
||
},
|
||
'new title',
|
||
);
|
||
|
||
expect(pr).toMatchObject({
|
||
number: 91,
|
||
sourceBranch: 'somebranch',
|
||
sha: localSha,
|
||
});
|
||
expect(git.forcePushToRemote).toHaveBeenCalledExactlyOnceWith(
|
||
'somebranch',
|
||
'origin',
|
||
);
|
||
});
|
||
|
||
it('aborts reopening if branch recreation fails', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.head(`/repos/some/repo/git/commits/${prSha}`)
|
||
.reply(200)
|
||
.post('/repos/some/repo/git/refs')
|
||
.reply(201)
|
||
.patch('/repos/some/repo/pulls/91')
|
||
.reply(422);
|
||
vi.spyOn(branch, 'remoteBranchExists').mockResolvedValueOnce(false);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const pr = await github.tryReuseAutoclosedPr(
|
||
{
|
||
number: 91,
|
||
title: 'old title - autoclosed',
|
||
state: 'closed',
|
||
closedAt: DateTime.now().minus({ days: 6 }).toISO(),
|
||
sourceBranch: 'somebranch',
|
||
sha: prSha,
|
||
},
|
||
'new title',
|
||
);
|
||
|
||
expect(pr).toBeNull();
|
||
});
|
||
|
||
it('aborts reopening if PR reopening fails', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.head(`/repos/some/repo/git/commits/${prSha}`).reply(400);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const pr = await github.tryReuseAutoclosedPr(
|
||
{
|
||
number: 91,
|
||
title: 'old title - autoclosed',
|
||
state: 'closed',
|
||
closedAt: DateTime.now().minus({ days: 6 }).toISO(),
|
||
sourceBranch: 'somebranch',
|
||
sha: prSha,
|
||
},
|
||
'new title',
|
||
);
|
||
|
||
expect(pr).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('getBranchStatus()', () => {
|
||
it('should pass through success', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/commits/somebranch/status')
|
||
.reply(200, {
|
||
state: 'success',
|
||
})
|
||
.get('/repos/some/repo/commits/somebranch/check-runs?per_page=100')
|
||
.reply(200, []);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatus('somebranch', true);
|
||
expect(res).toBe('green');
|
||
});
|
||
|
||
it('should not consider internal statuses as success', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/commits/somebranch/status')
|
||
.reply(200, {
|
||
state: 'success',
|
||
statuses: [
|
||
{
|
||
context: 'renovate/stability-days',
|
||
state: 'success',
|
||
},
|
||
],
|
||
})
|
||
.get('/repos/some/repo/commits/somebranch/check-runs?per_page=100')
|
||
.reply(200, []);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatus('somebranch', false);
|
||
expect(res).toBe('yellow');
|
||
});
|
||
|
||
it('should pass through failed', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/commits/somebranch/status')
|
||
.reply(200, {
|
||
state: 'failure',
|
||
})
|
||
.get('/repos/some/repo/commits/somebranch/check-runs?per_page=100')
|
||
.reply(200, []);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatus('somebranch', true);
|
||
expect(res).toBe('red');
|
||
});
|
||
|
||
it('defaults to pending', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/commits/somebranch/status')
|
||
.reply(200, {
|
||
state: 'unknown',
|
||
})
|
||
.get('/repos/some/repo/commits/somebranch/check-runs?per_page=100')
|
||
.reply(200, []);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatus('somebranch', true);
|
||
expect(res).toBe('yellow');
|
||
});
|
||
|
||
it('should fail if a check run has failed', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/commits/somebranch/status')
|
||
.reply(200, {
|
||
state: 'pending',
|
||
statuses: [],
|
||
})
|
||
.get('/repos/some/repo/commits/somebranch/check-runs?per_page=100')
|
||
.reply(200, {
|
||
total_count: 2,
|
||
check_runs: [
|
||
{
|
||
id: 23950198,
|
||
status: 'completed',
|
||
conclusion: 'success',
|
||
name: 'Travis CI - Pull Request',
|
||
},
|
||
{
|
||
id: 23950195,
|
||
status: 'completed',
|
||
conclusion: 'failure',
|
||
name: 'Travis CI - Branch',
|
||
},
|
||
],
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatus('somebranch', true);
|
||
expect(res).toBe('red');
|
||
});
|
||
|
||
it('should succeed if no status and all passed check runs', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/commits/somebranch/status')
|
||
.reply(200, {
|
||
state: 'pending',
|
||
statuses: [],
|
||
})
|
||
.get('/repos/some/repo/commits/somebranch/check-runs?per_page=100')
|
||
.reply(200, {
|
||
total_count: 3,
|
||
check_runs: [
|
||
{
|
||
id: 2390199,
|
||
status: 'completed',
|
||
conclusion: 'skipped',
|
||
name: 'Conditional GitHub Action',
|
||
},
|
||
{
|
||
id: 23950198,
|
||
status: 'completed',
|
||
conclusion: 'success',
|
||
name: 'Travis CI - Pull Request',
|
||
},
|
||
{
|
||
id: 23950195,
|
||
status: 'completed',
|
||
conclusion: 'success',
|
||
name: 'Travis CI - Branch',
|
||
},
|
||
],
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatus('somebranch', true);
|
||
expect(res).toBe('green');
|
||
});
|
||
|
||
it('should fail if a check run is pending', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/commits/somebranch/status')
|
||
.reply(200, {
|
||
state: 'pending',
|
||
statuses: [],
|
||
})
|
||
.get('/repos/some/repo/commits/somebranch/check-runs?per_page=100')
|
||
.reply(200, {
|
||
total_count: 2,
|
||
check_runs: [
|
||
{
|
||
id: 23950198,
|
||
status: 'completed',
|
||
conclusion: 'success',
|
||
name: 'Travis CI - Pull Request',
|
||
},
|
||
{
|
||
id: 23950195,
|
||
status: 'pending',
|
||
name: 'Travis CI - Branch',
|
||
},
|
||
],
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatus('somebranch', true);
|
||
expect(res).toBe('yellow');
|
||
});
|
||
});
|
||
|
||
describe('getBranchStatusCheck', () => {
|
||
it('returns state if found', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(`/repos/some/repo/commits/${branchCommitSha}/statuses`)
|
||
.reply(200, [
|
||
{
|
||
context: 'context-1',
|
||
state: 'success',
|
||
},
|
||
{
|
||
context: 'context-2',
|
||
state: 'pending',
|
||
},
|
||
{
|
||
context: 'context-3',
|
||
state: 'failure',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatusCheck(
|
||
'renovate/future_branch',
|
||
'context-2',
|
||
);
|
||
expect(res).toBe('yellow');
|
||
});
|
||
|
||
it('returns null', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(`/repos/some/repo/commits/${branchCommitSha}/statuses`)
|
||
.reply(200, [
|
||
{
|
||
context: 'context-1',
|
||
state: 'success',
|
||
},
|
||
{
|
||
context: 'context-2',
|
||
state: 'pending',
|
||
},
|
||
{
|
||
context: 'context-3',
|
||
state: 'error',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatusCheck('somebranch', 'context-4');
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('returns yellow if state not present in context object', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(`/repos/some/repo/commits/${branchCommitSha}/statuses`)
|
||
.reply(200, [
|
||
{
|
||
context: 'context-1',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getBranchStatusCheck('somebranch', 'context-1');
|
||
expect(res).toBe('yellow');
|
||
});
|
||
});
|
||
|
||
describe('setBranchStatus', () => {
|
||
it('returns if already set', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(`/repos/some/repo/commits/${branchCommitSha}/statuses`)
|
||
.reply(200, [
|
||
{
|
||
context: 'some-context',
|
||
state: 'pending',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(
|
||
github.setBranchStatus({
|
||
branchName: 'some-branch',
|
||
context: 'some-context',
|
||
description: 'some-description',
|
||
state: 'yellow',
|
||
url: 'some-url',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
|
||
it('sets branch status', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(`/repos/some/repo/commits/${branchCommitSha}/statuses`)
|
||
.reply(200, [
|
||
{
|
||
context: 'context-1',
|
||
state: 'state-1',
|
||
},
|
||
{
|
||
context: 'context-2',
|
||
state: 'state-2',
|
||
},
|
||
{
|
||
context: 'context-3',
|
||
state: 'state-3',
|
||
},
|
||
])
|
||
.post(`/repos/some/repo/statuses/${branchCommitSha}`)
|
||
.reply(200)
|
||
.get('/repos/some/repo/commits/some-branch/status')
|
||
.reply(200, {})
|
||
.get(`/repos/some/repo/commits/${branchCommitSha}/statuses`)
|
||
.reply(200, {});
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(
|
||
github.setBranchStatus({
|
||
branchName: 'some-branch',
|
||
context: 'some-context',
|
||
description: 'some-description',
|
||
state: 'green',
|
||
url: 'some-url',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
});
|
||
|
||
describe('getIssue()', () => {
|
||
it('returns null if issues disabled', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo', { hasIssuesEnabled: false });
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getIssue(1);
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('returns issue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
const issue = {
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
};
|
||
scope
|
||
.get('/repos/some/repo/issues/1')
|
||
.reply(200, { ...issue, updated_at: '2022-01-01T00:00:00Z' });
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getIssue(1);
|
||
expect(res).toMatchObject({
|
||
...issue,
|
||
lastModified: '2022-01-01T00:00:00Z',
|
||
});
|
||
});
|
||
|
||
it('returns null if issue not found', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get('/repos/some/repo/issues/1').reply(404);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getIssue(1);
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('logs debug message if issue deleted', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get('/repos/some/repo/issues/1').reply(410);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getIssue(1);
|
||
expect(res).toBeNull();
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
'Issue #1 has been deleted',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('findIssue()', () => {
|
||
it('returns null if no issue', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const res = await github.findIssue('title-3');
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('finds issue', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.get('/repos/undefined/issues/2')
|
||
.reply(200, {
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
const res = await github.findIssue('title-2');
|
||
expect(res).toEqual({
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
lastModified: '2023-01-01T00:00:00Z',
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('ensureIssue()', () => {
|
||
it('propagates GraphQL rate limit errors', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.post('/graphql').reply(200, graphqlRateLimitResponse);
|
||
|
||
await expect(
|
||
github.ensureIssue({ title: 'new-title', body: 'new-content' }),
|
||
).rejects.toThrow(PLATFORM_RATE_LIMIT_EXCEEDED);
|
||
});
|
||
|
||
it('handles repositories with issues disabled', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: { hasNextPage: false, endCursor: null },
|
||
nodes: [],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.post('/repos/some/repo/issues')
|
||
.reply(410, { message: 'Issues are disabled for this repo' });
|
||
|
||
await expect(
|
||
github.ensureIssue({ title: 'new-title', body: 'new-content' }),
|
||
).resolves.toBeNull();
|
||
});
|
||
|
||
it('creates issue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.post('/repos/some/repo/issues')
|
||
.reply(200, {
|
||
number: 3,
|
||
state: 'open',
|
||
title: 'new-title',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
const res = await github.ensureIssue({
|
||
title: 'new-title',
|
||
body: 'new-content',
|
||
});
|
||
expect(res).toBe('created');
|
||
});
|
||
|
||
it('creates issue if not ensuring only once', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'closed',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.get('/repos/some/repo/issues/1')
|
||
.reply(404);
|
||
const res = await github.ensureIssue({
|
||
title: 'title-1',
|
||
body: 'new-content',
|
||
});
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('does not create issue if ensuring only once', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.post('/graphql').reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'closed',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const once = true;
|
||
const res = await github.ensureIssue({
|
||
title: 'title-1',
|
||
body: 'new-content',
|
||
once,
|
||
});
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('creates issue with labels', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.post('/repos/some/repo/issues')
|
||
.reply(200, {
|
||
number: 3,
|
||
state: 'open',
|
||
title: 'new-title',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
const res = await github.ensureIssue({
|
||
title: 'new-title',
|
||
body: 'new-content',
|
||
labels: ['Renovate', 'Maintenance'],
|
||
});
|
||
expect(res).toBe('created');
|
||
});
|
||
|
||
it('closes others if ensuring only once', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 3,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'closed',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.get('/repos/some/repo/issues/3')
|
||
.reply(404);
|
||
const once = true;
|
||
const res = await github.ensureIssue({
|
||
title: 'title-1',
|
||
body: 'new-content',
|
||
once,
|
||
});
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('updates issue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.get('/repos/some/repo/issues/2')
|
||
.reply(200, {
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
})
|
||
.patch('/repos/some/repo/issues/2')
|
||
.reply(200, {
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'newer-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
const res = await github.ensureIssue({
|
||
title: 'title-3',
|
||
reuseTitle: 'title-2',
|
||
body: 'newer-content',
|
||
});
|
||
expect(res).toBe('updated');
|
||
});
|
||
|
||
it('updates issue with labels', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.get('/repos/some/repo/issues/2')
|
||
.reply(200, {
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
})
|
||
.patch('/repos/some/repo/issues/2')
|
||
.reply(200, {
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'newer-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
const res = await github.ensureIssue({
|
||
title: 'title-3',
|
||
reuseTitle: 'title-2',
|
||
body: 'newer-content',
|
||
labels: ['Renovate', 'Maintenance'],
|
||
});
|
||
expect(res).toBe('updated');
|
||
});
|
||
|
||
it('skips update if unchanged', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'newer-content',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'new-content',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.get('/repos/some/repo/issues/2')
|
||
.reply(200, { body: 'newer-content' });
|
||
const res = await github.ensureIssue({
|
||
title: 'title-2',
|
||
body: 'newer-content',
|
||
});
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('deletes if duplicate', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.patch('/repos/some/repo/issues/1')
|
||
.reply(200);
|
||
const res = await github.ensureIssue({
|
||
title: 'title-1',
|
||
body: 'newer-content',
|
||
});
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('creates issue if reopen flag false and issue is not open', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'close',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.get('/repos/some/repo/issues/2')
|
||
.reply(200, {
|
||
number: 2,
|
||
state: 'closed',
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
})
|
||
.post('/repos/some/repo/issues')
|
||
.reply(200, {
|
||
number: 3,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
const res = await github.ensureIssue({
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
once: false,
|
||
shouldReOpen: false,
|
||
});
|
||
expect(res).toBe('created');
|
||
});
|
||
|
||
it('does not create issue if reopen flag false and issue is already open', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.get('/repos/some/repo/issues/2')
|
||
.reply(200, {
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
const res = await github.ensureIssue({
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
once: false,
|
||
shouldReOpen: false,
|
||
});
|
||
expect(res).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('ensureIssueClosing()', () => {
|
||
it('closes issue', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
{
|
||
number: 1,
|
||
state: 'open',
|
||
title: 'title-1',
|
||
body: 'body-1',
|
||
updatedAt: '2021-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.patch('/repos/undefined/issues/2')
|
||
.reply(200, {
|
||
number: 2,
|
||
state: 'closed',
|
||
title: 'title-2',
|
||
body: 'new-content',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
await expect(github.ensureIssueClosing('title-2')).toResolve();
|
||
});
|
||
|
||
it('swallows 410 Gone when the issue was deleted on the platform', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 2,
|
||
state: 'open',
|
||
title: 'title-2',
|
||
body: 'body-2',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.patch('/repos/undefined/issues/2')
|
||
.reply(410, { message: 'This issue was deleted' });
|
||
await expect(github.ensureIssueClosing('title-2')).toResolve();
|
||
});
|
||
|
||
it('swallows 404 Not Found when the issue was deleted on the platform', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 3,
|
||
state: 'open',
|
||
title: 'title-3',
|
||
body: 'body-3',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.patch('/repos/undefined/issues/3')
|
||
.reply(404, { message: 'Not Found' });
|
||
await expect(github.ensureIssueClosing('title-3')).toResolve();
|
||
});
|
||
|
||
it('rethrows non-deletion errors', async () => {
|
||
httpMock
|
||
.scope(githubApiHost)
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
issues: {
|
||
pageInfo: {
|
||
startCursor: null,
|
||
hasNextPage: false,
|
||
endCursor: null,
|
||
},
|
||
nodes: [
|
||
{
|
||
number: 4,
|
||
state: 'open',
|
||
title: 'title-4',
|
||
body: 'body-4',
|
||
updatedAt: '2022-01-01T00:00:00Z',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
})
|
||
.patch('/repos/undefined/issues/4')
|
||
.reply(500, { message: 'Internal Server Error' });
|
||
await expect(github.ensureIssueClosing('title-4')).toReject();
|
||
});
|
||
});
|
||
|
||
describe('deleteLabel(issueNo, label)', () => {
|
||
it('should delete the label', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.delete('/repos/some/repo/issues/42/labels/rebase').reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(github.deleteLabel(42, 'rebase')).toResolve();
|
||
});
|
||
});
|
||
|
||
describe('addAssignees(issueNo, assignees)', () => {
|
||
it('should add the given assignees to the issue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/repos/some/repo/issues/42/assignees').reply(200, {
|
||
number: 42,
|
||
state: 'open',
|
||
title: 'title-42',
|
||
body: 'body-42',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(
|
||
github.addAssignees(42, ['someuser', 'someotheruser']),
|
||
).toResolve();
|
||
});
|
||
|
||
it('should retry on 404 and succeed', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.post('/repos/some/repo/issues/42/assignees')
|
||
.reply(404)
|
||
.post('/repos/some/repo/issues/42/assignees')
|
||
.reply(200, {
|
||
number: 42,
|
||
state: 'open',
|
||
title: 'title-42',
|
||
body: 'body-42',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(
|
||
github.addAssignees(42, ['someuser', 'someotheruser']),
|
||
).toResolve();
|
||
});
|
||
|
||
it('should throw after 3 consecutive 404 responses', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/repos/some/repo/issues/42/assignees').times(3).reply(404);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(
|
||
github.addAssignees(42, ['someuser', 'someotheruser']),
|
||
).rejects.toThrow('Request failed with status code 404 (Not Found)');
|
||
});
|
||
|
||
it('should throw immediately on non-404 errors', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/repos/some/repo/issues/42/assignees').reply(422);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(
|
||
github.addAssignees(42, ['someuser', 'someotheruser']),
|
||
).rejects.toThrow('external-host-error');
|
||
});
|
||
});
|
||
|
||
describe('addReviewers(issueNo, reviewers)', () => {
|
||
it('should add the given reviewers to the PR', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/repos/some/repo/pulls/42/requested_reviewers').reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(
|
||
github.addReviewers(42, ['someuser', 'someotheruser', 'team:someteam']),
|
||
).toResolve();
|
||
});
|
||
});
|
||
|
||
describe('ensureComment', () => {
|
||
it('add comment if not found', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/issues/42/comments?per_page=100')
|
||
.reply(200, [])
|
||
.post('/repos/some/repo/issues/42/comments')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await expect(
|
||
github.ensureComment({
|
||
number: 42,
|
||
topic: 'some-subject',
|
||
content: 'some\ncontent',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
|
||
it('adds comment if found in closed PR list', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/issues/2499/comments?per_page=100')
|
||
.reply(200, [
|
||
{
|
||
id: 419928791,
|
||
body: '[](https://cla-assistant.io/renovatebot/renovate?pullRequest=2500) <br/>All committers have signed the CLA.',
|
||
},
|
||
{
|
||
id: 420006957,
|
||
body: ':tada: This PR is included in version 13.63.5 :tada:\n\nThe release is available on:\n- [npm package (@latest dist-tag)](https://www.npmjs.com/package/renovate)\n- [GitHub release](https://github.com/renovatebot/renovate/releases/tag/13.63.5)\n\nYour **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:',
|
||
},
|
||
])
|
||
.post('/repos/some/repo/issues/2499/comments')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await expect(
|
||
github.ensureComment({
|
||
number: 2499,
|
||
topic: 'some-subject',
|
||
content: 'some\ncontent',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
|
||
it('add updates comment if necessary', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/issues/42/comments?per_page=100')
|
||
.reply(200, [{ id: 1234, body: '### some-subject\n\nblablabla' }])
|
||
.patch('/repos/some/repo/issues/comments/1234')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await expect(
|
||
github.ensureComment({
|
||
number: 42,
|
||
topic: 'some-subject',
|
||
content: 'some\ncontent',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
|
||
it('skips comment', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/issues/42/comments?per_page=100')
|
||
.reply(200, [{ id: 1234, body: '### some-subject\n\nsome\ncontent' }]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await expect(
|
||
github.ensureComment({
|
||
number: 42,
|
||
topic: 'some-subject',
|
||
content: 'some\ncontent',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
|
||
it('handles comment with no description', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/issues/42/comments?per_page=100')
|
||
.reply(200, [{ id: 1234, body: '!merge' }]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await expect(
|
||
github.ensureComment({
|
||
number: 42,
|
||
topic: null,
|
||
content: '!merge',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
});
|
||
|
||
describe('ensureCommentRemoval', () => {
|
||
it('deletes comment by topic if found', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/issues/42/comments?per_page=100')
|
||
.reply(200, [{ id: 1234, body: '### some-subject\n\nblablabla' }])
|
||
.delete('/repos/some/repo/issues/comments/1234')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await expect(
|
||
github.ensureCommentRemoval({
|
||
type: 'by-topic',
|
||
number: 42,
|
||
topic: 'some-subject',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
|
||
it('deletes comment by content if found', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/issues/42/comments?per_page=100')
|
||
.reply(200, [{ id: 1234, body: 'some-content' }])
|
||
.delete('/repos/some/repo/issues/comments/1234')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await expect(
|
||
github.ensureCommentRemoval({
|
||
type: 'by-content',
|
||
number: 42,
|
||
content: 'some-content',
|
||
}),
|
||
).toResolve();
|
||
});
|
||
});
|
||
|
||
describe('findPr(branchName, prTitle, state)', () => {
|
||
it('finds PR by branch name', async () => {
|
||
const scope = httpMock
|
||
.scope(githubApiHost)
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 2,
|
||
head: {
|
||
ref: 'branch-a',
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
title: 'branch a pr',
|
||
state: 'open',
|
||
user: { login: 'not-me' },
|
||
},
|
||
{
|
||
number: 1,
|
||
head: {
|
||
ref: 'branch-a',
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
title: 'branch a pr',
|
||
state: 'open',
|
||
user: { login: 'me' },
|
||
},
|
||
]);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
renovateUsername: 'me',
|
||
});
|
||
|
||
const res = await github.findPr({ branchName: 'branch-a' });
|
||
|
||
expect(res).toMatchObject({
|
||
number: 1,
|
||
sourceBranch: 'branch-a',
|
||
});
|
||
});
|
||
|
||
it('finds PR with non-open state', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 1,
|
||
head: { ref: 'branch-a', repo: { full_name: 'some/repo' } },
|
||
title: 'branch a pr',
|
||
state: 'closed',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.findPr({
|
||
branchName: 'branch-a',
|
||
state: '!open',
|
||
});
|
||
|
||
expect(res).toMatchObject({
|
||
number: 1,
|
||
sourceBranch: 'branch-a',
|
||
state: 'closed',
|
||
});
|
||
});
|
||
|
||
it('skips PR with non-matching state', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 1,
|
||
head: { ref: 'branch-a', repo: { full_name: 'some/repo' } },
|
||
title: 'branch a pr',
|
||
state: 'closed',
|
||
closed_at: DateTime.now().minus({ days: 1 }).toISO(),
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.findPr({
|
||
branchName: 'branch-a',
|
||
state: 'open',
|
||
});
|
||
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('skips PRs from forks', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 1,
|
||
head: { ref: 'branch-a', repo: { full_name: 'other/repo' } },
|
||
title: 'branch a pr',
|
||
state: 'open',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.findPr({
|
||
branchName: 'branch-a',
|
||
state: 'open',
|
||
});
|
||
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('skips PR with non-matching title', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 1,
|
||
head: { ref: 'branch-a', repo: { full_name: 'some/repo' } },
|
||
title: 'foo',
|
||
state: 'closed',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.findPr({
|
||
branchName: 'branch-a',
|
||
prTitle: 'bar',
|
||
});
|
||
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('caches pr list', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 1,
|
||
head: { ref: 'branch-a', repo: { full_name: 'some/repo' } },
|
||
title: 'branch a pr',
|
||
state: 'open',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
let res = await github.findPr({ branchName: 'branch-a' });
|
||
|
||
expect(res).toBeDefined();
|
||
res = await github.findPr({
|
||
branchName: 'branch-a',
|
||
prTitle: 'branch a pr',
|
||
});
|
||
expect(res).toBeDefined();
|
||
res = await github.findPr({
|
||
branchName: 'branch-a',
|
||
prTitle: 'branch a pr',
|
||
state: 'open',
|
||
});
|
||
expect(res).toBeDefined();
|
||
res = await github.findPr({ branchName: 'branch-b' });
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('finds pr from other authors', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/pulls?head=some:branch&state=open')
|
||
.reply(200, [
|
||
{
|
||
number: 1,
|
||
head: { ref: 'branch-a', repo: { full_name: 'some/repo' } },
|
||
title: 'branch a pr',
|
||
state: 'open',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await expect(
|
||
github.findPr({
|
||
branchName: 'branch',
|
||
state: 'open',
|
||
includeOtherAuthors: true,
|
||
}),
|
||
).resolves.toMatchObject({
|
||
number: 1,
|
||
sourceBranch: 'branch-a',
|
||
sourceRepo: 'some/repo',
|
||
state: 'open',
|
||
title: 'branch a pr',
|
||
updated_at: undefined,
|
||
});
|
||
});
|
||
|
||
it('returns null if no pr found - (includeOtherAuthors)', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get('/repos/some/repo/pulls?head=some:branch&state=open')
|
||
.reply(200, []);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.findPr({
|
||
branchName: 'branch',
|
||
state: 'open',
|
||
includeOtherAuthors: true,
|
||
});
|
||
expect(pr).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('createPr()', () => {
|
||
it('should create and return a PR object', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.post('/repos/some/repo/pulls')
|
||
.reply(200, {
|
||
number: 123,
|
||
head: { repo: { full_name: 'some/repo' } },
|
||
})
|
||
.post('/repos/some/repo/issues/123/labels')
|
||
.reply(200, []);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.createPr({
|
||
sourceBranch: 'some-branch',
|
||
targetBranch: 'dev',
|
||
prTitle: 'The Title',
|
||
prBody: 'Hello world',
|
||
labels: ['deps', 'renovate'],
|
||
});
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should use defaultBranch', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/repos/some/repo/pulls').reply(200, {
|
||
number: 123,
|
||
head: { repo: { full_name: 'some/repo' } },
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.createPr({
|
||
sourceBranch: 'some-branch',
|
||
targetBranch: 'master',
|
||
prTitle: 'The Title',
|
||
prBody: 'Hello world',
|
||
labels: null,
|
||
});
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should create a draftPR if set in the settings', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/repos/some/repo/pulls').reply(200, {
|
||
number: 123,
|
||
head: { repo: { full_name: 'some/repo' }, ref: 'some-branch' },
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.createPr({
|
||
sourceBranch: 'some-branch',
|
||
targetBranch: 'master',
|
||
prTitle: 'PR draft',
|
||
prBody: 'This is a result of a draft',
|
||
labels: null,
|
||
draftPR: true,
|
||
});
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
describe('with forkToken', () => {
|
||
let scope: httpMock.Scope;
|
||
|
||
beforeEach(async () => {
|
||
scope = httpMock.scope(githubApiHost);
|
||
forkInitRepoMock(scope, 'some/repo', false);
|
||
scope.get('/user').reply(200, {
|
||
login: 'forked',
|
||
});
|
||
scope.post('/repos/some/repo/forks').reply(200, {
|
||
full_name: 'forked/repo',
|
||
default_branch: 'master',
|
||
});
|
||
|
||
await github.initRepo({
|
||
repository: 'some/repo',
|
||
forkToken: 'true',
|
||
forkCreation: true,
|
||
});
|
||
});
|
||
|
||
it('should allow maintainer edits if explicitly enabled via options', async () => {
|
||
scope
|
||
.post(
|
||
'/repos/some/repo/pulls',
|
||
// Ensure the `maintainer_can_modify` option is set in the REST API request.
|
||
(body) => body.maintainer_can_modify === true,
|
||
)
|
||
.reply(200, {
|
||
number: 123,
|
||
head: { repo: { full_name: 'some/repo' }, ref: 'some-branch' },
|
||
});
|
||
const pr = await github.createPr({
|
||
sourceBranch: 'some-branch',
|
||
targetBranch: 'main',
|
||
prTitle: 'PR title',
|
||
prBody: 'PR can be edited by maintainers.',
|
||
labels: null,
|
||
platformPrOptions: {
|
||
forkModeDisallowMaintainerEdits: false,
|
||
},
|
||
});
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should allow maintainer edits if not explicitly set', async () => {
|
||
scope
|
||
.post(
|
||
'/repos/some/repo/pulls',
|
||
// Ensure the `maintainer_can_modify` option is `false` in the REST API request.
|
||
(body) => body.maintainer_can_modify === true,
|
||
)
|
||
.reply(200, {
|
||
number: 123,
|
||
head: { repo: { full_name: 'some/repo' }, ref: 'some-branch' },
|
||
});
|
||
const pr = await github.createPr({
|
||
sourceBranch: 'some-branch',
|
||
targetBranch: 'main',
|
||
prTitle: 'PR title',
|
||
prBody: 'PR *cannot* be edited by maintainers.',
|
||
labels: null,
|
||
});
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should disallow maintainer edits if explicitly disabled', async () => {
|
||
scope
|
||
.post(
|
||
'/repos/some/repo/pulls',
|
||
// Ensure the `maintainer_can_modify` option is `false` in the REST API request.
|
||
(body) => body.maintainer_can_modify === false,
|
||
)
|
||
.reply(200, {
|
||
number: 123,
|
||
head: { repo: { full_name: 'some/repo' }, ref: 'some-branch' },
|
||
});
|
||
const pr = await github.createPr({
|
||
sourceBranch: 'some-branch',
|
||
targetBranch: 'main',
|
||
prTitle: 'PR title',
|
||
prBody: 'PR *cannot* be edited by maintainers.',
|
||
labels: null,
|
||
platformPrOptions: {
|
||
forkModeDisallowMaintainerEdits: true,
|
||
},
|
||
});
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
});
|
||
|
||
describe('automerge', () => {
|
||
const createdPrResp = {
|
||
number: 123,
|
||
node_id: 'abcd',
|
||
head: { repo: { full_name: 'some/repo' } },
|
||
};
|
||
|
||
const graphqlAutomergeResp = {
|
||
data: {
|
||
enablePullRequestAutoMerge: {
|
||
pullRequest: {
|
||
number: 123,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const graphqlAutomergeErrorResp = {
|
||
...graphqlAutomergeResp,
|
||
errors: [
|
||
{
|
||
type: 'UNPROCESSABLE',
|
||
message:
|
||
'Pull request is not in the correct state to enable auto-merge',
|
||
},
|
||
],
|
||
};
|
||
|
||
const graphqlAutomergeBody = {
|
||
query: enableAutoMergeMutation,
|
||
variables: {
|
||
pullRequestId: 'abcd',
|
||
mergeMethod: 'SQUASH',
|
||
},
|
||
};
|
||
|
||
const prConfig: CreatePRConfig = {
|
||
sourceBranch: 'some-branch',
|
||
targetBranch: 'dev',
|
||
prTitle: 'The Title',
|
||
prBody: 'Hello world',
|
||
labels: ['deps', 'renovate'],
|
||
platformPrOptions: { usePlatformAutomerge: true },
|
||
};
|
||
|
||
async function mockScope(repoOpts: any = {}): Promise<httpMock.Scope> {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo', repoOpts);
|
||
scope
|
||
.post('/repos/some/repo/pulls')
|
||
.reply(200, createdPrResp)
|
||
.post('/repos/some/repo/issues/123/labels')
|
||
.reply(200, []);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
return scope;
|
||
}
|
||
|
||
it('should skip automerge if disabled in repo settings', async () => {
|
||
await mockScope({ autoMergeAllowed: false });
|
||
|
||
const pr = await github.createPr(prConfig);
|
||
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should skip automerge if GHE <3.3.0', async () => {
|
||
const scope = httpMock
|
||
.scope('https://github.company.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.1.7' })
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, {})
|
||
.post('/repos/some/repo/pulls')
|
||
.reply(200, {
|
||
number: 123,
|
||
})
|
||
.post('/repos/some/repo/issues/123/labels')
|
||
.reply(200, []);
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initPlatform({
|
||
endpoint: 'https://github.company.com',
|
||
token: '123test',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await github.createPr(prConfig);
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
{ prNumber: 123 },
|
||
'GitHub-native automerge: not supported on this version of GHE. Use 3.3.0 or newer.',
|
||
);
|
||
});
|
||
|
||
it('should perform automerge if GHE >=3.3.0', async () => {
|
||
const scope = httpMock
|
||
.scope('https://github.company.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.3.5' })
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, {})
|
||
.post('/repos/some/repo/pulls')
|
||
.reply(200, {
|
||
number: 123,
|
||
node_id: 'abcd',
|
||
})
|
||
.post('/repos/some/repo/issues/123/labels')
|
||
.reply(200, []);
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.post('/graphql', graphqlAutomergeBody)
|
||
.reply(200, graphqlAutomergeResp);
|
||
await github.initPlatform({
|
||
endpoint: 'https://github.company.com',
|
||
token: '123test',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await github.createPr(prConfig);
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
'GitHub-native automerge: success...PrNo: 123',
|
||
);
|
||
});
|
||
|
||
it('should perform automerge if the GHES version is unknown', async () => {
|
||
const scope = httpMock
|
||
.scope('https://github.company.com')
|
||
.head('/')
|
||
.reply(200)
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, {})
|
||
.post('/repos/some/repo/pulls')
|
||
.reply(200, {
|
||
number: 123,
|
||
})
|
||
.post('/repos/some/repo/issues/123/labels')
|
||
.reply(200, [])
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
repository: {
|
||
defaultBranchRef: {
|
||
name: 'main',
|
||
},
|
||
nameWithOwner: 'some/repo',
|
||
autoMergeAllowed: true,
|
||
},
|
||
},
|
||
});
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initPlatform({
|
||
endpoint: 'https://github.company.com',
|
||
token: '123test',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await github.createPr(prConfig);
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
'GitHub-native automerge: success...PrNo: 123',
|
||
);
|
||
});
|
||
|
||
it('should set automatic merge', async () => {
|
||
const scope = await mockScope();
|
||
scope
|
||
.post('/graphql', graphqlAutomergeBody)
|
||
.reply(200, graphqlAutomergeResp);
|
||
|
||
const pr = await github.createPr(prConfig);
|
||
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should use the configured automerge strategy', async () => {
|
||
const scope = await mockScope();
|
||
scope
|
||
.post('/graphql', {
|
||
query: enableAutoMergeMutation,
|
||
variables: {
|
||
pullRequestId: 'abcd',
|
||
mergeMethod: 'REBASE',
|
||
},
|
||
})
|
||
.reply(200, graphqlAutomergeResp);
|
||
|
||
const pr = await github.createPr({
|
||
...prConfig,
|
||
platformPrOptions: {
|
||
usePlatformAutomerge: true,
|
||
automergeStrategy: 'rebase',
|
||
},
|
||
});
|
||
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should handle GraphQL errors', async () => {
|
||
const scope = await mockScope();
|
||
scope
|
||
.post('/graphql', graphqlAutomergeBody)
|
||
.reply(200, graphqlAutomergeErrorResp);
|
||
const pr = await github.createPr(prConfig);
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should handle REST API errors', async () => {
|
||
const scope = await mockScope();
|
||
scope.post('/graphql', graphqlAutomergeBody).reply(500);
|
||
const pr = await github.createPr(prConfig);
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should propagate GraphQL rate limit errors', async () => {
|
||
const scope = await mockScope();
|
||
scope.post('/graphql').reply(200, graphqlRateLimitResponse);
|
||
|
||
await expect(github.createPr(prConfig)).rejects.toThrow(
|
||
PLATFORM_RATE_LIMIT_EXCEEDED,
|
||
);
|
||
});
|
||
|
||
it('should pass commit message as commitHeadline and commitBody for squash merge', async () => {
|
||
const scope = await mockScope();
|
||
scope
|
||
.post('/graphql', {
|
||
query: enableAutoMergeMutation,
|
||
variables: {
|
||
pullRequestId: 'abcd',
|
||
mergeMethod: 'SQUASH',
|
||
commitHeadline: 'Update dependency foo to v1.2.3 (#123)',
|
||
},
|
||
})
|
||
.reply(200, graphqlAutomergeResp);
|
||
|
||
const pr = await github.createPr({
|
||
...prConfig,
|
||
platformPrOptions: {
|
||
usePlatformAutomerge: true,
|
||
automergeCommitMessage: 'Update dependency foo to v1.2.3',
|
||
},
|
||
});
|
||
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should pass commit message as commitHeadline and commitBody for merge commit', async () => {
|
||
const scope = await mockScope({
|
||
squashMergeAllowed: false,
|
||
mergeCommitAllowed: true,
|
||
});
|
||
scope
|
||
.post('/graphql', {
|
||
query: enableAutoMergeMutation,
|
||
variables: {
|
||
pullRequestId: 'abcd',
|
||
mergeMethod: 'MERGE',
|
||
commitHeadline: 'Update dependency foo to v1.2.3 (#123)',
|
||
},
|
||
})
|
||
.reply(200, graphqlAutomergeResp);
|
||
|
||
const pr = await github.createPr({
|
||
...prConfig,
|
||
platformPrOptions: {
|
||
usePlatformAutomerge: true,
|
||
automergeCommitMessage: 'Update dependency foo to v1.2.3',
|
||
},
|
||
});
|
||
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should pass multi-line commit message body for squash merge', async () => {
|
||
const scope = await mockScope();
|
||
scope
|
||
.post('/graphql', {
|
||
query: enableAutoMergeMutation,
|
||
variables: {
|
||
pullRequestId: 'abcd',
|
||
mergeMethod: 'SQUASH',
|
||
commitHeadline: 'Update dependency foo to v1.2.3 (#123)',
|
||
commitBody: 'Some commit body',
|
||
},
|
||
})
|
||
.reply(200, graphqlAutomergeResp);
|
||
|
||
const pr = await github.createPr({
|
||
...prConfig,
|
||
platformPrOptions: {
|
||
usePlatformAutomerge: true,
|
||
automergeCommitMessage:
|
||
'Update dependency foo to v1.2.3\n\nSome commit body',
|
||
},
|
||
});
|
||
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
|
||
it('should not pass commit message headline/body for rebase merge', async () => {
|
||
const scope = await mockScope({
|
||
squashMergeAllowed: false,
|
||
mergeCommitAllowed: false,
|
||
rebaseMergeAllowed: true,
|
||
});
|
||
scope
|
||
.post('/graphql', {
|
||
query: enableAutoMergeMutation,
|
||
variables: {
|
||
pullRequestId: 'abcd',
|
||
mergeMethod: 'REBASE',
|
||
},
|
||
})
|
||
.reply(200, graphqlAutomergeResp);
|
||
|
||
const pr = await github.createPr({
|
||
...prConfig,
|
||
platformPrOptions: {
|
||
usePlatformAutomerge: true,
|
||
automergeCommitMessage:
|
||
'Update dependency foo to v1.2.3\n\nSome commit body',
|
||
},
|
||
});
|
||
|
||
expect(pr).toMatchObject({ number: 123 });
|
||
});
|
||
});
|
||
|
||
describe('milestone', () => {
|
||
it('should set the milestone on the PR', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.post(
|
||
'/repos/some/repo/pulls',
|
||
(body) => body.title === 'bump someDep to v2',
|
||
)
|
||
.reply(200, {
|
||
number: 123,
|
||
head: { repo: { full_name: 'some/repo' }, ref: 'some-branch' },
|
||
});
|
||
scope
|
||
.patch('/repos/some/repo/issues/123', (body) => body.milestone === 1)
|
||
.reply(200, {
|
||
number: 123,
|
||
state: 'open',
|
||
title: 'bump someDep to v2',
|
||
body: 'many informations about someDep',
|
||
updated_at: '2023-01-01T00:00:00Z',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.createPr({
|
||
targetBranch: 'main',
|
||
sourceBranch: 'renovate/someDep-v2',
|
||
prTitle: 'bump someDep to v2',
|
||
prBody: 'many informations about someDep',
|
||
milestone: 1,
|
||
});
|
||
expect(pr?.number).toBe(123);
|
||
});
|
||
|
||
it('should log a warning but not throw on error', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.post(
|
||
'/repos/some/repo/pulls',
|
||
(body) => body.title === 'bump someDep to v2',
|
||
)
|
||
.reply(200, {
|
||
number: 123,
|
||
head: { repo: { full_name: 'some/repo' }, ref: 'some-branch' },
|
||
});
|
||
scope
|
||
.patch('/repos/some/repo/issues/123', (body) => body.milestone === 1)
|
||
.reply(422, {
|
||
message: 'Validation Failed',
|
||
errors: [
|
||
{
|
||
value: 1,
|
||
resource: 'Issue',
|
||
field: 'milestone',
|
||
code: 'invalid',
|
||
},
|
||
],
|
||
documentation_url:
|
||
'https://docs.github.com/rest/issues/issues#update-an-issue',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.createPr({
|
||
targetBranch: 'main',
|
||
sourceBranch: 'renovate/someDep-v2',
|
||
prTitle: 'bump someDep to v2',
|
||
prBody: 'many informations about someDep',
|
||
milestone: 1,
|
||
});
|
||
expect(pr?.number).toBe(123);
|
||
|
||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||
{
|
||
err: {
|
||
message: 'Validation Failed',
|
||
errors: [
|
||
{
|
||
value: 1,
|
||
resource: 'Issue',
|
||
field: 'milestone',
|
||
code: 'invalid',
|
||
},
|
||
],
|
||
documentation_url:
|
||
'https://docs.github.com/rest/issues/issues#update-an-issue',
|
||
},
|
||
milestone: 1,
|
||
pr: 123,
|
||
},
|
||
'Unable to add milestone to PR',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('getPr(prNo)', () => {
|
||
it('should return null if no prNo is passed', async () => {
|
||
const pr = await github.getPr(0);
|
||
expect(pr).toBeNull();
|
||
});
|
||
|
||
it('should return PR', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 2499,
|
||
head: {
|
||
ref: 'renovate/delay-4.x',
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
title: 'build(deps): update dependency delay to v4.0.1',
|
||
state: 'closed',
|
||
updated_at: '01-09-2022',
|
||
},
|
||
{
|
||
number: 2500,
|
||
head: {
|
||
ref: 'renovate/jest-monorepo',
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
state: 'open',
|
||
title: 'chore(deps): update dependency jest to v23.6.0',
|
||
updated_at: '01-09-2022',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.getPr(2500);
|
||
expect(pr).toBeDefined();
|
||
expect(pr).toMatchObject({
|
||
number: 2500,
|
||
bodyStruct: { hash: expect.any(String) },
|
||
sourceBranch: 'renovate/jest-monorepo',
|
||
sourceRepo: 'some/repo',
|
||
state: 'open',
|
||
title: 'chore(deps): update dependency jest to v23.6.0',
|
||
updated_at: '01-09-2022',
|
||
});
|
||
});
|
||
|
||
it('should return closed PR', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 2500,
|
||
head: {
|
||
ref: 'renovate/jest-monorepo',
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
title: 'chore(deps): update dependency jest to v23.6.0',
|
||
state: 'closed',
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const pr = await github.getPr(2500);
|
||
|
||
expect(pr).toMatchObject({ number: 2500, state: 'closed' });
|
||
});
|
||
|
||
it('should return merged PR', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 2500,
|
||
head: {
|
||
ref: 'renovate/jest-monorepo',
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
title: 'chore(deps): update dependency jest to v23.6.0',
|
||
state: 'closed',
|
||
merged_at: DateTime.now().toISO(),
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const pr = await github.getPr(2500);
|
||
|
||
expect(pr).toMatchObject({ number: 2500, state: 'merged' });
|
||
});
|
||
|
||
it('should refresh a cached PR', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.get('/repos/some/repo/pulls/2500').reply(200, {
|
||
number: 2500,
|
||
head: {
|
||
ref: 'renovate/jest-monorepo',
|
||
repo: { full_name: 'some/repo' },
|
||
},
|
||
title: 'chore(deps): update dependency jest to v23.6.0',
|
||
state: 'closed',
|
||
merged_at: DateTime.now().toISO(),
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const pr = await github.getPr(2500, true);
|
||
|
||
expect(pr).toMatchObject({ number: 2500, state: 'merged' });
|
||
});
|
||
|
||
it('should return null if no PR is returned from GitHub', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [])
|
||
.get('/repos/some/repo/pulls/1234')
|
||
.reply(404);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.getPr(1234);
|
||
expect(pr).toBeNull();
|
||
});
|
||
|
||
it(`should return a PR object - 0`, async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [])
|
||
.get('/repos/some/repo/pulls/1234')
|
||
.reply(200, {
|
||
number: 1234,
|
||
state: 'closed',
|
||
base: { sha: 'abc' },
|
||
head: { sha: 'def', ref: 'some/branch' },
|
||
merged_at: 'sometime',
|
||
title: 'Some title',
|
||
labels: [{ name: 'foo' }, { name: 'bar' }],
|
||
assignee: { login: 'foobar' },
|
||
created_at: '01-01-2022',
|
||
updated_at: '01-09-2022',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.getPr(1234);
|
||
expect(pr).toMatchObject({ number: 1234, state: 'merged' });
|
||
});
|
||
|
||
it(`should return a PR object - 1`, async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [])
|
||
.get('/repos/some/repo/pulls/1234')
|
||
.reply(200, {
|
||
number: 1234,
|
||
state: 'open',
|
||
mergeable_state: 'dirty',
|
||
base: { sha: '1234' },
|
||
head: { ref: 'some/branch' },
|
||
commits: 1,
|
||
title: 'Some title',
|
||
assignees: [{ login: 'foo' }],
|
||
requested_reviewers: [{ login: 'bar' }],
|
||
updated_at: '01-09-2022',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.getPr(1234);
|
||
expect(pr).toMatchObject({
|
||
bodyStruct: {
|
||
hash: expect.any(String),
|
||
},
|
||
hasAssignees: true,
|
||
number: 1234,
|
||
sourceBranch: 'some/branch',
|
||
state: 'open',
|
||
title: 'Some title',
|
||
updated_at: '01-09-2022',
|
||
});
|
||
});
|
||
|
||
it(`should return a PR object - 2`, async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [])
|
||
.get('/repos/some/repo/pulls/1234')
|
||
.reply(200, {
|
||
number: 1234,
|
||
state: 'open',
|
||
base: { sha: '5678' },
|
||
head: { ref: 'some/branch' },
|
||
commits: 1,
|
||
title: 'Some title',
|
||
updated_at: '01-09-2022',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = await github.getPr(1234);
|
||
expect(pr).toMatchObject({
|
||
bodyStruct: {
|
||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||
},
|
||
number: 1234,
|
||
sourceBranch: 'some/branch',
|
||
state: 'open',
|
||
title: 'Some title',
|
||
updated_at: '01-09-2022',
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('assertPrNotInMergeQueue()', () => {
|
||
const prList = [
|
||
{
|
||
number: 1234,
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
state: 'open',
|
||
title: 'PR title',
|
||
updated_at: '01-09-2022',
|
||
node_id: 'abcd',
|
||
},
|
||
];
|
||
|
||
function prListMock(scope: httpMock.Scope, prs: unknown[] = prList): void {
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, prs);
|
||
}
|
||
|
||
function mergeQueueEnabledMock(
|
||
scope: httpMock.Scope,
|
||
mergeQueue: { id: string } | null = { id: 'queue' },
|
||
): void {
|
||
scope.post('/graphql').reply(200, {
|
||
data: {
|
||
repository: { mergeQueue },
|
||
},
|
||
});
|
||
}
|
||
|
||
function mergeQueueCheckMock(
|
||
scope: httpMock.Scope,
|
||
isInMergeQueue = false,
|
||
): void {
|
||
scope.post('/graphql').reply(200, {
|
||
data: {
|
||
repository: {
|
||
pullRequest: { isInMergeQueue },
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
it('does nothing if merge queue is not enabled for the base branch', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
mergeQueueEnabledMock(scope, null);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).toResolve();
|
||
|
||
// cached, no new request
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).toResolve();
|
||
});
|
||
|
||
it('defaults to the default branch when no base branch is given', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
// default branch merge queue status is cached during initRepo
|
||
await expect(github.assertPrNotInMergeQueue('somebranch')).toResolve();
|
||
});
|
||
|
||
it('uses merge queue status fetched during initRepo for the default branch', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo', { mergeQueue: { id: 'queue' } });
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
prListMock(scope);
|
||
mergeQueueCheckMock(scope, true);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'master'),
|
||
).rejects.toThrow(PR_ALREADY_IN_MERGE_QUEUE);
|
||
});
|
||
|
||
it('does nothing if there is no open PR for the branch', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
mergeQueueEnabledMock(scope);
|
||
prListMock(scope, []);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).toResolve();
|
||
});
|
||
|
||
it('does nothing if the PR is not in the merge queue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
mergeQueueEnabledMock(scope);
|
||
prListMock(scope);
|
||
mergeQueueCheckMock(scope);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).toResolve();
|
||
});
|
||
|
||
it('throws if the PR is in the merge queue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
mergeQueueEnabledMock(scope);
|
||
prListMock(scope);
|
||
mergeQueueCheckMock(scope, true);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).rejects.toThrow(PR_ALREADY_IN_MERGE_QUEUE);
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
'PR #1234 is in the merge queue - aborting push',
|
||
);
|
||
});
|
||
|
||
it('assumes merge queue is enabled if the base branch check returns errors', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, { errors: [{ message: 'some error' }] });
|
||
prListMock(scope);
|
||
mergeQueueCheckMock(scope, true);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).rejects.toThrow(PR_ALREADY_IN_MERGE_QUEUE);
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
{ baseBranch: 'main', errors: [{ message: 'some error' }] },
|
||
'Failed to fetch merge queue status - assuming merge queue is enabled',
|
||
);
|
||
});
|
||
|
||
it('assumes merge queue is enabled if the base branch check fails', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.post('/graphql').reply(500);
|
||
prListMock(scope);
|
||
mergeQueueCheckMock(scope, true);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).rejects.toThrow(PR_ALREADY_IN_MERGE_QUEUE);
|
||
});
|
||
|
||
it('propagates rate limits from the base branch check', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.post('/graphql').reply(200, graphqlRateLimitResponse);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).rejects.toThrow(PLATFORM_RATE_LIMIT_EXCEEDED);
|
||
});
|
||
|
||
it('logs if the merge queue check returns errors', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
mergeQueueEnabledMock(scope);
|
||
prListMock(scope);
|
||
scope
|
||
.post('/graphql')
|
||
.reply(200, { errors: [{ message: 'some error' }] });
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).toResolve();
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
{ prNo: 1234, errors: [{ message: 'some error' }] },
|
||
'Failed to fetch PR merge queue status',
|
||
);
|
||
});
|
||
|
||
it('ignores merge queue check failures', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
mergeQueueEnabledMock(scope);
|
||
prListMock(scope);
|
||
scope.post('/graphql').reply(500);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).toResolve();
|
||
});
|
||
|
||
it('propagates rate limits from the PR merge queue check', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
mergeQueueEnabledMock(scope);
|
||
prListMock(scope);
|
||
scope.post('/graphql').reply(200, graphqlRateLimitResponse);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).rejects.toThrow(PLATFORM_RATE_LIMIT_EXCEEDED);
|
||
});
|
||
|
||
it('skips merge queue check on GHE <3.12.0', async () => {
|
||
const scope = httpMock
|
||
.scope('https://github.company.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.11.0' })
|
||
.get('/user')
|
||
.reply(200, { login: 'renovate-bot' })
|
||
.get('/user/emails')
|
||
.reply(200, {});
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initPlatform({
|
||
endpoint: 'https://github.company.com',
|
||
token: '123test',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).toResolve();
|
||
});
|
||
|
||
it('checks the merge queue if the GHES version is unknown', async () => {
|
||
const scope = httpMock
|
||
.scope('https://github.company.com')
|
||
.head('/')
|
||
.reply(200)
|
||
.get('/user')
|
||
.reply(200, { login: 'renovate-bot' })
|
||
.get('/user/emails')
|
||
.reply(200, {});
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initPlatform({
|
||
endpoint: 'https://github.company.com',
|
||
token: '123test',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
mergeQueueEnabledMock(scope, null);
|
||
|
||
await expect(
|
||
github.assertPrNotInMergeQueue('somebranch', 'main'),
|
||
).toResolve();
|
||
});
|
||
});
|
||
|
||
describe('updatePr(prNo, title, body)', () => {
|
||
it('should update the PR', async () => {
|
||
const pr: UpdatePrConfig = {
|
||
number: 1234,
|
||
prTitle: 'The New Title',
|
||
prBody: 'Hello world again',
|
||
};
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.patch('/repos/some/repo/pulls/1234').reply(200, pr);
|
||
|
||
await expect(github.updatePr(pr)).toResolve();
|
||
});
|
||
|
||
it('should update and close the PR', async () => {
|
||
const pr: UpdatePrConfig = {
|
||
number: 1234,
|
||
prTitle: 'The New Title',
|
||
prBody: 'Hello world again',
|
||
state: 'closed',
|
||
};
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.patch('/repos/some/repo/pulls/1234').reply(200, pr);
|
||
|
||
await expect(github.updatePr(pr)).toResolve();
|
||
});
|
||
|
||
it('should update target branch', async () => {
|
||
const pr: UpdatePrConfig = {
|
||
number: 1234,
|
||
prTitle: 'The New Title',
|
||
prBody: 'Hello world again',
|
||
state: 'closed',
|
||
targetBranch: 'new_base',
|
||
};
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.patch('/repos/some/repo/pulls/1234').reply(200, pr);
|
||
|
||
await expect(github.updatePr(pr)).toResolve();
|
||
});
|
||
|
||
it('should add and remove labels', async () => {
|
||
const pr: UpdatePrConfig = {
|
||
number: 1234,
|
||
prTitle: 'The New Title',
|
||
prBody: 'Hello world again',
|
||
state: 'closed',
|
||
targetBranch: 'new_base',
|
||
addLabels: ['new_label'],
|
||
removeLabels: ['old_label'],
|
||
};
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.patch('/repos/some/repo/pulls/1234')
|
||
.reply(200, {
|
||
number: 91,
|
||
base: { sha: '1234' },
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
state: 'open',
|
||
title: 'old title',
|
||
updated_at: '01-09-2022',
|
||
})
|
||
.post('/repos/some/repo/issues/1234/labels')
|
||
.reply(200, pr)
|
||
.delete('/repos/some/repo/issues/1234/labels/old_label')
|
||
.reply(200, pr);
|
||
|
||
await expect(github.updatePr(pr)).toResolve();
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
`Adding labels 'new_label' to #1234`,
|
||
);
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
`Deleting label old_label from #1234`,
|
||
);
|
||
});
|
||
|
||
describe('addLabels', () => {
|
||
it('warns if adding labels failed', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
scope.post('/repos/undefined/issues/2/labels').reply(400, {
|
||
message: 'Failed to add labels',
|
||
});
|
||
await expect(github.addLabels(2, ['fail'])).toResolve();
|
||
|
||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||
{
|
||
err: expect.any(Object),
|
||
issueNo: 2,
|
||
labels: ['fail'],
|
||
},
|
||
'Error while adding labels. Skipping',
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('reattemptPlatformAutomerge(number, platformPrOptions)', () => {
|
||
const getPrListResp = [
|
||
{
|
||
number: 1234,
|
||
base: { sha: '1234' },
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
state: 'open',
|
||
title: 'Some PR',
|
||
},
|
||
];
|
||
const getPrResp = {
|
||
number: 123,
|
||
node_id: 'abcd',
|
||
head: { repo: { full_name: 'some/repo' } },
|
||
};
|
||
|
||
const graphqlAutomergeResp = {
|
||
data: {
|
||
enablePullRequestAutoMerge: {
|
||
pullRequest: {
|
||
number: 123,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const pr: ReattemptPlatformAutomergeConfig = {
|
||
number: 123,
|
||
platformPrOptions: { usePlatformAutomerge: true },
|
||
};
|
||
|
||
async function mockScope(repoOpts: any = {}): Promise<httpMock.Scope> {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo', repoOpts);
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, getPrListResp);
|
||
scope.get('/repos/some/repo/pulls/123').reply(200, getPrResp);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
return scope;
|
||
}
|
||
|
||
it('should set automatic merge', async () => {
|
||
const scope = await mockScope();
|
||
scope
|
||
.post('/graphql', {
|
||
query: enableAutoMergeMutation,
|
||
variables: {
|
||
pullRequestId: 'abcd',
|
||
mergeMethod: 'SQUASH',
|
||
},
|
||
})
|
||
.reply(200, graphqlAutomergeResp);
|
||
|
||
await expect(github.reattemptPlatformAutomerge(pr)).toResolve();
|
||
|
||
expect(logger.logger.debug).toHaveBeenLastCalledWith(
|
||
'PR platform automerge re-attempted...prNo: 123',
|
||
);
|
||
});
|
||
|
||
it('handles unknown error', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.replyWithError('unknown error');
|
||
|
||
await expect(github.reattemptPlatformAutomerge(pr)).toResolve();
|
||
|
||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||
{
|
||
err: new ExternalHostError(expect.any(RequestError), 'github'),
|
||
},
|
||
'Error re-attempting PR platform automerge',
|
||
);
|
||
});
|
||
|
||
it('propagates GraphQL rate limit errors', async () => {
|
||
const scope = await mockScope();
|
||
scope.post('/graphql').reply(200, graphqlRateLimitResponse);
|
||
|
||
await expect(github.reattemptPlatformAutomerge(pr)).rejects.toThrow(
|
||
PLATFORM_RATE_LIMIT_EXCEEDED,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('mergePr(prNo)', () => {
|
||
it('should merge the PR', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
number: 1234,
|
||
base: { sha: '1234' },
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
state: 'open',
|
||
title: 'Some PR',
|
||
},
|
||
])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const prBefore = await github.getPr(1234); // fetched remotely
|
||
const mergeResult = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
const prAfter = await github.getPr(1234); // obtained from cache
|
||
|
||
expect(mergeResult).toBeTrue();
|
||
expect(prBefore?.state).toBe('open');
|
||
expect(prAfter?.state).toBe('merged');
|
||
});
|
||
|
||
it('should handle merge error', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1234);
|
||
scope
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.replyWithError('merge error');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = {
|
||
number: 1234,
|
||
head: {
|
||
ref: 'someref',
|
||
},
|
||
};
|
||
await expect(
|
||
github.mergePr({
|
||
branchName: '',
|
||
id: pr.number,
|
||
}),
|
||
).resolves.toBeFalse();
|
||
});
|
||
|
||
it('should handle merge block', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1234);
|
||
scope
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Required status check "build" is expected.' });
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = {
|
||
number: 1234,
|
||
head: {
|
||
ref: 'someref',
|
||
},
|
||
};
|
||
await expect(
|
||
github.mergePr({
|
||
branchName: '',
|
||
id: pr.number,
|
||
strategy: 'merge-commit', // for coverage - has no effect on this test
|
||
}),
|
||
).resolves.toBeFalse();
|
||
});
|
||
|
||
it.each([
|
||
'Waiting on code owner review from org/team.',
|
||
'At least 1 approving review is required by reviewers with write access',
|
||
'Repository rule violations found\n\nNew changes require approval from someone other than the last pusher.\n\n',
|
||
])('should handle approvers required: %j', async (message) => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1234);
|
||
scope.put('/repos/some/repo/pulls/1234/merge').reply(405, {
|
||
message,
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = {
|
||
number: 1234,
|
||
head: {
|
||
ref: 'someref',
|
||
},
|
||
};
|
||
await expect(
|
||
github.mergePr({
|
||
branchName: '',
|
||
id: pr.number,
|
||
strategy: 'auto', // for coverage -- has not effect on this test
|
||
}),
|
||
).resolves.toBeFalse();
|
||
});
|
||
|
||
it('should warn if automergeStrategy is not supported', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1234);
|
||
scope.put('/repos/some/repo/pulls/1234/merge').reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const mergeResult = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
strategy: 'fast-forward',
|
||
});
|
||
|
||
expect(mergeResult).toBeTrue();
|
||
|
||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||
'Fast-forward merge strategy is not supported by Github. Falling back to merge strategy set for the repository.',
|
||
);
|
||
});
|
||
|
||
it('should use configured automergeStrategy', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1234);
|
||
scope.put('/repos/some/repo/pulls/1234/merge').reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const mergeResult = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
strategy: 'rebase',
|
||
});
|
||
|
||
expect(mergeResult).toBeTrue();
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
{
|
||
options: {
|
||
body: { merge_method: 'rebase' },
|
||
},
|
||
url: 'repos/some/repo/pulls/1234/merge',
|
||
},
|
||
'mergePr',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('mergePr(prNo) - merge queue', () => {
|
||
const pullsListItem = {
|
||
number: 1234,
|
||
node_id: 'abcd',
|
||
head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
|
||
base: { ref: 'main' },
|
||
state: 'open',
|
||
title: 'Some PR',
|
||
updated_at: '01-09-2022',
|
||
};
|
||
|
||
function mergeQueueMock(
|
||
scope: httpMock.Scope,
|
||
mergeQueue: { id: string } | null,
|
||
): void {
|
||
initRepoMock(scope, 'some/repo');
|
||
scope.post('/graphql').reply(200, {
|
||
data: { repository: { mergeQueue } },
|
||
});
|
||
}
|
||
|
||
it('should merge directly if the branch has a merge queue and the merge succeeds (bypass)', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeTrue();
|
||
// The PR is merged, so it must be cached as merged
|
||
await expect(github.getPr(1234)).resolves.toMatchObject({
|
||
number: 1234,
|
||
state: 'merged',
|
||
});
|
||
});
|
||
|
||
it('should add PR to the merge queue when the direct merge is refused', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
mergeQueueMock(scope, { id: 'MQ_kwDOBJLedM0dmQ' });
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, {
|
||
message:
|
||
'At least 1 approving review is required by reviewers with write access.',
|
||
})
|
||
.post('/graphql', {
|
||
query: enqueuePullRequestMutation,
|
||
variables: { pullRequestId: 'abcd' },
|
||
})
|
||
.reply(200, {
|
||
data: {
|
||
enqueuePullRequest: {
|
||
mergeQueueEntry: { id: 'MQE_1', position: 1 },
|
||
},
|
||
},
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeTrue();
|
||
// The PR is not merged yet, so it must not be cached as merged
|
||
await expect(github.getPr(1234)).resolves.toMatchObject({
|
||
number: 1234,
|
||
state: 'open',
|
||
});
|
||
});
|
||
|
||
it('should return true if the PR is already in the merge queue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
mergeQueueMock(scope, { id: 'MQ_kwDOBJLedM0dmQ' });
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, {
|
||
message:
|
||
'At least 1 approving review is required by reviewers with write access.',
|
||
})
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
errors: [
|
||
{
|
||
type: 'UNPROCESSABLE',
|
||
message: 'The pull request is already enqueued',
|
||
},
|
||
],
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should return false if adding to the merge queue fails', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
mergeQueueMock(scope, { id: 'MQ_kwDOBJLedM0dmQ' });
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, {
|
||
message:
|
||
'At least 1 approving review is required by reviewers with write access.',
|
||
})
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
errors: [
|
||
{
|
||
type: 'UNPROCESSABLE',
|
||
message: 'Pull request is in unstable status',
|
||
},
|
||
],
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should return false on merge queue request error', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
mergeQueueMock(scope, { id: 'MQ_kwDOBJLedM0dmQ' });
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, {
|
||
message:
|
||
'At least 1 approving review is required by reviewers with write access.',
|
||
})
|
||
.post('/graphql')
|
||
.replyWithError('unknown error');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeFalse();
|
||
expect(logger.logger.warn).toHaveBeenCalledWith(
|
||
{ prNumber: 1234, err: expect.any(Error) },
|
||
'Failed to add PR to the merge queue',
|
||
);
|
||
});
|
||
|
||
it('should return false if the direct merge is refused and the branch has no merge queue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
mergeQueueMock(scope, null);
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, {
|
||
message:
|
||
'At least 1 approving review is required by reviewers with write access.',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should add PR to the merge queue when the direct merge is refused with an unrecognized message', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
mergeQueueMock(scope, { id: 'MQ_kwDOBJLedM0dmQ' });
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Pull request is not mergeable' })
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Pull request is not mergeable' })
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Pull request is not mergeable' })
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Pull request is not mergeable' })
|
||
.post('/graphql')
|
||
.reply(200, {
|
||
data: {
|
||
enqueuePullRequest: {
|
||
mergeQueueEntry: { id: 'MQE_1', position: 1 },
|
||
},
|
||
},
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
// The unrecognized refusal falls through method guessing before the
|
||
// merge queue fallback
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should return false when the direct merge is refused with an unrecognized message and the branch has no merge queue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
mergeQueueMock(scope, null);
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Pull request is not mergeable' })
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Pull request is not mergeable' })
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Pull request is not mergeable' })
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(405, { message: 'Pull request is not mergeable' });
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeFalse();
|
||
});
|
||
|
||
it('should merge directly if the PR cannot be found', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [])
|
||
.get('/repos/some/repo/pulls/1234')
|
||
.reply(404)
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeTrue();
|
||
});
|
||
|
||
it('should merge directly if the branch has no merge queue', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
|
||
)
|
||
.reply(200, [pullsListItem])
|
||
.put('/repos/some/repo/pulls/1234/merge')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.mergePr({
|
||
id: 1234,
|
||
branchName: 'somebranch',
|
||
});
|
||
|
||
expect(res).toBeTrue();
|
||
});
|
||
});
|
||
|
||
describe('massageMarkdown(input)', () => {
|
||
it('returns updated pr body', () => {
|
||
const input =
|
||
'https://github.com/foo/bar/issues/5 plus also [a link](https://github.com/foo/bar/issues/5)';
|
||
expect(github.massageMarkdown(input)).toBe(
|
||
'[https://github.com/foo/bar/issues/5](https://redirect.github.com/foo/bar/issues/5) plus also [a link](https://redirect.github.com/foo/bar/issues/5)',
|
||
);
|
||
});
|
||
|
||
it.each`
|
||
input | expected
|
||
${'> ℹ **Note**\n>\n> Some note text\n'} | ${'> [!NOTE]\n> Some note text\n'}
|
||
${'> ℹ️ **Note**\n> \n> Some note text\n'} | ${'> [!NOTE]\n> Some note text\n'}
|
||
${'> ⚠ **Warning**\n>\n> Some warning text\n'} | ${'> [!WARNING]\n> Some warning text\n'}
|
||
${'> ⚠️ **Warning**\n> \n> Some warning text\n'} | ${'> [!WARNING]\n> Some warning text\n'}
|
||
${'> 🛑 **Caution**\n>\n> Some caution text\n'} | ${'> [!CAUTION]\n> Some caution text\n'}
|
||
${'> ❗ **Important**\n> \n> Some important text\n'} | ${'> [!IMPORTANT]\n> Some important text\n'}
|
||
`('converts $input to GitHub alert syntax', ({ input, expected }) => {
|
||
expect(github.massageMarkdown(input)).toBe(expected);
|
||
});
|
||
|
||
it('converts the notices added by smartTruncate() to GitHub alert syntax', () => {
|
||
const input = 'x'.repeat(github.maxBodyLength() + 1000);
|
||
|
||
const result = github.massageMarkdown(input);
|
||
|
||
expect(result).toContain(
|
||
'> [!NOTE]\n> This PR body was truncated due to platform limits.\n\n',
|
||
);
|
||
expect(result).toContain(
|
||
'> [!IMPORTANT]\n> ✂ PR body was truncated to here.',
|
||
);
|
||
});
|
||
|
||
it('returns not-updated pr body for GHE', async () => {
|
||
const scope = httpMock
|
||
.scope('https://github.company.com')
|
||
.head('/')
|
||
.reply(200, '', { 'x-github-enterprise-version': '3.1.7' })
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, {});
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initPlatform({
|
||
endpoint: 'https://github.company.com',
|
||
token: '123test',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const input =
|
||
'https://github.com/foo/bar/issues/5 plus also [a link](https://github.com/foo/bar/issues/5)';
|
||
expect(github.massageMarkdown(input)).toEqual(input);
|
||
});
|
||
|
||
it('returns not-updated pr body for GHEC', async () => {
|
||
const scope = httpMock
|
||
.scope('https://api.octocorp.ghe.com')
|
||
.get('/user')
|
||
.reply(200, {
|
||
login: 'renovate-bot',
|
||
})
|
||
.get('/user/emails')
|
||
.reply(200, {});
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initPlatform({
|
||
endpoint: 'https://api.octocorp.ghe.com',
|
||
token: '123test',
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const input =
|
||
'https://github.com/foo/bar/issues/5 plus also [a link](https://github.com/foo/bar/issues/5)';
|
||
expect(github.massageMarkdown(input)).toEqual(input);
|
||
});
|
||
});
|
||
|
||
describe('mergePr(prNo) - autodetection', () => {
|
||
it('should try squash first', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1235);
|
||
scope.put('/repos/some/repo/pulls/1235/merge').reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = {
|
||
number: 1235,
|
||
head: {
|
||
ref: 'someref',
|
||
},
|
||
};
|
||
await expect(
|
||
github.mergePr({
|
||
branchName: '',
|
||
id: pr.number,
|
||
}),
|
||
).resolves.toBeTrue();
|
||
});
|
||
|
||
it('should try merge after squash', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1236);
|
||
scope
|
||
.put('/repos/some/repo/pulls/1236/merge')
|
||
.reply(400, 'no squashing allowed');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = {
|
||
number: 1236,
|
||
head: {
|
||
ref: 'someref',
|
||
},
|
||
};
|
||
await expect(
|
||
github.mergePr({
|
||
branchName: '',
|
||
id: pr.number,
|
||
}),
|
||
).resolves.toBeFalse();
|
||
});
|
||
|
||
it('should try rebase after merge', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1237);
|
||
scope
|
||
.put('/repos/some/repo/pulls/1237/merge')
|
||
.reply(405, 'no squashing allowed')
|
||
.put('/repos/some/repo/pulls/1237/merge')
|
||
.reply(405, 'no merging allowed')
|
||
.put('/repos/some/repo/pulls/1237/merge')
|
||
.reply(200);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = {
|
||
number: 1237,
|
||
head: {
|
||
ref: 'someref',
|
||
},
|
||
};
|
||
await expect(
|
||
github.mergePr({
|
||
branchName: '',
|
||
id: pr.number,
|
||
}),
|
||
).resolves.toBeTrue();
|
||
});
|
||
|
||
it('should give up', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
prListMock(scope, 1237);
|
||
scope
|
||
.put('/repos/some/repo/pulls/1237/merge')
|
||
.reply(405, 'no squashing allowed')
|
||
.put('/repos/some/repo/pulls/1237/merge')
|
||
.replyWithError('no merging allowed')
|
||
.put('/repos/some/repo/pulls/1237/merge')
|
||
.replyWithError('no rebasing allowed')
|
||
.put('/repos/some/repo/pulls/1237/merge')
|
||
.replyWithError('never gonna give you up');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const pr = {
|
||
number: 1237,
|
||
head: {
|
||
ref: 'someref',
|
||
},
|
||
};
|
||
await expect(
|
||
github.mergePr({
|
||
branchName: '',
|
||
id: pr.number,
|
||
}),
|
||
).resolves.toBeFalse();
|
||
});
|
||
});
|
||
|
||
describe('getVulnerabilityAlerts()', () => {
|
||
it('avoids fetching if repo has vulnerability alerts disabled', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo', {
|
||
hasVulnerabilityAlertsEnabled: false,
|
||
});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getVulnerabilityAlerts();
|
||
expect(res).toHaveLength(0);
|
||
});
|
||
|
||
it('returns empty if error', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100',
|
||
)
|
||
.reply(200, {});
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getVulnerabilityAlerts();
|
||
expect(res).toHaveLength(0);
|
||
});
|
||
|
||
it('returns array if found', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'high',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'npm',
|
||
name: 'left-pad',
|
||
},
|
||
severity: 'high',
|
||
vulnerable_version_range: '0.0.2',
|
||
first_patched_version: { identifier: '0.0.3' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'high',
|
||
},
|
||
security_vulnerability: null,
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getVulnerabilityAlerts();
|
||
expect(res).toHaveLength(1);
|
||
});
|
||
|
||
it('returns empty if disabled', async () => {
|
||
// prettier-ignore
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100',
|
||
)
|
||
.reply(200, { data: { repository: {} } });
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getVulnerabilityAlerts();
|
||
expect(res).toHaveLength(0);
|
||
});
|
||
|
||
it('handles network error', async () => {
|
||
// prettier-ignore
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100',
|
||
)
|
||
.replyWithError('unknown error');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getVulnerabilityAlerts();
|
||
expect(res).toHaveLength(0);
|
||
});
|
||
|
||
it('calls logger.debug with only items that include securityVulnerability', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'high',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'npm',
|
||
name: 'left-pad',
|
||
},
|
||
severity: 'high',
|
||
vulnerable_version_range: '0.0.2',
|
||
first_patched_version: { identifier: '0.0.3' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'high',
|
||
},
|
||
security_vulnerability: null,
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
await github.getVulnerabilityAlerts();
|
||
|
||
expect(logger.logger.debug).toHaveBeenCalledWith(
|
||
{ alerts: { 'npm/left-pad': { '0.0.2': '0.0.3' } } },
|
||
'GitHub vulnerability details',
|
||
);
|
||
expect(logger.logger.error).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('returns normalized names for PIP ecosystem', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'medium',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'pip',
|
||
name: 'FrIeNdLy.-.BARD',
|
||
},
|
||
severity: 'medium',
|
||
vulnerable_version_range: '0.0.2',
|
||
first_patched_version: { identifier: '0.0.3' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
]);
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getVulnerabilityAlerts();
|
||
expect(res[0].security_vulnerability!.package.name).toBe('friendly-bard');
|
||
});
|
||
|
||
it('handles pagination correctly', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100',
|
||
)
|
||
.reply(
|
||
200,
|
||
[
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'high',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'npm',
|
||
name: 'left-pad',
|
||
},
|
||
severity: 'high',
|
||
vulnerable_version_range: '0.0.2',
|
||
first_patched_version: { identifier: '0.0.3' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'critical',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'npm',
|
||
name: 'right-pad',
|
||
},
|
||
severity: 'critical',
|
||
vulnerable_version_range: '0.0.1',
|
||
first_patched_version: { identifier: '0.0.2' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
],
|
||
{
|
||
link: `<${githubApiHost}/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100&page=2>; rel="next", <${githubApiHost}/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100&page=2>; rel="last"`,
|
||
},
|
||
)
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100&page=2',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'low',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'npm',
|
||
name: 'center-pad',
|
||
},
|
||
severity: 'low',
|
||
vulnerable_version_range: '0.0.3',
|
||
first_patched_version: { identifier: '0.0.4' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
]);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getVulnerabilityAlerts();
|
||
|
||
expect(res).toHaveLength(3);
|
||
expect(res[0].security_vulnerability!.package.name).toBe('left-pad');
|
||
expect(res[1].security_vulnerability!.package.name).toBe('right-pad');
|
||
expect(res[2].security_vulnerability!.package.name).toBe('center-pad');
|
||
});
|
||
|
||
it('handles cursor pagination correctly', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
|
||
scope
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100',
|
||
)
|
||
.reply(
|
||
200,
|
||
[
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'high',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'npm',
|
||
name: 'left-pad',
|
||
},
|
||
severity: 'high',
|
||
vulnerable_version_range: '0.0.2',
|
||
first_patched_version: { identifier: '0.0.3' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'critical',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'npm',
|
||
name: 'right-pad',
|
||
},
|
||
severity: 'critical',
|
||
vulnerable_version_range: '0.0.1',
|
||
first_patched_version: { identifier: '0.0.2' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
],
|
||
{
|
||
link: `<${githubApiHost}/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100&after=cursor-1>; rel="next"`,
|
||
},
|
||
)
|
||
.get(
|
||
'/repos/some/repo/dependabot/alerts?state=open&direction=asc&per_page=100&after=cursor-1',
|
||
)
|
||
.reply(200, [
|
||
{
|
||
security_advisory: {
|
||
ghsa_id: 'GHSA-1234-5678-9012',
|
||
summary: 'summary',
|
||
description: 'description',
|
||
identifiers: [{ type: 'type', value: 'value' }],
|
||
references: [],
|
||
severity: 'low',
|
||
},
|
||
security_vulnerability: {
|
||
package: {
|
||
ecosystem: 'npm',
|
||
name: 'center-pad',
|
||
},
|
||
severity: 'low',
|
||
vulnerable_version_range: '0.0.3',
|
||
first_patched_version: { identifier: '0.0.4' },
|
||
},
|
||
dependency: {
|
||
manifest_path: 'bar/foo',
|
||
},
|
||
},
|
||
]);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
const res = await github.getVulnerabilityAlerts();
|
||
|
||
expect(res).toHaveLength(3);
|
||
expect(res[0].security_vulnerability!.package.name).toBe('left-pad');
|
||
expect(res[1].security_vulnerability!.package.name).toBe('right-pad');
|
||
expect(res[2].security_vulnerability!.package.name).toBe('center-pad');
|
||
});
|
||
});
|
||
|
||
describe('getJsonFile()', () => {
|
||
it('returns null', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.get('/repos/some/repo/contents/file.json').reply(200, {
|
||
content: '',
|
||
});
|
||
const res = await github.getJsonFile('file.json');
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('returns file content', async () => {
|
||
const data = { foo: 'bar' };
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.get('/repos/some/repo/contents/file.json').reply(200, {
|
||
content: toBase64(JSON.stringify(data)),
|
||
});
|
||
const res = await github.getJsonFile('file.json');
|
||
expect(res).toEqual(data);
|
||
});
|
||
|
||
it('returns file content in json5 format', async () => {
|
||
const json5Data = `
|
||
{
|
||
// json5 comment
|
||
foo: 'bar'
|
||
}
|
||
`;
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.get('/repos/some/repo/contents/file.json5').reply(200, {
|
||
content: toBase64(json5Data),
|
||
});
|
||
const res = await github.getJsonFile('file.json5');
|
||
expect(res).toEqual({ foo: 'bar' });
|
||
});
|
||
|
||
it('returns file content from given repo', async () => {
|
||
const data = { foo: 'bar' };
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'different/repo');
|
||
await github.initRepo({ repository: 'different/repo' });
|
||
scope.get('/repos/different/repo/contents/file.json').reply(200, {
|
||
content: toBase64(JSON.stringify(data)),
|
||
});
|
||
const res = await github.getJsonFile('file.json', 'different/repo');
|
||
expect(res).toEqual(data);
|
||
});
|
||
|
||
it('returns file content from branch or tag', async () => {
|
||
const data = { foo: 'bar' };
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.get('/repos/some/repo/contents/file.json?ref=dev').reply(200, {
|
||
content: toBase64(JSON.stringify(data)),
|
||
});
|
||
const res = await github.getJsonFile('file.json', 'some/repo', 'dev');
|
||
expect(res).toEqual(data);
|
||
});
|
||
|
||
it('throws on malformed JSON', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.get('/repos/some/repo/contents/file.json').reply(200, {
|
||
content: toBase64('!@#'),
|
||
});
|
||
await expect(github.getJsonFile('file.json')).rejects.toThrow(
|
||
"JSON5: invalid character '!' at 1:1",
|
||
);
|
||
});
|
||
|
||
it('throws on errors', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope
|
||
.get('/repos/some/repo/contents/file.json')
|
||
.replyWithError('some error');
|
||
|
||
await expect(github.getJsonFile('file.json')).rejects.toThrow(
|
||
'some error',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('pushFiles', () => {
|
||
const fetchedSha = fakeSha('0abcdef');
|
||
|
||
beforeEach(() => {
|
||
git.prepareCommit.mockImplementation(({ files }) =>
|
||
Promise.resolve({
|
||
parentCommitSha: fakeSha('1234567'),
|
||
commitSha: fakeSha('7654321'),
|
||
files,
|
||
}),
|
||
);
|
||
git.fetchBranch.mockImplementation(() => Promise.resolve(fetchedSha));
|
||
git.pushCommitToRenovateRef.mockResolvedValue(undefined);
|
||
git.getCommitTreeSha.mockResolvedValue(
|
||
fakeSha('0000000000000000000000000000000000000000'),
|
||
);
|
||
git.diffCommitTree.mockResolvedValue([
|
||
{
|
||
path: 'foo.bar',
|
||
mode: '100644',
|
||
type: 'blob',
|
||
sha: fakeSha('abc0000000000000000000000000000000000000'),
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('returns null if pre-commit phase has failed', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
git.prepareCommit.mockResolvedValueOnce(null);
|
||
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [
|
||
{ type: 'addition', path: 'foo.bar', contents: 'foobar' },
|
||
{ type: 'deletion', path: 'baz' },
|
||
{ type: 'deletion', path: 'qux' },
|
||
],
|
||
message: 'Foobar',
|
||
});
|
||
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('returns null on REST error', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
scope.post('/repos/some/repo/git/trees').replyWithError('unknown');
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
|
||
message: 'Foobar',
|
||
});
|
||
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('returns null when diff is empty', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
git.diffCommitTree.mockResolvedValueOnce([]);
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
|
||
message: 'Foobar',
|
||
});
|
||
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('commits and returns SHA string', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
scope
|
||
.post('/repos/some/repo/git/trees')
|
||
.reply(200, { sha: '111' })
|
||
.post('/repos/some/repo/git/commits')
|
||
.reply(200, { sha: '0123456789abcdef0123456789abcdef01234567' })
|
||
.head(
|
||
'/repos/some/repo/git/commits/0123456789abcdef0123456789abcdef01234567',
|
||
)
|
||
.reply(200)
|
||
.post('/repos/some/repo/git/refs')
|
||
.reply(200);
|
||
vi.spyOn(branch, 'remoteBranchExists').mockResolvedValueOnce(false);
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
|
||
message: 'Foobar',
|
||
});
|
||
|
||
expect(res).toBe(fetchedSha);
|
||
});
|
||
|
||
it('includes commit trailers in the platform-native commit message', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
scope
|
||
.post('/repos/some/repo/git/trees')
|
||
.reply(200, { sha: '111' })
|
||
.post('/repos/some/repo/git/commits', {
|
||
message: codeBlock`
|
||
Foobar
|
||
|
||
Signed-off-by: Renovate Bot <bot@renovateapp.com>
|
||
`,
|
||
tree: '111',
|
||
parents: [fakeSha('1234567')],
|
||
})
|
||
.reply(200, { sha: '0123456789abcdef0123456789abcdef01234567' })
|
||
.head(
|
||
'/repos/some/repo/git/commits/0123456789abcdef0123456789abcdef01234567',
|
||
)
|
||
.reply(200)
|
||
.post('/repos/some/repo/git/refs')
|
||
.reply(200);
|
||
vi.spyOn(branch, 'remoteBranchExists').mockResolvedValueOnce(false);
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
|
||
message: 'Foobar',
|
||
trailers: ['Signed-off-by: Renovate Bot <bot@renovateapp.com>'],
|
||
});
|
||
|
||
expect(res).toBe(fetchedSha);
|
||
});
|
||
|
||
it('performs rebase', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
scope
|
||
.post('/repos/some/repo/git/trees')
|
||
.reply(200, { sha: '111' })
|
||
.post('/repos/some/repo/git/commits')
|
||
.reply(200, { sha: '0123456789abcdef0123456789abcdef01234567' })
|
||
.head(
|
||
'/repos/some/repo/git/commits/0123456789abcdef0123456789abcdef01234567',
|
||
)
|
||
.reply(200)
|
||
.patch('/repos/some/repo/git/refs/heads/foo/bar')
|
||
.reply(200);
|
||
vi.spyOn(branch, 'remoteBranchExists').mockResolvedValueOnce(true);
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
|
||
message: 'Foobar',
|
||
});
|
||
|
||
expect(res).toBe(fetchedSha);
|
||
});
|
||
|
||
it('continues if rebase fails due to 422', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
scope
|
||
.post('/repos/some/repo/git/trees')
|
||
.reply(200, { sha: '111' })
|
||
.post('/repos/some/repo/git/commits')
|
||
.reply(200, { sha: '0123456789abcdef0123456789abcdef01234567' })
|
||
.head(
|
||
'/repos/some/repo/git/commits/0123456789abcdef0123456789abcdef01234567',
|
||
)
|
||
.reply(200)
|
||
.patch('/repos/some/repo/git/refs/heads/foo/bar')
|
||
.reply(422)
|
||
.post('/repos/some/repo/git/refs')
|
||
.reply(200);
|
||
vi.spyOn(branch, 'remoteBranchExists').mockResolvedValueOnce(true);
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
|
||
message: 'Foobar',
|
||
});
|
||
|
||
expect(res).toBe(fetchedSha);
|
||
});
|
||
|
||
it('aborts if rebase fails due to non-422', async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
scope
|
||
.post('/repos/some/repo/git/trees')
|
||
.reply(200, { sha: '111' })
|
||
.post('/repos/some/repo/git/commits')
|
||
.reply(200, { sha: '0123456789abcdef0123456789abcdef01234567' })
|
||
.head(
|
||
'/repos/some/repo/git/commits/0123456789abcdef0123456789abcdef01234567',
|
||
)
|
||
.reply(200)
|
||
.patch('/repos/some/repo/git/refs/heads/foo/bar')
|
||
.reply(404);
|
||
vi.spyOn(branch, 'remoteBranchExists').mockResolvedValueOnce(true);
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
|
||
message: 'Foobar',
|
||
});
|
||
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it("aborts if commit SHA doesn't exist", async () => {
|
||
const scope = httpMock.scope(githubApiHost);
|
||
|
||
initRepoMock(scope, 'some/repo');
|
||
await github.initRepo({ repository: 'some/repo' });
|
||
|
||
scope
|
||
.post('/repos/some/repo/git/trees')
|
||
.reply(200, { sha: '111' })
|
||
.post('/repos/some/repo/git/commits')
|
||
.reply(200, { sha: '0123456789abcdef0123456789abcdef01234567' })
|
||
.head(
|
||
'/repos/some/repo/git/commits/0123456789abcdef0123456789abcdef01234567',
|
||
)
|
||
.reply(404);
|
||
|
||
const res = await github.commitFiles({
|
||
branchName: 'foo/bar',
|
||
files: [{ type: 'addition', path: 'foo.bar', contents: 'foobar' }],
|
||
message: 'Foobar',
|
||
});
|
||
|
||
expect(res).toBeNull();
|
||
});
|
||
});
|
||
});
|