diff --git a/lib/modules/platform/github/index.spec.ts b/lib/modules/platform/github/index.spec.ts index 211719a4dfc689e1dbd5959f2d12faa4d181d88c..af8edb9b79604348aa1369c1ffda6f80f9cd78e7 100644 --- a/lib/modules/platform/github/index.spec.ts +++ b/lib/modules/platform/github/index.spec.ts @@ -3,6 +3,7 @@ import * as httpMock from '../../../../test/http-mock'; import { logger, mocked, partial } from '../../../../test/util'; import { GlobalConfig } from '../../../config/global'; import { + REPOSITORY_CANNOT_FORK, REPOSITORY_NOT_FOUND, REPOSITORY_RENAMED, } from '../../../constants/error-messages'; @@ -289,20 +290,20 @@ describe('modules/platform/github/index', () => { }, }, }) - // getRepos - .get('/user/repos?per_page=100') + // getForks + .get(`/repos/${repository}/forks?per_page=100`) .reply( 200, forkExisted - ? [{ full_name: 'forked/repo', default_branch: forkDefaulBranch }] + ? [ + { + full_name: 'forked/repo', + owner: { login: 'forked' }, + default_branch: forkDefaulBranch, + }, + ] : [] - ) - // getBranchCommit - .post(`/repos/${repository}/forks`) - .reply(200, { - full_name: 'forked/repo', - default_branch: forkDefaulBranch, - }); + ); } describe('initRepo', () => { @@ -316,6 +317,13 @@ describe('modules/platform/github/index', () => { 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: 'true', @@ -323,9 +331,43 @@ describe('modules/platform/github/index', () => { expect(config).toMatchSnapshot(); }); + 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, branch); + scope.get('/user').reply(404); + await expect( + github.initRepo({ + repository: 'some/repo', + forkToken: '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', + }); + // getBranchCommit + scope.post(`/repos/${repo}/forks`).reply(500); + await expect( + github.initRepo({ + repository: 'some/repo', + forkToken: 'true', + }) + ).rejects.toThrow(REPOSITORY_CANNOT_FORK); + }); + it('should update fork when using forkToken', async () => { const scope = httpMock.scope(githubApiHost); forkInitRepoMock(scope, 'some/repo', true); + scope.get('/user').reply(200, { + login: 'forked', + }); scope.patch('/repos/forked/repo/git/refs/heads/master').reply(200); const config = await github.initRepo({ repository: 'some/repo', @@ -337,6 +379,9 @@ describe('modules/platform/github/index', () => { it('detects fork default branch mismatch', async () => { const scope = httpMock.scope(githubApiHost); forkInitRepoMock(scope, 'some/repo', true, '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); scope.patch('/repos/forked/repo/git/refs/heads/master').reply(200); diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts index 9e458272ffc3ef186b5fd15bdb9622fecd8e47cd..b82e9ff039675a6284c9326ea05b3609fa1f6b0a 100644 --- a/lib/modules/platform/github/index.ts +++ b/lib/modules/platform/github/index.ts @@ -74,6 +74,7 @@ import type { GhPr, GhRepo, GhRestPr, + GhRestRepo, LocalRepoConfig, PlatformConfig, } from './types'; @@ -241,6 +242,86 @@ export async function getJsonFile( return JSON5.parse(raw); } +export async function getForkOrgs(token: string): Promise<string[]> { + // This function will be adapted later to support configured forkOrgs + if (!config.renovateForkUser) { + try { + logger.debug('Determining fork user from API'); + const userDetails = await getUserDetails(platformConfig.endpoint, token); + config.renovateForkUser = userDetails.username; + } catch (err) { + logger.debug({ err }, 'Error getting username for forkToken'); + } + } + return config.renovateForkUser ? [config.renovateForkUser] : []; +} + +export async function listForks( + token: string, + repository: string +): Promise<GhRestRepo[]> { + // Get list of existing repos + const url = `repos/${repository}/forks?per_page=100`; + const repos = ( + await githubApi.getJson<GhRestRepo[]>(url, { + token, + paginate: true, + pageLimit: 100, + }) + ).body; + logger.debug(`Found ${repos.length} forked repo(s)`); + return repos; +} + +export async function findFork( + token: string, + repository: string +): Promise<GhRestRepo | null> { + const forks = await listForks(token, repository); + const orgs = await getForkOrgs(token); + if (!orgs.length) { + throw new Error(REPOSITORY_CANNOT_FORK); + } + let forkedRepo: GhRestRepo | undefined; + for (const forkOrg of orgs) { + logger.debug(`Searching for forked repo in ${forkOrg}`); + forkedRepo = forks.find((repo) => repo.owner.login === forkOrg); + if (forkedRepo) { + logger.debug(`Found existing forked repo: ${forkedRepo.full_name}`); + break; + } + } + return forkedRepo ?? null; +} + +export async function createFork( + token: string, + repository: string +): Promise<GhRestRepo> { + let forkedRepo: GhRestRepo | undefined; + try { + const organization = (await getForkOrgs(token))[0]; + forkedRepo = ( + await githubApi.postJson<GhRestRepo>(`repos/${repository}/forks`, { + token, + body: { + organization, + name: config.parentRepo!.replace('/', '-_-'), + default_branch_only: true, // no baseBranches support yet + }, + }) + ).body; + } catch (err) { + logger.debug({ err }, 'Error creating fork'); + } + if (!forkedRepo) { + throw new Error(REPOSITORY_CANNOT_FORK); + } + logger.debug(`Created forked repo ${forkedRepo.full_name}, now sleeping 30s`); + await delay(30000); + return forkedRepo; +} + // Initialize GitHub by getting base branch and SHA export async function initRepo({ endpoint, @@ -375,26 +456,10 @@ export async function initRepo({ // save parent name then delete config.parentRepo = config.repository; config.repository = null; - // Get list of existing repos - platformConfig.existingRepos ??= ( - await githubApi.getJson<{ full_name: string }[]>( - 'user/repos?per_page=100', - { - token: forkToken ?? opts.token, - paginate: true, - pageLimit: 100, - } - ) - ).body.map((r) => r.full_name); - try { - const forkedRepo = await githubApi.postJson<{ - full_name: string; - default_branch: string; - }>(`repos/${repository}/forks`, { - token: forkToken ?? opts.token, - }); - config.repository = forkedRepo.body.full_name; - const forkDefaultBranch = forkedRepo.body.default_branch; + let forkedRepo = await findFork(forkToken, repository); + if (forkedRepo) { + config.repository = forkedRepo.full_name; + const forkDefaultBranch = forkedRepo.default_branch; if (forkDefaultBranch !== config.defaultBranch) { const body = { ref: `refs/heads/${config.defaultBranch}`, @@ -442,15 +507,6 @@ export async function initRepo({ logger.warn({ err }, 'Could not set default branch'); } } - } catch (err) /* istanbul ignore next */ { - logger.debug({ err }, 'Error forking repository'); - throw new Error(REPOSITORY_CANNOT_FORK); - } - if (platformConfig.existingRepos.includes(config.repository)) { - logger.debug( - { repository_fork: config.repository }, - 'Found existing fork' - ); // This is a lovely "hack" by GitHub that lets us force update our fork's default branch // with the base commit from the parent repository const url = `repos/${config.repository}/git/refs/heads/${config.defaultBranch}`; @@ -477,10 +533,9 @@ export async function initRepo({ throw new ExternalHostError(err); } } else { - logger.debug({ repository_fork: config.repository }, 'Created fork'); - platformConfig.existingRepos.push(config.repository); - // Wait an arbitrary 30s to hopefully give GitHub enough time for forking to complete - await delay(30000); + logger.debug('Forked repo is not found - attempting to create it'); + forkedRepo = await createFork(forkToken, repository); + config.repository = forkedRepo.full_name; } } diff --git a/lib/modules/platform/github/types.ts b/lib/modules/platform/github/types.ts index 2e3f3839b9f83b4ccac109b1cc8a778b6292bcf8..9c62287dcfd79a91a81676bf177aa2b33c19b4aa 100644 --- a/lib/modules/platform/github/types.ts +++ b/lib/modules/platform/github/types.ts @@ -20,6 +20,14 @@ export interface Comment { body: string; } +export interface GhRestRepo { + full_name: string; + default_branch: string; + owner: { + login: string; + }; +} + export interface GhRestPr { head: { ref: string; @@ -88,6 +96,7 @@ export interface LocalRepoConfig { repositoryOwner: string; repository: string | null; renovateUsername: string | undefined; + renovateForkUser: string | undefined; productLinks: any; ignorePrAuthor: boolean; autoMergeAllowed: boolean;