From 01d23f197d37e38197f870680932f1f39c42d0cc Mon Sep 17 00:00:00 2001 From: Riley Kohler Date: Fri, 29 Aug 2025 08:41:57 -0400 Subject: [PATCH] feat: support creating internal mirrors --- .env.example | 3 ++ env.mjs | 7 ++++ src/server/repos/controller.ts | 5 ++- test/server/repos.test.ts | 58 +++++++++++++++++++++++++++++++++- 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 50531e1..141eb8e 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,6 @@ PRIVATE_ORG= # Used to skip branch protection creation if organization level branch protections are used instead SKIP_BRANCH_PROTECTION_CREATION= + +# Used to create mirrors with internal visibility instead of private for use in GitHub Enterprise Cloud organizations +CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY= diff --git a/env.mjs b/env.mjs index f7f7395..7b991ee 100644 --- a/env.mjs +++ b/env.mjs @@ -44,6 +44,11 @@ export const env = createEnv({ .optional() .default('false') .transform((value) => value === 'true'), + CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY: z + .enum(['true', 'false', '']) + .optional() + .default('false') + .transform((value) => value === 'true'), }, /* * Environment variables available on the client (and server). @@ -73,6 +78,8 @@ export const env = createEnv({ ALLOWED_ORGS: process.env.ALLOWED_ORGS, SKIP_BRANCH_PROTECTION_CREATION: process.env.SKIP_BRANCH_PROTECTION_CREATION, + CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY: + process.env.CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY, }, skipValidation: process.env.SKIP_ENV_VALIDATIONS === 'true', }) diff --git a/src/server/repos/controller.ts b/src/server/repos/controller.ts index f541e60..15c0f53 100644 --- a/src/server/repos/controller.ts +++ b/src/server/repos/controller.ts @@ -132,7 +132,10 @@ export const createMirrorHandler = async ({ newRepo = await privateOctokit.rest.repos.createInOrg({ name: input.newRepoName, org: privateOrg, - private: true, + // @ts-expect-error because the rest API accepts internal as an option but the types aren't up to date + visibility: process.env.CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY + ? 'internal' + : 'private', description: `Mirror of ${input.forkRepoOwner}/${input.forkRepoName}`, custom_properties: { fork: `${input.forkRepoOwner}/${input.forkRepoName}`, diff --git a/test/server/repos.test.ts b/test/server/repos.test.ts index ae383b2..53da749 100644 --- a/test/server/repos.test.ts +++ b/test/server/repos.test.ts @@ -20,6 +20,7 @@ import { Octomock } from '../octomock' import { createTestContext } from '../utils/auth' import { t } from '../../src/utils/trpc-server' const om = new Octomock() +const UNMODIFIED_ENV = process.env jest.mock('../../src/bot/config') jest.mock('../../src/bot/octokit', () => ({ @@ -104,9 +105,10 @@ describe('Repos router', () => { beforeEach(() => { om.resetMocks() jest.resetAllMocks() + process.env = { ...UNMODIFIED_ENV } }) - it('should create a mirror when repo does not exist exist', async () => { + it('should create a mirror when repo does not exist', async () => { const caller = t.createCallerFactory(reposRouter)(createTestContext()) const configSpy = jest.spyOn(config, 'getConfig').mockResolvedValue({ @@ -284,6 +286,60 @@ describe('Repos router', () => { expect(stubbedGit.clone).toHaveBeenCalledTimes(1) }) + it('should create an internal repo when the CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY flag is used', async () => { + const caller = t.createCallerFactory(reposRouter)(createTestContext()) + + const configSpy = jest.spyOn(config, 'getConfig').mockResolvedValue({ + publicOrg: 'github', + privateOrg: 'github-test', + }) + + om.mockFunctions.rest.apps.getOrgInstallation.mockResolvedValue( + fakeOrgInstallation, + ) + om.mockFunctions.rest.orgs.get.mockResolvedValue(fakeOrg) + om.mockFunctions.rest.repos.get.mockResolvedValueOnce(repoNotFound) + om.mockFunctions.rest.repos.get.mockResolvedValueOnce(fakeForkRepo) + om.mockFunctions.rest.orgs.getAllCustomProperties.mockResolvedValue( + fakeOrgCustomProperties, + ) + om.mockFunctions.rest.orgs.createOrUpdateCustomProperty.mockResolvedValue( + fakeOrgCustomProperties, + ) + om.mockFunctions.rest.repos.createInOrg.mockResolvedValue(fakeMirrorRepo) + + // set the environment variable to trigger mirrors being created with internal visibility + process.env.CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY = 'true' + + const res = await caller.createMirror({ + forkId: 'test', + orgId: 'test', + forkRepoName: 'fork-test', + forkRepoOwner: 'github', + newBranchName: 'test', + newRepoName: 'test', + }) + + // TODO: use real git operations and verify fs state after + expect(configSpy).toHaveBeenCalledTimes(1) + expect(om.mockFunctions.rest.repos.get).toHaveBeenCalledTimes(2) + expect(stubbedGit.clone).toHaveBeenCalledTimes(1) + expect(om.mockFunctions.rest.repos.createInOrg).toHaveBeenCalledTimes(1) + expect(om.mockFunctions.rest.repos.createInOrg).toHaveBeenCalledWith( + expect.objectContaining({ + visibility: 'internal', + }), + ) + expect(stubbedGit.addRemote).toHaveBeenCalledTimes(1) + expect(stubbedGit.push).toHaveBeenCalledTimes(2) + expect(stubbedGit.checkoutBranch).toHaveBeenCalledTimes(1) + + expect(res).toEqual({ + success: true, + data: fakeMirrorRepo.data, + }) + }) + it('reject repository names over the character limit', async () => { const caller = t.createCallerFactory(reposRouter)(createTestContext())