├── .nvmrc ├── .dockerignore ├── .husky └── pre-commit ├── babel.config.testing.js ├── CODEOWNERS ├── docs ├── images │ ├── branch-protection.png │ ├── create-new-mirror.png │ └── public-forks-inside-org.png ├── architecture.md ├── developing.md ├── attribution-flow.md └── using-the-app.md ├── src ├── utils │ ├── dir.ts │ ├── query-client.ts │ ├── pem.ts │ ├── proxy.ts │ ├── trpc-middleware.ts │ ├── trpc.ts │ ├── trpc-server.ts │ ├── logger.ts │ └── auth.ts ├── server │ ├── config │ │ ├── schema.ts │ │ ├── router.ts │ │ └── controller.ts │ ├── octokit │ │ ├── schema.ts │ │ ├── router.ts │ │ └── controller.ts │ ├── git │ │ ├── router.ts │ │ ├── schema.ts │ │ └── controller.ts │ └── repos │ │ ├── router.ts │ │ └── schema.ts ├── app │ ├── [organizationId] │ │ └── layout.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.tsx │ │ └── trpc │ │ │ ├── trpc-router.ts │ │ │ └── [trpc] │ │ │ └── route.tsx │ ├── components │ │ ├── header │ │ │ ├── WelcomeHeader.tsx │ │ │ ├── OrgHeader.tsx │ │ │ ├── MainHeader.tsx │ │ │ └── ForkHeader.tsx │ │ ├── loading │ │ │ └── Loading.tsx │ │ ├── breadcrumbs │ │ │ ├── OrgBreadcrumbs.tsx │ │ │ └── ForkBreadcrumbs.tsx │ │ ├── flash │ │ │ ├── AppNotInstalledFlash.tsx │ │ │ ├── ErrorFlash.tsx │ │ │ └── SuccessFlash.tsx │ │ ├── dialog │ │ │ ├── DeleteMirrorDialog.tsx │ │ │ ├── CreateMirrorDialog.tsx │ │ │ └── EditMirrorDialog.tsx │ │ ├── search │ │ │ ├── Search.tsx │ │ │ └── SearchWithCreate.tsx │ │ └── login │ │ │ └── Login.tsx │ ├── not-found.tsx │ ├── auth │ │ ├── login │ │ │ └── page.tsx │ │ └── error │ │ │ └── page.tsx │ ├── context │ │ └── AuthProvider.tsx │ ├── layout.tsx │ └── page.tsx ├── bot │ ├── rest.ts │ ├── config.ts │ ├── graphql.ts │ ├── octokit.ts │ └── rules.ts ├── middleware.ts ├── types │ ├── next-auth.d.ts │ └── forks.ts ├── pages │ └── api │ │ └── webhooks.ts ├── providers │ ├── registry-provider.tsx │ └── trpc-provider.tsx └── hooks │ ├── useOrganizations.tsx │ ├── useFork.tsx │ ├── useOrganization.tsx │ └── useForks.tsx ├── .gitignore ├── next.config.mjs ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── workflows │ ├── pr-title.yml │ ├── auto-labeler.yml │ ├── lint.yml │ ├── tests.yml │ ├── build.yml │ ├── docker-build.yml │ ├── release.yml │ └── scorecard.yml ├── pull_request_template.md ├── dependabot.yml └── release-drafter.yml ├── scripts ├── eventer ├── proxy.mjs ├── events │ ├── ping.json │ └── installation.created.json └── webhook-relay.mjs ├── docker-compose.yml ├── .prettierrc.js ├── .eslintrc.json ├── test ├── utils │ └── auth.ts ├── server │ ├── config.test.ts │ └── auth.test.ts ├── config.test.ts ├── fixtures │ ├── mock-cert.pem │ ├── ping.json │ ├── installation.created.json │ └── fork.created.json ├── app.ts └── app.test.ts ├── SUPPORT.md ├── jest.config.js ├── .vscode └── launch.json ├── LICENSE.md ├── .env.example ├── Dockerfile ├── CONTRIBUTING.md ├── package.json ├── env.mjs ├── app.yml ├── tsconfig.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.21.1 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /babel.config.testing.js: -------------------------------------------------------------------------------- 1 | module.exports = { presets: ['@babel/preset-env'] } 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github-community-projects/ospo @github-community-projects/pma-maintainers 2 | -------------------------------------------------------------------------------- /docs/images/branch-protection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-community-projects/private-mirrors/HEAD/docs/images/branch-protection.png -------------------------------------------------------------------------------- /docs/images/create-new-mirror.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-community-projects/private-mirrors/HEAD/docs/images/create-new-mirror.png -------------------------------------------------------------------------------- /docs/images/public-forks-inside-org.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/github-community-projects/private-mirrors/HEAD/docs/images/public-forks-inside-org.png -------------------------------------------------------------------------------- /src/utils/dir.ts: -------------------------------------------------------------------------------- 1 | import tempy from 'tempy' 2 | 3 | // FIXME: Had to downgrade tempy to not use esm 4 | export const temporaryDirectory = () => tempy.directory() 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | *.pem 4 | !mock-cert.pem 5 | .env 6 | coverage 7 | build 8 | .next 9 | .env.* 10 | !.env.example 11 | .DS_Store 12 | next-env.d.ts 13 | -------------------------------------------------------------------------------- /src/server/config/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const GetConfigSchema = z.object({ 4 | orgId: z.string(), 5 | }) 6 | 7 | export type GetConfigSchema = z.TypeOf 8 | -------------------------------------------------------------------------------- /src/utils/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query' 2 | 3 | const queryClient = new QueryClient({ 4 | defaultOptions: { queries: { staleTime: 5 * 1000 } }, 5 | }) 6 | 7 | export default queryClient 8 | -------------------------------------------------------------------------------- /src/server/octokit/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const CheckInstallationSchema = z.object({ 4 | orgId: z.string(), 5 | }) 6 | 7 | export type CheckInstallationSchema = z.TypeOf 8 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import './env.mjs' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: true, 6 | compiler: { 7 | styledComponents: true, 8 | }, 9 | } 10 | 11 | export default nextConfig 12 | -------------------------------------------------------------------------------- /src/app/[organizationId]/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Box } from '@primer/react' 4 | 5 | const DashLayout = ({ children }: { children: React.ReactNode }) => { 6 | return {children} 7 | } 8 | 9 | export default DashLayout 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Ask a question 5 | url: https://github.com/github-community-projects/private-mirrors/discussions/new?category=q-a 6 | about: Ask a question or start a discussion 7 | -------------------------------------------------------------------------------- /scripts/eventer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "Running eventer for $1 $2" 4 | echo "Running the following command:" 5 | echo "node_modules/.bin/probot receive -e $1 -p $2 src/bot/index.ts" 6 | 7 | node_modules/.bin/probot receive -e "$1" -p "$2 src/bot/index.ts" 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | env_file: .env 4 | build: 5 | context: . 6 | target: runner 7 | volumes: 8 | - .:/app 9 | - /app/node_modules 10 | - /app/.next 11 | command: npm run start 12 | ports: 13 | - '3000:3000' 14 | -------------------------------------------------------------------------------- /src/bot/rest.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@probot/octokit-plugin-config' 2 | import { Octokit as Core } from 'octokit' 3 | 4 | export const Octokit = Core.plugin(config).defaults({ 5 | userAgent: `octokit-rest.js/repo-sync-bot`, 6 | }) 7 | 8 | export type Octokit = InstanceType 9 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // prettier.config.js, .prettierrc.js, prettier.config.mjs, or .prettierrc.mjs 2 | 3 | /** @type {import("prettier").Config} */ 4 | const config = { 5 | trailingComma: 'all', 6 | tabWidth: 2, 7 | semi: false, 8 | singleQuote: true, 9 | } 10 | 11 | module.exports = config 12 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.tsx: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth' 2 | import { NextRequest, NextResponse } from 'next/server' 3 | import { nextAuthOptions } from '../lib/nextauth-options' 4 | 5 | const handler = NextAuth(nextAuthOptions) as ( 6 | req: NextRequest, 7 | res: NextResponse, 8 | ) => Promise 9 | 10 | export { handler as GET, handler as POST } 11 | -------------------------------------------------------------------------------- /src/server/config/router.ts: -------------------------------------------------------------------------------- 1 | import { procedure, router } from '../../utils/trpc-server' 2 | import { getConfigHandler } from './controller' 3 | import { GetConfigSchema } from './schema' 4 | 5 | const configRouter = router({ 6 | getConfig: procedure 7 | .input(GetConfigSchema) 8 | .query(({ input }) => getConfigHandler({ input })), 9 | }) 10 | 11 | export default configRouter 12 | -------------------------------------------------------------------------------- /src/server/git/router.ts: -------------------------------------------------------------------------------- 1 | import { gitProcedure, router } from '../../utils/trpc-server' 2 | import { syncReposHandler } from './controller' 3 | import { SyncReposSchema } from './schema' 4 | 5 | const gitRouter = router({ 6 | syncRepos: gitProcedure 7 | .input(SyncReposSchema) 8 | .mutation(({ input }) => syncReposHandler({ input })), 9 | }) 10 | 11 | export default gitRouter 12 | -------------------------------------------------------------------------------- /src/server/octokit/router.ts: -------------------------------------------------------------------------------- 1 | import { procedure, router } from '../../utils/trpc-server' 2 | import { checkInstallationHandler } from './controller' 3 | import { CheckInstallationSchema } from './schema' 4 | 5 | const octokitRouter = router({ 6 | checkInstallation: procedure 7 | .input(CheckInstallationSchema) 8 | .query(({ input }) => checkInstallationHandler({ input })), 9 | }) 10 | 11 | export default octokitRouter 12 | -------------------------------------------------------------------------------- /src/utils/pem.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | /** 4 | * Converts a private key in PKCS1 format to PKCS8 format 5 | * @param privateKey Private key in PKCS1 format 6 | */ 7 | export const generatePKCS8Key = (privateKey: string) => { 8 | const privateKeyPkcs8 = crypto 9 | .createPrivateKey(privateKey.replace(/\\n/g, '\n')) 10 | .export({ 11 | type: 'pkcs8', 12 | format: 'pem', 13 | }) 14 | .toString() 15 | 16 | return privateKeyPkcs8 17 | } 18 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { withAuth } from 'next-auth/middleware' 2 | 3 | export default withAuth({ 4 | pages: { 5 | signIn: '/auth/login', 6 | error: '/auth/error', 7 | }, 8 | }) 9 | 10 | export const config = { 11 | matcher: [ 12 | /* 13 | * Match all request paths except for the ones starting with: 14 | * - api (API routes) 15 | * - static (static files) 16 | * - favicon.ico (favicon file) 17 | */ 18 | '/((?!api|static|favicon.ico).*)', 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked" 7 | ], 8 | "parser": "@typescript-eslint/parser", 9 | "plugins": ["@typescript-eslint"], 10 | "parserOptions": { 11 | "project": ["./tsconfig.json"] 12 | }, 13 | "root": true, 14 | "rules": { 15 | // Most of the problems this rule catches are false positives from libs 16 | "@typescript-eslint/no-misused-promises": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /scripts/proxy.mjs: -------------------------------------------------------------------------------- 1 | import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici' 2 | import { ProxyAgent } from 'proxy-agent' 3 | import http from 'http' 4 | import https from 'https' 5 | 6 | // set unidci global dispatcher to a proxy agent based on env variables for fetch calls 7 | const envHttpProxyAgent = new EnvHttpProxyAgent() 8 | setGlobalDispatcher(envHttpProxyAgent) 9 | 10 | // set global agent for older libraries that use http and https calls 11 | const proxyAgent = new ProxyAgent() 12 | http.globalAgent = proxyAgent 13 | https.globalAgent = proxyAgent 14 | -------------------------------------------------------------------------------- /src/utils/proxy.ts: -------------------------------------------------------------------------------- 1 | import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici' 2 | import { ProxyAgent } from 'proxy-agent' 3 | import http from 'http' 4 | import https from 'https' 5 | 6 | // set unidci global dispatcher to a proxy agent based on env variables for fetch calls 7 | const envHttpProxyAgent = new EnvHttpProxyAgent() 8 | setGlobalDispatcher(envHttpProxyAgent) 9 | 10 | // set global agent for older libraries that use http and https calls 11 | const proxyAgent = new ProxyAgent() 12 | http.globalAgent = proxyAgent 13 | https.globalAgent = proxyAgent 14 | -------------------------------------------------------------------------------- /test/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '../../src/types/next-auth' 2 | import { createContext } from '../../src/utils/trpc-server' 3 | 4 | export const createTestContext = ( 5 | session?: Session, 6 | ): Awaited> => { 7 | return { 8 | session: session ?? { 9 | user: { 10 | name: 'fake-username', 11 | email: 'fake-email', 12 | image: 'fake-image', 13 | accessToken: 'fake-access-token', 14 | }, 15 | expires: new Date('2030-01-01').toISOString(), 16 | }, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import 'next-auth' 2 | import { DefaultSession } from 'next-auth' 3 | 4 | declare module 'next-auth' { 5 | interface Session { 6 | error?: 'RefreshAccessTokenError' 7 | user: { 8 | accessToken: string | undefined 9 | } & DefaultSession['user'] 10 | } 11 | } 12 | 13 | declare module 'next-auth/jwt' { 14 | interface JWT { 15 | accessToken: string 16 | accessTokenExpires: number 17 | refreshToken: string 18 | refreshTokenExpires: number 19 | error?: 'RefreshAccessTokenError' 20 | } 21 | } 22 | 23 | export { Session } 24 | -------------------------------------------------------------------------------- /scripts/events/ping.json: -------------------------------------------------------------------------------- 1 | { 2 | "zen": "Responsive is better than fast.", 3 | "hook_id": 438642781, 4 | "hook": { 5 | "type": "App", 6 | "id": 438642781, 7 | "name": "web", 8 | "active": true, 9 | "events": [], 10 | "config": { 11 | "content_type": "json", 12 | "insecure_ssl": "0", 13 | "url": "https://smee.io/gExecvo4VWfRUGCL" 14 | }, 15 | "updated_at": "2023-10-17T20:02:58Z", 16 | "created_at": "2023-10-17T20:02:58Z", 17 | "app_id": 409700, 18 | "deliveries_url": "https://api.github.com/app/hook/deliveries" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yml: -------------------------------------------------------------------------------- 1 | ## Reference: https://github.com/amannn/action-semantic-pull-request 2 | --- 3 | name: 'Lint PR Title' 4 | 5 | on: 6 | pull_request_target: 7 | types: 8 | - opened 9 | - reopened 10 | - edited 11 | - synchronize 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | main: 18 | permissions: 19 | contents: read 20 | pull-requests: read 21 | statuses: write 22 | uses: github/ospo-reusable-workflows/.github/workflows/pr-title.yaml@6f158f242fe68adb5a2698ef47e06dac07ac7e71 23 | secrets: 24 | github-token: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/auto-labeler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Auto Labeler 3 | 4 | on: 5 | # pull_request_target event is required for autolabeler to support all PRs including forks 6 | pull_request_target: 7 | types: [opened, reopened, synchronize] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | main: 14 | permissions: 15 | contents: read 16 | pull-requests: write 17 | uses: github/ospo-reusable-workflows/.github/workflows/auto-labeler.yaml@6f158f242fe68adb5a2698ef47e06dac07ac7e71 18 | with: 19 | config-name: release-drafter.yml 20 | secrets: 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /src/app/api/trpc/trpc-router.ts: -------------------------------------------------------------------------------- 1 | import configRouter from 'server/config/router' 2 | import gitRouter from '../../../server/git/router' 3 | import octokitRouter from '../../../server/octokit/router' 4 | import reposRouter from '../../../server/repos/router' 5 | import { procedure, t } from '../../../utils/trpc-server' 6 | 7 | export const healthCheckerRouter = t.router({ 8 | healthChecker: procedure.query(() => { 9 | return 'ok' 10 | }), 11 | }) 12 | 13 | export const appRouter = t.mergeRouters( 14 | reposRouter, 15 | octokitRouter, 16 | gitRouter, 17 | configRouter, 18 | healthCheckerRouter, 19 | ) 20 | 21 | export type AppRouter = typeof appRouter 22 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. 6 | 7 | For help or questions about using this project, please file an issue or start a GitHub discussion. 8 | 9 | The Private Mirrors app is under active development and maintained by GitHub staff **and the community**. We will do our best to respond to support, feature requests, and community questions in a timely manner. 10 | 11 | ## GitHub Support Policy 12 | 13 | Support for this project is limited to the resources listed above. 14 | -------------------------------------------------------------------------------- /src/app/components/header/WelcomeHeader.tsx: -------------------------------------------------------------------------------- 1 | import { RepoForkedIcon } from '@primer/octicons-react' 2 | import { Octicon, Pagehead, Text } from '@primer/react' 3 | import { Stack } from '@primer/react/lib-esm/Stack' 4 | 5 | export const WelcomeHeader = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Welcome to Private Mirrors App! 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/api/webhooks.ts: -------------------------------------------------------------------------------- 1 | import app from 'bot' 2 | import { createNodeMiddleware, createProbot } from 'probot' 3 | import { logger } from 'utils/logger' 4 | 5 | export const probot = createProbot() 6 | 7 | const probotLogger = logger.getSubLogger({ name: 'probot' }) 8 | 9 | export const config = { 10 | api: { 11 | bodyParser: false, 12 | }, 13 | } 14 | 15 | export default createNodeMiddleware(app, { 16 | probot: createProbot({ 17 | defaults: { 18 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 19 | log: { 20 | child: () => probotLogger, 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | } as any, 23 | }, 24 | }), 25 | webhooksPath: '/api/webhooks', 26 | }) 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testRegex: '(/__tests__/.*|\\.(test|spec))\\.[tj]sx?$', 5 | testEnvironment: 'node', 6 | moduleDirectories: ['node_modules', 'src'], 7 | collectCoverage: true, 8 | coveragePathIgnorePatterns: [ 9 | '/node_modules/', 10 | '/test/', 11 | '/src/utils/', 12 | ], 13 | transformIgnorePatterns: ['node_modules/(?!(superjson)/)'], 14 | transform: { 15 | '^.+\\.(ts|tsx)?$': [ 16 | 'ts-jest', 17 | { configFile: './babel.config.testing.js' }, 18 | ], 19 | '^.+\\.(js|jsx)$': [ 20 | 'babel-jest', 21 | { configFile: './babel.config.testing.js' }, 22 | ], 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "npm run dev", 21 | "serverReadyAction": { 22 | "pattern": "- Local:.+(https?://.+)", 23 | "uriFormat": "%s", 24 | "action": "debugWithChrome" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/app/components/loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Spinner } from '@primer/react' 2 | import { Stack } from '@primer/react/lib-esm/Stack' 3 | 4 | interface LoadingProps { 5 | message: string 6 | } 7 | 8 | export const Loading = ({ message }: LoadingProps) => { 9 | return ( 10 | 19 | 20 | 21 | 22 | 23 | {message} 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/server/git/schema.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from 'bot/rest' 2 | import { z } from 'zod' 3 | 4 | export const SyncReposSchema = z.object({ 5 | source: z.object({ 6 | org: z.string(), 7 | repo: z.string(), 8 | branch: z.string(), 9 | octokit: z.object({ 10 | accessToken: z.string(), 11 | octokit: z.instanceof(Octokit), 12 | installationId: z.string(), 13 | }), 14 | }), 15 | destination: z.object({ 16 | org: z.string(), 17 | repo: z.string(), 18 | branch: z.string(), 19 | octokit: z.object({ 20 | accessToken: z.string(), 21 | octokit: z.instanceof(Octokit), 22 | installationId: z.string(), 23 | }), 24 | }), 25 | removeHeadMergeCommit: z.boolean(), 26 | }) 27 | 28 | export type SyncReposSchema = z.TypeOf 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Run Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 25 | with: 26 | node-version-file: '.nvmrc' 27 | cache: 'npm' 28 | 29 | - name: Install Dependencies 30 | run: npm ci --ignore-scripts 31 | 32 | - name: Run Lint 33 | run: npm run lint 34 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Jest Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 25 | with: 26 | node-version-file: '.nvmrc' 27 | cache: 'npm' 28 | 29 | - name: Install Dependencies 30 | run: npm ci --ignore-scripts 31 | 32 | - name: Run Jest Tests 33 | run: npm test 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Run Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 25 | with: 26 | node-version-file: '.nvmrc' 27 | cache: 'npm' 28 | 29 | - name: Install Dependencies 30 | run: npm ci --ignore-scripts 31 | 32 | - name: Run Build 33 | run: NEXT_TELEMETRY_DISABLED=1 npm run build 34 | -------------------------------------------------------------------------------- /src/app/components/breadcrumbs/OrgBreadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Breadcrumbs } from '@primer/react' 2 | import { OrgData } from 'hooks/useOrganization' 3 | 4 | interface ForkBreadcrumbsProps { 5 | orgData: OrgData 6 | } 7 | 8 | export const OrgBreadcrumbs = ({ orgData }: ForkBreadcrumbsProps) => { 9 | if (!orgData) { 10 | return null 11 | } 12 | 13 | return ( 14 | 15 | 16 | 17 | All organizations 18 | 19 | 20 | {orgData?.login} 21 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/server/config/controller.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server' 2 | import { getConfig } from '../../bot/config' 3 | import { logger } from '../../utils/logger' 4 | import { GetConfigSchema } from './schema' 5 | 6 | const configApiLogger = logger.getSubLogger({ name: 'org-api' }) 7 | 8 | // Get the config values for the given org 9 | export const getConfigHandler = async ({ 10 | input, 11 | }: { 12 | input: GetConfigSchema 13 | }) => { 14 | try { 15 | configApiLogger.info('Fetching config', { ...input }) 16 | 17 | const config = await getConfig(input.orgId) 18 | 19 | configApiLogger.debug('Fetched config', config) 20 | 21 | return config 22 | } catch (error) { 23 | configApiLogger.error('Error fetching config', { error }) 24 | 25 | throw new TRPCError({ 26 | code: 'INTERNAL_SERVER_ERROR', 27 | message: (error as Error).message, 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/server/octokit/controller.ts: -------------------------------------------------------------------------------- 1 | import { appOctokit } from '../../bot/octokit' 2 | import { logger } from '../../utils/logger' 3 | import { CheckInstallationSchema } from './schema' 4 | 5 | const octokitApiLogger = logger.getSubLogger({ name: 'octokit-api' }) 6 | 7 | // Checks if the app is installed in the org 8 | export const checkInstallationHandler = async ({ 9 | input, 10 | }: { 11 | input: CheckInstallationSchema 12 | }) => { 13 | try { 14 | octokitApiLogger.info('Checking installation', { input }) 15 | 16 | const installationId = await appOctokit().rest.apps.getOrgInstallation({ 17 | org: input.orgId, 18 | }) 19 | 20 | if (installationId.data.id) { 21 | return { installed: true } 22 | } 23 | 24 | return { installed: false } 25 | } catch (error) { 26 | octokitApiLogger.error('Failed to check app installation', { input, error }) 27 | 28 | return { installed: false } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/trpc-middleware.ts: -------------------------------------------------------------------------------- 1 | import { checkGitHubAppInstallationAuth, checkGitHubAuth } from './auth' 2 | import { Middleware } from './trpc-server' 3 | 4 | export const verifyGitHubAppAuth: Middleware = async (opts) => { 5 | const { ctx, rawInput } = opts 6 | 7 | // Check app authentication 8 | await checkGitHubAppInstallationAuth( 9 | (rawInput as Record)?.accessToken, 10 | (rawInput as Record)?.mirrorOwner, 11 | (rawInput as Record)?.mirrorName, 12 | ) 13 | 14 | return opts.next({ 15 | ctx, 16 | }) 17 | } 18 | 19 | export const verifyAuth: Middleware = async (opts) => { 20 | const { ctx, rawInput } = opts 21 | 22 | // Verify valid github session 23 | await checkGitHubAuth( 24 | ctx.session?.user?.accessToken, 25 | (rawInput as Record)?.orgId, // Fetch orgId if there is one 26 | ) 27 | 28 | return opts.next({ 29 | ctx, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/server/repos/router.ts: -------------------------------------------------------------------------------- 1 | import { procedure, router } from '../../utils/trpc-server' 2 | import { 3 | createMirrorHandler, 4 | deleteMirrorHandler, 5 | editMirrorHandler, 6 | listMirrorsHandler, 7 | } from './controller' 8 | import { 9 | CreateMirrorSchema, 10 | DeleteMirrorSchema, 11 | EditMirrorSchema, 12 | ListMirrorsSchema, 13 | } from './schema' 14 | 15 | const reposRouter = router({ 16 | createMirror: procedure 17 | .input(CreateMirrorSchema) 18 | .mutation(({ input }) => createMirrorHandler({ input })), 19 | listMirrors: procedure 20 | .input(ListMirrorsSchema) 21 | .query(({ input }) => listMirrorsHandler({ input })), 22 | editMirror: procedure 23 | .input(EditMirrorSchema) 24 | .mutation(({ input }) => editMirrorHandler({ input })), 25 | deleteMirror: procedure 26 | .input(DeleteMirrorSchema) 27 | .mutation(({ input }) => deleteMirrorHandler({ input })), 28 | }) 29 | 30 | export default reposRouter 31 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FetchCreateContextFnOptions, 3 | fetchRequestHandler, 4 | } from '@trpc/server/adapters/fetch' 5 | import { getServerSession } from 'next-auth' 6 | import { nextAuthOptions } from '../../auth/lib/nextauth-options' 7 | import { appRouter } from '../trpc-router' 8 | import { logger } from '../../../../utils/logger' 9 | 10 | const trpcLogger = logger.getSubLogger({ name: 'trpc' }) 11 | 12 | const createContext = async ({ 13 | req, 14 | resHeaders, 15 | }: FetchCreateContextFnOptions) => { 16 | const session = await getServerSession(nextAuthOptions) 17 | 18 | return { req, resHeaders, session } 19 | } 20 | 21 | const handler = (request: Request) => { 22 | trpcLogger.info(`incoming request ${request.url}`) 23 | return fetchRequestHandler({ 24 | endpoint: '/api/trpc', 25 | req: request, 26 | router: appRouter, 27 | createContext, 28 | }) 29 | } 30 | 31 | export { handler as GET, handler as POST } 32 | -------------------------------------------------------------------------------- /src/server/repos/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const CreateMirrorSchema = z.object({ 4 | orgId: z.string(), 5 | forkRepoOwner: z.string(), 6 | forkRepoName: z.string(), 7 | forkId: z.string(), 8 | newRepoName: z.string().max(100), 9 | newBranchName: z.string(), 10 | }) 11 | 12 | export const ListMirrorsSchema = z.object({ 13 | orgId: z.string(), 14 | forkName: z.string(), 15 | }) 16 | 17 | export const EditMirrorSchema = z.object({ 18 | orgId: z.string(), 19 | mirrorName: z.string(), 20 | newMirrorName: z.string().max(100), 21 | }) 22 | 23 | export const DeleteMirrorSchema = z.object({ 24 | orgId: z.string(), 25 | mirrorName: z.string(), 26 | }) 27 | 28 | export type CreateMirrorSchema = z.TypeOf 29 | export type ListMirrorsSchema = z.TypeOf 30 | export type EditMirrorSchema = z.TypeOf 31 | export type DeleteMirrorSchema = z.TypeOf 32 | -------------------------------------------------------------------------------- /src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCProxyClient, httpBatchLink } from '@trpc/client' 2 | import { createTRPCReact } from '@trpc/react-query' 3 | import SuperJSON from 'superjson' 4 | import type { AppRouter } from '../app/api/trpc/trpc-router' 5 | 6 | export const getBaseUrl = () => { 7 | if (typeof window !== 'undefined') 8 | // browser should use relative path 9 | return '' 10 | if (process.env.VERCEL_URL) 11 | // reference for vercel.com deployments 12 | return `https://${process.env.VERCEL_URL}` 13 | if (process.env.NEXTAUTH_URL) 14 | // reference for non-vercel providers 15 | return process.env.NEXTAUTH_URL 16 | // assume localhost 17 | return `http://localhost:${process.env.PORT ?? 3000}` 18 | } 19 | 20 | export const trpc = createTRPCReact() 21 | export const serverTrpc = createTRPCProxyClient({ 22 | transformer: SuperJSON, 23 | links: [ 24 | httpBatchLink({ 25 | url: `${getBaseUrl()}/api/trpc`, 26 | }), 27 | ], 28 | }) 29 | -------------------------------------------------------------------------------- /src/providers/registry-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useServerInsertedHTML } from 'next/navigation' 4 | import React, { useState } from 'react' 5 | import { ServerStyleSheet, StyleSheetManager } from 'styled-components' 6 | 7 | export const StyledComponentsRegistry = ({ 8 | children, 9 | }: { 10 | children: React.ReactNode 11 | }) => { 12 | // Only create stylesheet once with lazy initial state 13 | // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state 14 | const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet()) 15 | 16 | useServerInsertedHTML(() => { 17 | const styles = styledComponentsStyleSheet.getStyleElement() 18 | styledComponentsStyleSheet.instance.clearTag() 19 | return <>{styles} 20 | }) 21 | 22 | if (typeof window !== 'undefined') return <>{children} 23 | 24 | return ( 25 | 26 | {children} 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { AlertIcon } from '@primer/octicons-react' 4 | import { Box, Octicon } from '@primer/react' 5 | import Blankslate from '@primer/react/lib-esm/Blankslate/Blankslate' 6 | 7 | const NotFoundPage = () => { 8 | return ( 9 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Page not found 21 | 22 | This is not the page you're looking for. 23 | 24 | 25 | 26 | Back to home 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | export default NotFoundPage 35 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | 11 | 12 | ## Proposed Changes 13 | 14 | 15 | 16 | ## Readiness Checklist 17 | 18 | ### Author/Contributor 19 | 20 | - [ ] If documentation is needed for this change, has that been included in this pull request 21 | - [ ] run `npm run lint` and fix any linting issues that have been introduced 22 | - [ ] run `npm run test` and run tests 23 | - [ ] If publishing new data to the public (scorecards, security scan results, code quality results, live dashboards, etc.), please request review from `@jeffrey-luszcz` 24 | 25 | ### Reviewer 26 | 27 | - [ ] Label as either `bug`, `documentation`, `enhancement`, `infrastructure`, `maintenance`, or `breaking` 28 | -------------------------------------------------------------------------------- /src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Box } from '@primer/react' 4 | import { Login } from 'app/components/login/Login' 5 | import { useOrgsData } from 'hooks/useOrganizations' 6 | import { useSession } from 'next-auth/react' 7 | import { useRouter } from 'next/navigation' 8 | import { useEffect } from 'react' 9 | 10 | const LoginPage = () => { 11 | const session = useSession() 12 | const orgsData = useOrgsData() 13 | 14 | const router = useRouter() 15 | 16 | useEffect(() => { 17 | if (session.data?.user) { 18 | // if orgs data is still loading, do nothing 19 | if (orgsData.isLoading) { 20 | return 21 | } 22 | 23 | // if user only has one org, go to that org's page 24 | if (orgsData.data?.length === 1) { 25 | router.push(`/${orgsData.data[0].login}`) 26 | return 27 | } 28 | 29 | // otherwise go to home page 30 | router.push('/') 31 | } 32 | }, [session.data?.user, orgsData.isLoading, orgsData.data, router]) 33 | 34 | return {!session.data?.user && } 35 | } 36 | 37 | export default LoginPage 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 GitHub Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/components/flash/AppNotInstalledFlash.tsx: -------------------------------------------------------------------------------- 1 | import { AlertIcon } from '@primer/octicons-react' 2 | import { Box, Flash, Link, Octicon } from '@primer/react' 3 | 4 | interface AppNotInstalledFlashProps { 5 | orgLogin: string 6 | } 7 | 8 | export const AppNotInstalledFlash = ({ 9 | orgLogin, 10 | }: AppNotInstalledFlashProps) => { 11 | return ( 12 | 13 | 14 | 21 | 22 | 23 | 24 | 25 | This organization does not have the required App installed. Visit{' '} 26 | 29 | this page 30 | {' '} 31 | to install the App to the organization. 32 | 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/app/components/breadcrumbs/ForkBreadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Breadcrumbs } from '@primer/react' 2 | import { ForkData } from 'hooks/useFork' 3 | import { OrgData } from 'hooks/useOrganization' 4 | 5 | interface ForkBreadcrumbsProps { 6 | orgData: OrgData 7 | forkData: ForkData 8 | } 9 | 10 | export const ForkBreadcrumbs = ({ 11 | orgData, 12 | forkData, 13 | }: ForkBreadcrumbsProps) => { 14 | if (!orgData || !forkData) { 15 | return null 16 | } 17 | 18 | return ( 19 | 20 | 21 | 22 | All organizations 23 | 24 | 28 | {orgData?.login} 29 | 30 | 31 | {forkData?.name} 32 | 33 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/app/components/flash/ErrorFlash.tsx: -------------------------------------------------------------------------------- 1 | import { AlertIcon, XIcon } from '@primer/octicons-react' 2 | import { Box, Flash, IconButton, Octicon } from '@primer/react' 3 | 4 | interface ErrorFlashProps { 5 | message: string 6 | closeFlash?: () => void 7 | } 8 | 9 | export const ErrorFlash = ({ message, closeFlash }: ErrorFlashProps) => { 10 | return ( 11 | 12 | 19 | 20 | 21 | 22 | {message} 23 | {closeFlash && ( 24 | 29 | 36 | 37 | )} 38 | 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | commit-message: 8 | prefix: 'chore' 9 | prefix-development: 'chore' 10 | include: 'scope' 11 | groups: 12 | dependencies: 13 | applies-to: version-updates 14 | update-types: 15 | - 'minor' 16 | - 'patch' 17 | - package-ecosystem: 'docker' 18 | directory: '/' 19 | schedule: 20 | interval: 'weekly' 21 | commit-message: 22 | prefix: 'chore' 23 | prefix-development: 'chore' 24 | include: 'scope' 25 | groups: 26 | dependencies: 27 | applies-to: version-updates 28 | update-types: 29 | - 'minor' 30 | - 'patch' 31 | - package-ecosystem: 'github-actions' 32 | directory: '/' 33 | schedule: 34 | interval: 'weekly' 35 | commit-message: 36 | prefix: 'chore' 37 | prefix-development: 'chore' 38 | include: 'scope' 39 | groups: 40 | dependencies: 41 | applies-to: version-updates 42 | update-types: 43 | - 'minor' 44 | - 'patch' 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | description: Suggest an idea for this project 4 | labels: 5 | - enhancement 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Is your feature request related to a problem? 10 | description: A clear and concise description of what the problem is. Please describe. 11 | placeholder: | 12 | Ex. I'm always frustrated when [...] 13 | validations: 14 | required: false 15 | 16 | - type: textarea 17 | attributes: 18 | label: Describe the solution you'd like 19 | description: A clear and concise description of what you want to happen. 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | attributes: 25 | label: Describe alternatives you've considered 26 | description: A clear and concise description of any alternative solutions or features you've considered. 27 | validations: 28 | required: false 29 | 30 | - type: textarea 31 | attributes: 32 | label: Additional context 33 | description: Add any other context or screenshots about the feature request here. 34 | validations: 35 | required: false 36 | -------------------------------------------------------------------------------- /src/providers/trpc-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { QueryClientProvider } from '@tanstack/react-query' 4 | import { getFetch, httpBatchLink, loggerLink } from '@trpc/client' 5 | import { useState } from 'react' 6 | import superjson from 'superjson' 7 | import queryClient from '../utils/query-client' 8 | import { getBaseUrl, trpc } from '../utils/trpc' 9 | 10 | export const TrpcProvider = ({ children }: { children: React.ReactNode }) => { 11 | const [trpcClient] = useState(() => 12 | trpc.createClient({ 13 | links: [ 14 | loggerLink({ 15 | enabled: () => true, 16 | }), 17 | httpBatchLink({ 18 | url: `${getBaseUrl()}/api/trpc`, 19 | fetch: async (input, init?) => { 20 | const fetch = getFetch() 21 | return fetch(input, { 22 | ...init, 23 | credentials: 'include', 24 | }) 25 | }, 26 | }), 27 | ], 28 | transformer: superjson, 29 | }), 30 | ) 31 | 32 | return ( 33 | 34 | {children} 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/types/forks.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | // this type is generated from the graphql query to support 4 | // the requirements of the primer datatable component 5 | const ForksObject = z.object({ 6 | organization: z.object({ 7 | repositories: z.object({ 8 | totalCount: z.number(), 9 | nodes: z.array( 10 | z.object({ 11 | databaseId: z.number(), 12 | name: z.string(), 13 | isPrivate: z.boolean(), 14 | updatedAt: z.date(), 15 | owner: z.object({ 16 | avatarUrl: z.string(), 17 | login: z.string(), 18 | }), 19 | parent: z.object({ 20 | name: z.string(), 21 | owner: z.object({ 22 | login: z.string(), 23 | avatarUrl: z.string(), 24 | }), 25 | }), 26 | languages: z.object({ 27 | nodes: z.array( 28 | z.object({ 29 | name: z.string(), 30 | color: z.string(), 31 | }), 32 | ), 33 | }), 34 | refs: z.object({ 35 | totalCount: z.number(), 36 | }), 37 | }), 38 | ), 39 | }), 40 | }), 41 | }) 42 | 43 | export type ForksObject = z.infer 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | description: Create a report to help us improve 4 | labels: 5 | - bug 6 | body: 7 | - type: textarea 8 | attributes: 9 | label: Describe the bug 10 | description: A clear and concise description of what the bug is. 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | attributes: 16 | label: To Reproduce 17 | description: Steps to reproduce the behavior 18 | placeholder: | 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | attributes: 28 | label: Expected behavior 29 | description: A clear and concise description of what you expected to happen. 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | attributes: 35 | label: Screenshots 36 | description: If applicable, add screenshots to help explain your problem. 37 | validations: 38 | required: false 39 | 40 | - type: textarea 41 | attributes: 42 | label: Additional context 43 | description: Add any other context about the problem here. 44 | validations: 45 | required: false 46 | -------------------------------------------------------------------------------- /src/app/components/dialog/DeleteMirrorDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Text } from '@primer/react' 2 | import { Dialog } from '@primer/react/lib-esm/drafts' 3 | 4 | interface DeleteMirrorDialogProps { 5 | orgLogin: string 6 | orgId: string 7 | mirrorName: string 8 | isOpen: boolean 9 | closeDialog: () => void 10 | deleteMirror: (data: { 11 | orgId: string 12 | orgLogin: string 13 | mirrorName: string 14 | }) => void 15 | } 16 | 17 | export const DeleteMirrorDialog = ({ 18 | orgLogin, 19 | orgId, 20 | mirrorName, 21 | isOpen, 22 | closeDialog, 23 | deleteMirror, 24 | }: DeleteMirrorDialogProps) => { 25 | if (!isOpen) { 26 | return null 27 | } 28 | 29 | return ( 30 | deleteMirror({ orgId, orgLogin, mirrorName }), 38 | }, 39 | ]} 40 | onClose={closeDialog} 41 | > 42 | 43 | Are you sure you'd like to delete 44 | 45 | {' '} 46 | {orgLogin}/{mirrorName}? 47 | 48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | pull_request: 9 | 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | jobs: 15 | docker-build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 21 | with: 22 | persist-credentials: false 23 | 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 26 | 27 | - name: Get Git commit timestamps 28 | run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV 29 | 30 | - name: Validate build configuration 31 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 32 | with: 33 | call: check 34 | 35 | - name: Build Docker image 36 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 37 | with: 38 | push: false 39 | tags: private-mirrors 40 | cache-from: type=gha 41 | cache-to: type=gha,mode=max 42 | env: 43 | SOURCE_DATE_EPOCH: ${{ env.TIMESTAMP }} 44 | -------------------------------------------------------------------------------- /src/utils/trpc-server.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server' 2 | import { CreateNextContextOptions } from '@trpc/server/adapters/next' 3 | import { getServerSession } from 'next-auth' 4 | import SuperJSON from 'superjson' 5 | import { nextAuthOptions } from '../app/api/auth/lib/nextauth-options' 6 | import { verifyAuth, verifyGitHubAppAuth } from '../utils/trpc-middleware' 7 | 8 | export const createContext = async (opts: CreateNextContextOptions) => { 9 | const session = await getServerSession(opts.req, opts.res, nextAuthOptions) 10 | 11 | return { 12 | session, 13 | } 14 | } 15 | 16 | // Avoid exporting the entire t-object 17 | // since it's not very descriptive. 18 | // For instance, the use of a t variable 19 | // is common in i18n libraries. 20 | export const t = initTRPC.context().create({ 21 | transformer: SuperJSON, 22 | }) 23 | 24 | // Base router and procedure helpers 25 | export const router = t.router 26 | const publicProcedure = t.procedure 27 | export type Middleware = Parameters<(typeof t.procedure)['use']>[0] 28 | // Used for general user access token verification 29 | export const procedure = publicProcedure.use(verifyAuth) 30 | // Used for GitHub App authentication verification (non-user events like 'push') 31 | export const gitProcedure = publicProcedure.use(verifyGitHubAppAuth) 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | workflow_dispatch: 6 | pull_request_target: 7 | types: 8 | - closed 9 | branches: 10 | - main 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | release: 17 | permissions: 18 | contents: write 19 | pull-requests: read 20 | uses: github/ospo-reusable-workflows/.github/workflows/release.yaml@6f158f242fe68adb5a2698ef47e06dac07ac7e71 21 | with: 22 | publish: true 23 | release-config-name: release-drafter.yml 24 | secrets: 25 | github-token: ${{ secrets.GITHUB_TOKEN }} 26 | release_image: 27 | needs: release 28 | permissions: 29 | contents: read 30 | packages: write 31 | id-token: write 32 | attestations: write 33 | uses: github/ospo-reusable-workflows/.github/workflows/release-image.yaml@6f158f242fe68adb5a2698ef47e06dac07ac7e71 34 | with: 35 | image-name: ${{ github.repository }} 36 | full-tag: ${{ needs.release.outputs.full-tag }} 37 | short-tag: ${{ needs.release.outputs.short-tag }} 38 | create-attestation: true 39 | secrets: 40 | github-token: ${{ secrets.GITHUB_TOKEN }} 41 | image-registry: ghcr.io 42 | image-registry-username: ${{ github.actor }} 43 | image-registry-password: ${{ secrets.GITHUB_TOKEN }} 44 | -------------------------------------------------------------------------------- /src/hooks/useOrganizations.tsx: -------------------------------------------------------------------------------- 1 | import { personalOctokit } from 'bot/octokit' 2 | import { useSession } from 'next-auth/react' 3 | import { useEffect, useState } from 'react' 4 | 5 | const getOrganizationsData = async (accessToken: string) => { 6 | const octokit = personalOctokit(accessToken) 7 | return await octokit.rest.orgs.listForAuthenticatedUser() 8 | } 9 | 10 | export const useOrgsData = () => { 11 | const session = useSession() 12 | const accessToken = session.data?.user.accessToken 13 | 14 | const [organizationData, setOrganizationData] = useState( 15 | null, 16 | ) 17 | const [isLoading, setIsLoading] = useState(true) 18 | const [error, setError] = useState(null) 19 | 20 | useEffect(() => { 21 | if (!accessToken) { 22 | return 23 | } 24 | 25 | setIsLoading(true) 26 | setError(null) 27 | 28 | getOrganizationsData(accessToken) 29 | .then((orgs) => { 30 | setOrganizationData(orgs.data) 31 | }) 32 | .catch((error: Error) => { 33 | setError(error) 34 | }) 35 | .finally(() => { 36 | setIsLoading(false) 37 | }) 38 | }, [accessToken]) 39 | 40 | return { 41 | data: organizationData, 42 | isLoading, 43 | error, 44 | } 45 | } 46 | 47 | export type OrgsData = Awaited>['data'] 48 | -------------------------------------------------------------------------------- /test/server/config.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | // load private key before importing the code that uses it 5 | 6 | process.env.PRIVATE_KEY = fs.readFileSync( 7 | path.join(__dirname, '../fixtures/mock-cert.pem'), 8 | 'utf-8', 9 | ) 10 | 11 | import * as config from '../../src/bot/config' 12 | import * as auth from '../../src/utils/auth' 13 | import configRouter from '../../src/server/config/router' 14 | import { Octomock } from '../octomock' 15 | import { createTestContext } from '../utils/auth' 16 | 17 | const om = new Octomock() 18 | 19 | jest.mock('../../src/bot/config') 20 | 21 | jest.spyOn(auth, 'checkGitHubAuth').mockResolvedValue() 22 | 23 | describe('Config router', () => { 24 | beforeEach(() => { 25 | om.resetMocks() 26 | jest.resetAllMocks() 27 | }) 28 | 29 | it('should fetch the values from the config', async () => { 30 | const caller = configRouter.createCaller(createTestContext()) 31 | 32 | const configSpy = jest.spyOn(config, 'getConfig').mockResolvedValue({ 33 | publicOrg: 'github', 34 | privateOrg: 'github-test', 35 | }) 36 | 37 | const res = await caller.getConfig({ 38 | orgId: 'github', 39 | }) 40 | 41 | expect(res).toEqual({ 42 | publicOrg: 'github', 43 | privateOrg: 'github-test', 44 | }) 45 | expect(configSpy).toHaveBeenCalledTimes(1) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/app/components/flash/SuccessFlash.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, XIcon } from '@primer/octicons-react' 2 | import { Box, Flash, IconButton, Link, Octicon } from '@primer/react' 3 | 4 | interface SuccessFlashProps { 5 | message: string 6 | mirrorUrl: string 7 | orgLogin: string 8 | mirrorName: string 9 | closeFlash: () => void 10 | } 11 | 12 | export const SuccessFlash = ({ 13 | message, 14 | mirrorUrl, 15 | orgLogin, 16 | mirrorName, 17 | closeFlash, 18 | }: SuccessFlashProps) => { 19 | return ( 20 | 21 | 28 | 29 | 30 | 31 | 32 | {message}{' '} 33 | 34 | {orgLogin}/{mirrorName} 35 | 36 | . 37 | 38 | 43 | 50 | 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/app/components/header/OrgHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Link, Pagehead, Spinner, Text } from '@primer/react' 2 | import { Stack } from '@primer/react/lib-esm/Stack' 3 | import { OrgData } from 'hooks/useOrganization' 4 | 5 | interface OrgHeaderProps { 6 | orgData: OrgData 7 | } 8 | 9 | export const OrgHeader = ({ orgData }: OrgHeaderProps) => { 10 | return ( 11 | 12 | {orgData ? ( 13 | 14 | 15 | 16 | 17 | 18 | 24 | {orgData.login} 25 | 26 | 27 | 28 | ) : ( 29 | 30 | 31 | 32 | 33 | 34 | 37 | Loading organization data... 38 | 39 | 40 | 41 | )} 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /test/config.test.ts: -------------------------------------------------------------------------------- 1 | import * as config from '../src/bot/config' 2 | import { Octomock } from './octomock' 3 | const om = new Octomock() 4 | 5 | jest.mock('../src/bot/octokit', () => ({ 6 | generateAppAccessToken: async () => 'fake-token', 7 | appOctokit: () => om.getOctokitImplementation(), 8 | installationOctokit: () => om.getOctokitImplementation(), 9 | })) 10 | 11 | describe('PMA Config', () => { 12 | beforeEach(() => { 13 | jest.resetAllMocks() 14 | delete process.env.PUBLIC_ORG 15 | delete process.env.PRIVATE_ORG 16 | }) 17 | 18 | it('should use env variables when they are available', async () => { 19 | // set the env variables 20 | process.env.PUBLIC_ORG = 'github' 21 | process.env.PRIVATE_ORG = 'github-test' 22 | 23 | const res = await config.getConfig() 24 | 25 | expect(res).toEqual({ 26 | publicOrg: 'github', 27 | privateOrg: 'github-test', 28 | }) 29 | }) 30 | 31 | it('should use the public org for both values when only PUBLIC_ORG provided', async () => { 32 | // set the env variables 33 | process.env.PUBLIC_ORG = 'github' 34 | 35 | const res = await config.getConfig() 36 | 37 | expect(res).toEqual({ 38 | publicOrg: 'github', 39 | privateOrg: 'github', 40 | }) 41 | }) 42 | 43 | it('should throw an error when no env and no org id provided', async () => { 44 | await config.getConfig().catch((error) => { 45 | expect(error.message).toContain('Organization ID is required') 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # GitHub App Details 2 | APP_ID=12345 3 | GITHUB_CLIENT_ID=Iv1.12345def 4 | GITHUB_CLIENT_SECRET=clientsecret 5 | 6 | # Private key for the GitHub App (base64 encoded or raw) 7 | PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nYOUR KEY HERE WITH \n INCLUDED=\n-----END RSA PRIVATE KEY-----\n 8 | 9 | # Auth configs 10 | NEXTAUTH_SECRET=bad-secret 11 | NEXTAUTH_URL=http://localhost:3000 12 | # A comma-separated list of GitHub usernames that are allowed to access the app 13 | ALLOWED_HANDLES= 14 | # A comma-separated list of GitHub orgs that are allowed to access the app 15 | ALLOWED_ORGS= 16 | 17 | # This is used to sign payloads from github, see this doc for more info 18 | # https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries 19 | WEBHOOK_SECRET=bad-secret 20 | 21 | # Use `trace` to get verbose logging or `info` to show less 22 | LOGGING_LEVEL=debug 23 | 24 | # Used for settings various configuration in the app 25 | NODE_ENV=development 26 | 27 | # Used for GHEC configs, where private mirrors are kept in a different org 28 | PUBLIC_ORG= 29 | PRIVATE_ORG= 30 | 31 | # Used to skip branch protection creation if organization level branch protections are used instead 32 | SKIP_BRANCH_PROTECTION_CREATION= 33 | 34 | # Used to create mirrors with internal visibility instead of private for use in GitHub Enterprise Cloud organizations 35 | CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY= 36 | 37 | # Used for syncing logic to avoid having EMU users listed as committer 38 | DELETE_INTERNAL_MERGE_COMMITS_ON_SYNC= 39 | -------------------------------------------------------------------------------- /src/app/components/search/Search.tsx: -------------------------------------------------------------------------------- 1 | import { SearchIcon, XCircleFillIcon } from '@primer/octicons-react' 2 | import { Box, FormControl, TextInput } from '@primer/react' 3 | import { ChangeEvent } from 'react' 4 | 5 | interface SearchProps { 6 | placeholder: string 7 | searchValue: string 8 | setSearchValue: (value: string) => void 9 | } 10 | 11 | export const Search = ({ 12 | placeholder, 13 | searchValue, 14 | setSearchValue, 15 | }: SearchProps) => { 16 | const handleChange = (event: ChangeEvent) => { 17 | setSearchValue(event.target.value) 18 | } 19 | 20 | return ( 21 | 29 | 30 | Search 31 | { 41 | setSearchValue('') 42 | }} 43 | icon={XCircleFillIcon} 44 | aria-label="Clear input" 45 | sx={{ 46 | color: 'fg.subtle', 47 | }} 48 | /> 49 | } 50 | /> 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/app/context/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | 'use client' 3 | 4 | import { Session } from 'next-auth' 5 | import { SessionProvider, signOut, useSession } from 'next-auth/react' 6 | 7 | import { ReactNode, useEffect } from 'react' 8 | import { logger } from 'utils/logger' 9 | 10 | const authProviderLogger = logger.getSubLogger({ name: 'auth-provider' }) 11 | 12 | const VerifiedAuthProvider = ({ children }: { children: ReactNode }) => { 13 | const session = useSession() 14 | 15 | // sign user out if session is expired 16 | useEffect(() => { 17 | if (!session || session.status === 'loading') { 18 | return 19 | } 20 | 21 | if (session.data?.error === 'RefreshAccessTokenError') { 22 | authProviderLogger.error('Could not refresh access token - signing out') 23 | signOut() 24 | } 25 | 26 | if (session.data && new Date(session.data.expires) < new Date()) { 27 | authProviderLogger.info('session expired - signing out') 28 | signOut() 29 | } 30 | }, [ 31 | session, 32 | session.status, 33 | session.data, 34 | session.data?.error, 35 | session.data?.expires, 36 | ]) 37 | 38 | return children 39 | } 40 | 41 | export const AuthProvider = ({ 42 | children, 43 | session, 44 | }: { 45 | children: ReactNode 46 | session: Session | null 47 | }) => { 48 | return ( 49 | 50 | {children} 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine@sha256:c06bea602e410a3321622c7782eb35b0afb7899d9e28300937ebf2e521902555 AS deps 2 | RUN apk add --no-cache libc6-compat 3 | WORKDIR /app 4 | 5 | COPY package.json package-lock.json ./ 6 | RUN npm install --omit=dev 7 | 8 | FROM node:22-alpine@sha256:c06bea602e410a3321622c7782eb35b0afb7899d9e28300937ebf2e521902555 AS builder 9 | WORKDIR /app 10 | COPY --from=deps /app/node_modules ./node_modules 11 | COPY . . 12 | 13 | ENV NEXT_TELEMETRY_DISABLED=1 14 | 15 | RUN npm run build 16 | 17 | FROM node:22-alpine@sha256:c06bea602e410a3321622c7782eb35b0afb7899d9e28300937ebf2e521902555 AS runner 18 | LABEL maintainer="@github" \ 19 | org.opencontainers.image.url="https://github.com/github-community-projects/private-mirrors" \ 20 | org.opencontainers.image.source="https://github.com/github-community-projects/private-mirrors" \ 21 | org.opencontainers.image.documentation="https://github.com/github-community-projects/private-mirrors" \ 22 | org.opencontainers.image.vendor="GitHub Community Projects" \ 23 | org.opencontainers.image.description="A GitHub App that allows you to contribute upstream using private mirrors of public projects." 24 | 25 | RUN apk add --no-cache git 26 | WORKDIR /app 27 | 28 | ENV NODE_ENV=production 29 | ENV NEXT_TELEMETRY_DISABLED=1 30 | 31 | RUN addgroup --system --gid 1001 nodejs 32 | RUN adduser --system --uid 1001 nextjs 33 | 34 | COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next 35 | COPY --from=builder /app/node_modules ./node_modules 36 | COPY --from=builder /app/package.json ./package.json 37 | 38 | USER nextjs 39 | 40 | EXPOSE 3000 41 | 42 | ENV PORT=3000 43 | 44 | CMD ["npm", "start"] 45 | -------------------------------------------------------------------------------- /src/app/components/header/MainHeader.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | 'use client' 3 | 4 | import { MarkGithubIcon } from '@primer/octicons-react' 5 | import { Avatar, Button, Header, Octicon, Text } from '@primer/react' 6 | import { Stack } from '@primer/react/lib-esm/Stack' 7 | import { signOut, useSession } from 'next-auth/react' 8 | 9 | export const MainHeader = () => { 10 | const session = useSession() 11 | 12 | return ( 13 |
20 | 21 | 22 | 23 | 24 | 25 | Private Mirrors 26 | 27 | 28 | {session && session.data?.user && ( 29 | 30 | 31 | 32 | 39 | 40 | 41 | {session.data?.user.image && ( 42 | 43 | )} 44 | 45 | 46 | 47 | )} 48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Scorecard supply-chain security 3 | on: 4 | workflow_dispatch: 5 | # For Branch-Protection check (for repo branch protection or rules). 6 | # Only the default branch is supported. See 7 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 8 | branch_protection_rule: 9 | # To guarantee Maintained check is occasionally updated. See 10 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 11 | schedule: 12 | - cron: '29 11 * * 6' 13 | push: 14 | branches: ['main'] 15 | 16 | permissions: read-all 17 | 18 | jobs: 19 | analysis: 20 | name: Merge to Main Scorecard analysis 21 | runs-on: ubuntu-latest 22 | permissions: 23 | security-events: write 24 | id-token: write 25 | 26 | steps: 27 | - name: 'Checkout code' 28 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | with: 30 | persist-credentials: false 31 | 32 | - name: 'Run analysis' 33 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 34 | with: 35 | results_file: results.sarif 36 | results_format: sarif 37 | publish_results: true 38 | - name: 'Upload artifact' 39 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 40 | with: 41 | name: SARIF file 42 | path: results.sarif 43 | retention-days: 5 44 | - name: 'Upload to code-scanning' 45 | uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 46 | with: 47 | sarif_file: results.sarif 48 | -------------------------------------------------------------------------------- /test/fixtures/mock-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAli7V49NdZe+XYC1pLaHM0te8kiDmZBJ1u2HJHN8GdbROB6NO 3 | VpC3xK7NxQn6xpvZ9ux20NvcDvGle+DOptZztBH+np6h2jZQ1/kD1yG1eQvVH4th 4 | /9oqHuIjmIfO8lIe4Hyd5Fw5xHkGqVETTGR+0c7kdZIlHmkOregUGtMYZRUi4YG+ 5 | q0w+uFemiHpGKXbeCIAvkq7aIkisEzvPWfSyYdA6WJHpxFk7tD7D8VkzABLVRHCq 6 | AuyqPG39BhGZcGLXx5rGK56kDBJkyTR1t3DkHpwX+JKNG5UYNwOG4LcQj1fteeta 7 | TdkYUMjIyWbanlMYyC+dq7B5fe7el99jXQ1gXwIDAQABAoIBADKfiPOpzKLOtzzx 8 | MbHzB0LO+75aHq7+1faayJrVxqyoYWELuB1P3NIMhknzyjdmU3t7S7WtVqkm5Twz 9 | lBUC1q+NHUHEgRQ4GNokExpSP4SU63sdlaQTmv0cBxmkNarS6ZuMBgDy4XoLvaYX 10 | MSUf/uukDLhg0ehFS3BteVFtdJyllhDdTenF1Nb1rAeN4egt8XLsE5NQDr1szFEG 11 | xH5lb+8EDtzgsGpeIddWR64xP0lDIKSZWst/toYKWiwjaY9uZCfAhvYQ1RsO7L/t 12 | sERmpYgh+rAZUh/Lr98EI8BPSPhzFcSHmtqzzejvC5zrZPHcUimz0CGA3YBiLoJX 13 | V1OrxmECgYEAxkd8gpmVP+LEWB3lqpSvJaXcGkbzcDb9m0OPzHUAJDZtiIIf0UmO 14 | nvL68/mzbCHSj+yFjZeG1rsrAVrOzrfDCuXjAv+JkEtEx0DIevU1u60lGnevOeky 15 | r8Be7pmymFB9/gzQAd5ezIlTv/COgoO986a3h1yfhzrrzbqSiivw308CgYEAwecI 16 | aZZwqH3GifR+0+Z1B48cezA5tC8LZt5yObGzUfxKTWy30d7lxe9N59t0KUVt/QL5 17 | qVkd7mqGzsUMyxUN2U2HVnFTWfUFMhkn/OnCnayhILs8UlCTD2Xxoy1KbQH/9FIr 18 | xf0pbMNJLXeGfyRt/8H+BzSZKBw9opJBWE4gqfECgYBp9FdvvryHuBkt8UQCRJPX 19 | rWsRy6pY47nf11mnazpZH5Cmqspv3zvMapF6AIxFk0leyYiQolFWvAv+HFV5F6+t 20 | Si1mM8GCDwbA5zh6pEBDewHhw+UqMBh63HSeUhmi1RiOwrAA36CO8i+D2Pt+eQHv 21 | ir52IiPJcs4BUNrv5Q1BdwKBgBHgVNw3LGe8QMOTMOYkRwHNZdjNl2RPOgPf2jQL 22 | d/bFBayhq0jD/fcDmvEXQFxVtFAxKAc+2g2S8J67d/R5Gm/AQAvuIrsWZcY6n38n 23 | pfOXaLt1x5fnKcevpFlg4Y2vM4O416RHNLx8PJDehh3Oo/2CSwMrDDuwbtZAGZok 24 | icphAoGBAI74Tisfn+aeCZMrO8KxaWS5r2CD1KVzddEMRKlJvSKTY+dOCtJ+XKj1 25 | OsZdcDvDC5GtgcywHsYeOWHldgDWY1S8Z/PUo4eK9qBXYBXp3JEZQ1dqzFdz+Txi 26 | rBn2WsFLsxV9j2/ugm0PqWVBcU2bPUCwvaRu3SOms2teaLwGCkhr 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: 'v$RESOLVED_VERSION' 3 | tag-template: 'v$RESOLVED_VERSION' 4 | template: | 5 | # Changelog 6 | $CHANGES 7 | 8 | See details of [all code changes](https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION) since previous release 9 | 10 | categories: 11 | - title: '🚀 Features' 12 | labels: 13 | - 'feature' 14 | - 'enhancement' 15 | - title: '🐛 Bug Fixes' 16 | labels: 17 | - 'fix' 18 | - 'bugfix' 19 | - 'bug' 20 | - title: '🧰 Maintenance' 21 | labels: 22 | - 'infrastructure' 23 | - 'automation' 24 | - 'documentation' 25 | - 'dependencies' 26 | - 'maintenance' 27 | - 'revert' 28 | - title: '🏎 Performance' 29 | label: 'performance' 30 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 31 | version-resolver: 32 | major: 33 | labels: 34 | - 'breaking' 35 | - 'major' 36 | minor: 37 | labels: 38 | - 'enhancement' 39 | - 'feature' 40 | - 'minor' 41 | patch: 42 | labels: 43 | - 'fix' 44 | - 'documentation' 45 | - 'maintenance' 46 | - 'patch' 47 | default: patch 48 | autolabeler: 49 | - label: 'automation' 50 | title: 51 | - '/^(build|ci|perf|refactor|test).*/i' 52 | - label: 'enhancement' 53 | title: 54 | - '/^(style).*/i' 55 | - label: 'documentation' 56 | title: 57 | - '/^(docs).*/i' 58 | - label: 'feature' 59 | title: 60 | - '/^(feat).*/i' 61 | - label: 'fix' 62 | title: 63 | - '/^(fix).*/i' 64 | - label: 'infrastructure' 65 | title: 66 | - '/^(infrastructure).*/i' 67 | - label: 'maintenance' 68 | title: 69 | - '/^(chore|maintenance).*/i' 70 | - label: 'revert' 71 | title: 72 | - '/^(revert).*/i' 73 | -------------------------------------------------------------------------------- /src/hooks/useFork.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { personalOctokit } from 'bot/octokit' 3 | import { useSession } from 'next-auth/react' 4 | import { useParams } from 'next/navigation' 5 | import { Octokit } from 'octokit' 6 | import { useEffect, useState } from 'react' 7 | 8 | const getForkById = async (accessToken: string, repoId: string) => { 9 | try { 10 | return ( 11 | await personalOctokit(accessToken).request('GET /repositories/:id', { 12 | id: repoId, 13 | }) 14 | ).data as Awaited>['data'] 15 | } catch (error) { 16 | console.error('Error fetching fork', { error }) 17 | return null 18 | } 19 | } 20 | 21 | export const useForkData = () => { 22 | const session = useSession() 23 | const accessToken = session.data?.user.accessToken 24 | 25 | const { organizationId, forkId } = useParams() 26 | 27 | const [fork, setFork] = useState 29 | > | null>(null) 30 | const [isLoading, setIsLoading] = useState(true) 31 | const [error, setError] = useState(null) 32 | 33 | useEffect(() => { 34 | if (!organizationId || !forkId || !accessToken) { 35 | return 36 | } 37 | 38 | setIsLoading(true) 39 | setError(null) 40 | 41 | getForkById(accessToken, forkId as string) 42 | .then((fork) => { 43 | setFork(fork) 44 | }) 45 | .catch((error: Error) => { 46 | setError(error) 47 | }) 48 | .finally(() => { 49 | setIsLoading(false) 50 | }) 51 | }, [accessToken, organizationId, forkId]) 52 | 53 | return { 54 | data: fork, 55 | isLoading, 56 | error, 57 | } 58 | } 59 | 60 | export type ForkData = Awaited> 61 | -------------------------------------------------------------------------------- /src/app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { MarkGithubIcon } from '@primer/octicons-react' 4 | import { Box, Button, Octicon, Text } from '@primer/react' 5 | import { useRouter } from 'next/navigation' 6 | 7 | const ErrorPage = () => { 8 | const router = useRouter() 9 | 10 | return ( 11 | 28 | 29 | 30 | 31 | 38 | 39 | 40 | Access denied 41 | 42 | 43 | 44 | Reach out to your organization admin to get access 45 | 46 | 47 | 48 | 49 | 56 | 57 | 58 | 59 | ) 60 | } 61 | 62 | export default ErrorPage 63 | -------------------------------------------------------------------------------- /src/app/components/login/Login.tsx: -------------------------------------------------------------------------------- 1 | import { MarkGithubIcon } from '@primer/octicons-react' 2 | import { Box, Button, Octicon, Text } from '@primer/react' 3 | import { signIn } from 'next-auth/react' 4 | 5 | const signInWithGitHub = async () => { 6 | await signIn('github') 7 | } 8 | 9 | export const Login = () => { 10 | return ( 11 | 28 | 29 | 30 | 31 | 38 | 39 | 40 | Sign in to get started. 41 | 42 | 43 | 44 | Private Mirrors 45 | 46 | 47 | 48 | 49 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { BaseStyles, Box, ThemeProvider } from '@primer/react' 2 | import { StyledComponentsRegistry } from '../providers/registry-provider' 3 | import { TrpcProvider } from '../providers/trpc-provider' 4 | import { MainHeader } from './components/header/MainHeader' 5 | import { AuthProvider } from './context/AuthProvider' 6 | import { getServerSession } from 'next-auth' 7 | import { nextAuthOptions } from './api/auth/lib/nextauth-options' 8 | 9 | const RootLayout = async ({ children }: { children: React.ReactNode }) => { 10 | const session = await getServerSession(nextAuthOptions) 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 26 | 35 | 36 | 37 | 38 | {children} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | export default RootLayout 52 | -------------------------------------------------------------------------------- /src/hooks/useOrganization.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { personalOctokit } from 'bot/octokit' 3 | import { useSession } from 'next-auth/react' 4 | import { useParams, useRouter } from 'next/navigation' 5 | import { useEffect, useState } from 'react' 6 | 7 | export const getOrganizationData = async ( 8 | accessToken: string, 9 | orgId: string, 10 | ) => { 11 | try { 12 | return (await personalOctokit(accessToken).rest.orgs.get({ org: orgId })) 13 | .data 14 | } catch (error) { 15 | console.error('Error fetching organization', { error }) 16 | return null 17 | } 18 | } 19 | 20 | export const useOrgData = () => { 21 | const router = useRouter() 22 | 23 | const { organizationId } = useParams() 24 | 25 | const session = useSession() 26 | const accessToken = session.data?.user.accessToken 27 | 28 | const [orgData, setOrgData] = useState 30 | > | null>(null) 31 | const [isLoading, setIsLoading] = useState(true) 32 | const [error, setError] = useState(null) 33 | 34 | useEffect(() => { 35 | if (!accessToken || !organizationId) { 36 | return 37 | } 38 | 39 | setIsLoading(true) 40 | setError(null) 41 | 42 | getOrganizationData(accessToken, organizationId as string) 43 | .then((orgData) => { 44 | if (!orgData) { 45 | router.push('/_error') 46 | } 47 | 48 | setOrgData(orgData) 49 | }) 50 | .catch((error: Error) => { 51 | setError(error) 52 | }) 53 | .finally(() => { 54 | setIsLoading(false) 55 | }) 56 | }, [organizationId, accessToken, router]) 57 | 58 | return { 59 | data: orgData, 60 | isLoading, 61 | error, 62 | } 63 | } 64 | 65 | export type OrgData = Awaited> 66 | -------------------------------------------------------------------------------- /test/server/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { healthCheckerRouter } from '../../src/app/api/trpc/trpc-router' 2 | import { Octomock } from '../octomock' 3 | import { createTestContext } from '../utils/auth' 4 | import { t } from '../../src/utils/trpc-server' 5 | const om = new Octomock() 6 | 7 | jest.mock('../../src/bot/octokit', () => ({ 8 | personalOctokit: () => om.getOctokitImplementation(), 9 | })) 10 | 11 | describe('Git router', () => { 12 | beforeEach(() => { 13 | om.resetMocks() 14 | jest.resetAllMocks() 15 | }) 16 | 17 | it('should allow users that are authenticated', async () => { 18 | const caller = 19 | t.createCallerFactory(healthCheckerRouter)(createTestContext()) 20 | 21 | om.mockFunctions.rest.users.getAuthenticated.mockResolvedValue({ 22 | status: 200, 23 | data: { 24 | login: 'test-user', 25 | }, 26 | }) 27 | 28 | const res = await caller.healthChecker() 29 | 30 | expect(res).toEqual('ok') 31 | 32 | expect(om.mockFunctions.rest.users.getAuthenticated).toHaveBeenCalledTimes( 33 | 1, 34 | ) 35 | }) 36 | 37 | it('should throw on invalid sessions', async () => { 38 | const caller = t.createCallerFactory(healthCheckerRouter)( 39 | createTestContext({ 40 | user: { 41 | name: 'fake-username', 42 | email: 'fake-email', 43 | image: 'fake-image', 44 | accessToken: 'bad-token', 45 | }, 46 | expires: new Date('2030-01-01').toISOString(), 47 | }), 48 | ) 49 | 50 | om.mockFunctions.rest.users.getAuthenticated.mockResolvedValue({ 51 | status: 401, 52 | data: { 53 | message: 'Bad credentials', 54 | }, 55 | }) 56 | 57 | await caller.healthChecker().catch((error) => { 58 | expect(error.code).toContain('UNAUTHORIZED') 59 | }) 60 | 61 | expect(om.mockFunctions.rest.users.getAuthenticated).toHaveBeenCalledTimes( 62 | 1, 63 | ) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /test/app.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | 3 | // Requiring our app implementation 4 | import { Probot, ProbotOctokit } from 'probot' 5 | import app from '../src/bot' 6 | 7 | // Requiring our fixtures 8 | import fs from 'fs' 9 | import path from 'path' 10 | import payload from './fixtures/installation.created.json' 11 | const issueCreatedBody = { body: 'Thanks for opening this issue!' } 12 | 13 | const privateKey = fs.readFileSync( 14 | path.join(__dirname, 'fixtures/mock-cert.pem'), 15 | 'utf-8', 16 | ) 17 | 18 | describe('Webhooks events', () => { 19 | let probot: Probot 20 | 21 | beforeEach(() => { 22 | nock.disableNetConnect() 23 | probot = new Probot({ 24 | appId: 123, 25 | privateKey, 26 | // disable request throttling and retries for testing 27 | Octokit: ProbotOctokit.defaults({ 28 | retry: { enabled: false }, 29 | throttle: { enabled: false }, 30 | }), 31 | }) 32 | // Load our app into probot 33 | probot.load(app) 34 | }) 35 | 36 | test('creates a comment when an issue is opened', (done) => { 37 | const mock = nock('https://api.github.com') 38 | // Test that we correctly return a test token 39 | .post('/app/installations/2/access_tokens') 40 | .reply(200, { 41 | token: 'test', 42 | permissions: { 43 | issues: 'write', 44 | }, 45 | }) 46 | 47 | // Test that a comment is posted 48 | .post('/repos/hiimbex/testing-things/issues/1/comments', (body) => { 49 | done(expect(body).toMatchObject(issueCreatedBody)) 50 | return body 51 | }) 52 | .reply(200) 53 | 54 | // Receive a webhook event 55 | probot 56 | .receive({ 57 | id: payload.action, 58 | name: 'installation', 59 | payload: payload as any, 60 | }) 61 | .then(() => { 62 | expect(mock.pendingMocks()).toStrictEqual([]) 63 | }) 64 | }) 65 | 66 | afterEach(() => { 67 | nock.cleanAll() 68 | nock.enableNetConnect() 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /src/app/components/search/SearchWithCreate.tsx: -------------------------------------------------------------------------------- 1 | import { PlusIcon, SearchIcon, XCircleFillIcon } from '@primer/octicons-react' 2 | import { Box, Button, FormControl, TextInput } from '@primer/react' 3 | import { Stack } from '@primer/react/lib-esm/Stack' 4 | import { ChangeEvent } from 'react' 5 | 6 | interface SearchWithCreateProps { 7 | placeholder: string 8 | createButtonLabel: string 9 | searchValue: string 10 | setSearchValue: (value: string) => void 11 | openCreateDialog: () => void 12 | } 13 | 14 | export const SearchWithCreate = ({ 15 | placeholder, 16 | createButtonLabel, 17 | searchValue, 18 | setSearchValue, 19 | openCreateDialog, 20 | }: SearchWithCreateProps) => { 21 | const handleChange = (event: ChangeEvent) => { 22 | setSearchValue(event.target.value) 23 | } 24 | 25 | return ( 26 | 34 | 35 | 36 | 37 | Search 38 | { 48 | setSearchValue('') 49 | }} 50 | icon={XCircleFillIcon} 51 | aria-label="Clear input" 52 | sx={{ 53 | color: 'fg.subtle', 54 | }} 55 | /> 56 | } 57 | /> 58 | 59 | 60 | 61 | 69 | 70 | 71 | 72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: https://github.com/github-community-projects/private-mirrors/fork 4 | [pr]: https://github.com/github-community-projects/private-mirrors/compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE.md). 10 | 11 | Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 12 | 13 | ## Submitting a pull request 14 | 15 | 1. [Fork][fork] and clone the repository 16 | 1. Configure and install the dependencies: `npm i` 17 | 1. Make sure the tests pass on your machine: `npm t` 18 | 1. Create a new branch: `git checkout -b my-branch-name` 19 | 1. Make your change, add tests, and make sure the tests still pass 20 | 1. Push to your fork and [submit a pull request][pr] 21 | 1. Pat yourself on the back and wait for your pull request to be reviewed and merged. 22 | 23 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 24 | 25 | - Follow the style by fixing any ESLint errors. 26 | - Write tests. 27 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 28 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 29 | 30 | ## Releases 31 | 32 | Releases are automated if a pull request is labelled with our [semver related labels](.github/release-drafter.yml) or with the `vuln` or `release` labels. 33 | 34 | You can also manually initiate a release you can do so through the GitHub Actions UI. If you have permissions to do so, you can navigate to the [Actions tab](https://github.com/github-community-projects/private-mirrors/actions/workflows/release.yml) and select the `Run workflow` button. This will allow you to select the branch to release from and the version to release. 35 | 36 | ## Resources 37 | 38 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 39 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 40 | - [GitHub Help](https://help.github.com) 41 | -------------------------------------------------------------------------------- /src/app/components/header/ForkHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Label, Link, Pagehead, Spinner, Text } from '@primer/react' 2 | import { Stack } from '@primer/react/lib-esm/Stack' 3 | import { ForkData } from 'hooks/useFork' 4 | 5 | interface ForkHeaderProps { 6 | forkData: ForkData 7 | } 8 | 9 | export const ForkHeader = ({ forkData }: ForkHeaderProps) => { 10 | return ( 11 | 12 | {forkData ? ( 13 | 14 | 15 | 21 | 22 | 23 | 24 | 35 | {forkData.organization?.login}/{forkData.name} 36 | 37 | 40 | 41 | 42 | 43 | Forked from{' '} 44 | 50 | {forkData.parent?.owner.login}/{forkData.parent?.name} 51 | 52 | 53 | 54 | 55 | 56 | ) : ( 57 | 58 | 59 | 60 | 61 | 62 | 65 | Loading fork data... 66 | 67 | 68 | 69 | )} 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | ## Overview 4 | 5 | The project is a Node.js application that uses the [Probot framework](https://probot.github.io/) to listen for events from GitHub. It uses a GitHub App to perform elevated actions on behalf of the user while the user has little or no permissions at all. 6 | 7 | ## EMU Flow 8 | 9 | ```mermaid 10 | sequenceDiagram 11 | participant U as upstream *Public 12 | box github.com/public-organization 13 | participant F as fork *Public 14 | end 15 | box github.com/emu-organization 16 | participant M as mirror *Private 17 | end 18 | U->>F: User forks upstream into Organization 19 | F-->>F: Bot creates branch protection rules 20 | F-->>F: User requests mirror to be made 21 | F->>M: Bot creates a mirror of the fork 22 | M-->>M: Bot creates branch protection rules 23 | M-->>M: User makes all changes in non-default branches 24 | M-->>M: User merges into `default` branch 25 | M->>F: Bot automatically syncs mirror to fork 26 | F->>U: User opens PR to upstream 27 | ``` 28 | 29 | ## Single Organization Flow 30 | 31 | ```mermaid 32 | sequenceDiagram 33 | participant U as upstream *Public 34 | box github.com/your-public-organization 35 | participant F as fork *Public 36 | participant M as mirror *Private 37 | end 38 | U->>F: User forks upstream into Organization 39 | F-->>F: Bot creates branch protection rules 40 | F-->>F: User requests mirror to be made 41 | F->>M: Bot creates a mirror of the fork 42 | M-->>M: Bot creates branch protection rules 43 | M-->>M: User makes all changes in non-default branches 44 | M-->>M: User merges into `default` branch 45 | M->>F: Bot automatically syncs mirror to fork 46 | F->>U: User opens PR to upstream 47 | ``` 48 | 49 | ## Components 50 | 51 | ### UI 52 | 53 | The UI is built in nextjs with React Typescript. The main purpose of the UI is to provide a way for the user to request a mirror repository to be created. Every other part of the workflow can be achieved in GitHub or with the git CLI. 54 | 55 | ### GitHub App 56 | 57 | The GitHub App is the main component of the project. It is responsible for listening to events from GitHub and performing actions on behalf of the user. It is also responsible for creating the mirror repository and syncing all changes between the fork and the mirror. 58 | 59 | ### Node Server 60 | 61 | As part of the nextjs framework, it exposes a Node server we can use to run the GitHub App. This is the entry point for the application. It is responsible for loading the GitHub App and starting the server as well as performing git operations. 62 | -------------------------------------------------------------------------------- /src/bot/config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from '@probot/octokit-plugin-config/dist-types/types' 2 | import z from 'zod' 3 | import { logger } from '../utils/logger' 4 | import { appOctokit, installationOctokit } from './octokit' 5 | 6 | const configLogger = logger.getSubLogger({ name: 'config' }) 7 | 8 | const pmaConfig = z.object({ 9 | publicOrg: z.string(), 10 | privateOrg: z.string(), 11 | }) 12 | 13 | type pmaConfig = z.infer & Configuration 14 | 15 | export const getGitHubConfig = async (orgId: string) => { 16 | const installationId = await appOctokit().rest.apps.getOrgInstallation({ 17 | org: orgId, 18 | }) 19 | const octokit = installationOctokit(String(installationId.data.id)) 20 | 21 | const orgData = await octokit.rest.orgs.get({ org: orgId }) 22 | 23 | configLogger.info( 24 | `No config found for org, using default org: '${orgData.data.login}' for BOTH public and private!`, 25 | ) 26 | return { 27 | publicOrg: orgData.data.login, 28 | privateOrg: orgData.data.login, 29 | } 30 | } 31 | 32 | export const getEnvConfig = () => { 33 | if (!process.env.PUBLIC_ORG) { 34 | return null 35 | } 36 | 37 | configLogger.info( 38 | `PUBLIC_ORG is set. Using config from environment variables!`, 39 | ) 40 | 41 | const config = { 42 | publicOrg: process.env.PUBLIC_ORG, 43 | privateOrg: process.env.PUBLIC_ORG, 44 | } as pmaConfig 45 | 46 | if (process.env.PRIVATE_ORG) { 47 | config.privateOrg = process.env.PRIVATE_ORG 48 | } 49 | return config 50 | } 51 | 52 | export const validateConfig = (config: pmaConfig) => { 53 | try { 54 | pmaConfig.parse(config) 55 | } catch (error) { 56 | configLogger.error('Invalid config found!', { error }) 57 | throw new Error( 58 | 'Invalid config found! Please check the config and error log for more details.', 59 | ) 60 | } 61 | 62 | return config 63 | } 64 | 65 | /** 66 | * Fetches a configuration file from the organization's .github repository 67 | * @param orgId Organization ID 68 | * @returns Configuration file 69 | */ 70 | export const getConfig = async (orgId?: string) => { 71 | let config: pmaConfig | null = null 72 | 73 | // First check for environment variables 74 | config = getEnvConfig() 75 | if (config) { 76 | return validateConfig(config) 77 | } 78 | 79 | // Lastly check github for a config 80 | if (!orgId) { 81 | configLogger.error( 82 | 'No orgId present, Organization ID is required to set a config when not using environment variables', 83 | ) 84 | throw new Error('Organization ID is required to set a config!') 85 | } 86 | 87 | config = await getGitHubConfig(orgId) 88 | 89 | configLogger.info(`Using following config values`, { 90 | config, 91 | }) 92 | 93 | return validateConfig(config) 94 | } 95 | -------------------------------------------------------------------------------- /src/hooks/useForks.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-floating-promises */ 2 | import { getReposInOrgGQL } from 'bot/graphql' 3 | import { personalOctokit } from 'bot/octokit' 4 | import { useSession } from 'next-auth/react' 5 | import { useEffect, useState } from 'react' 6 | import { ForksObject } from 'types/forks' 7 | import { logger } from '../utils/logger' 8 | 9 | const forksLogger = logger.getSubLogger({ name: 'useForks' }) 10 | 11 | const getForksInOrg = async (accessToken: string, login: string) => { 12 | const res = await personalOctokit(accessToken) 13 | .graphql.paginate(getReposInOrgGQL, { 14 | login, 15 | isFork: true, 16 | }) 17 | .catch((error: Error & { data: ForksObject }) => { 18 | forksLogger.error('Error fetching forks', { error }) 19 | return error.data 20 | }) 21 | 22 | // the primer datatable component requires the data to not contain null 23 | // values and the type returned from the graphql query contains null values 24 | return { 25 | organization: { 26 | repositories: { 27 | totalCount: res.organization.repositories.totalCount, 28 | nodes: res.organization.repositories.nodes.map((node) => ({ 29 | id: node.databaseId, 30 | name: node.name, 31 | isPrivate: node.isPrivate, 32 | updatedAt: node.updatedAt, 33 | owner: { 34 | avatarUrl: node.owner.avatarUrl, 35 | login: node.owner.login, 36 | }, 37 | parent: { 38 | name: node?.parent?.name, 39 | owner: { 40 | login: node?.parent?.owner.login, 41 | avatarUrl: node?.parent?.owner.avatarUrl, 42 | }, 43 | }, 44 | languages: { 45 | nodes: node.languages.nodes.map((node) => ({ 46 | name: node.name, 47 | color: node.color, 48 | })), 49 | }, 50 | refs: { 51 | totalCount: node.refs.totalCount, 52 | }, 53 | })), 54 | }, 55 | }, 56 | } 57 | } 58 | 59 | export const useForksData = (login: string | undefined) => { 60 | const session = useSession() 61 | const accessToken = session.data?.user.accessToken 62 | 63 | const [forks, setForks] = useState 65 | > | null>(null) 66 | const [isLoading, setIsLoading] = useState(true) 67 | const [error, setError] = useState(null) 68 | 69 | useEffect(() => { 70 | if (!login || !accessToken) { 71 | return 72 | } 73 | 74 | setIsLoading(true) 75 | setError(null) 76 | 77 | getForksInOrg(accessToken, login) 78 | .then((forks) => { 79 | setForks(forks) 80 | }) 81 | .catch((error: Error) => { 82 | setError(error) 83 | }) 84 | .finally(() => { 85 | setIsLoading(false) 86 | }) 87 | }, [login, accessToken]) 88 | 89 | return { 90 | data: forks, 91 | isLoading, 92 | error, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | import safeStringify from 'fast-safe-stringify' 4 | import { Logger } from 'tslog' 5 | 6 | // This is a workaround for the issue with JSON.stringify and circular references 7 | const stringify = (obj: any) => { 8 | try { 9 | return JSON.stringify(obj) 10 | } catch { 11 | return safeStringify(obj) 12 | } 13 | } 14 | 15 | // If you need logs during tests you can set the env var TEST_LOGGING=true 16 | const getLoggerType = () => { 17 | if (process.env.NODE_ENV === 'development') { 18 | return 'pretty' 19 | } 20 | 21 | if (process.env.NODE_ENV === 'test' || process.env.TEST_LOGGING === '1') { 22 | return 'pretty' 23 | } 24 | 25 | return 'json' 26 | } 27 | 28 | // Map logger level name to number for tsLog 29 | const mapLevelToMethod: Record = { 30 | silly: 0, 31 | trace: 1, 32 | debug: 2, 33 | info: 3, 34 | warn: 4, 35 | error: 5, 36 | fatal: 6, 37 | } 38 | 39 | export const logger = new Logger({ 40 | type: getLoggerType(), 41 | minLevel: 42 | mapLevelToMethod[process.env.LOGGING_LEVEL?.toLowerCase() ?? 'info'], 43 | maskValuesRegEx: [ 44 | /"access[-._]?token":"[^"]+"/g, 45 | /"api[-._]?key":"[^"]+"/g, 46 | /"client[-._]?secret":"[^"]+"/g, 47 | /"cookie":"[^"]+"/g, 48 | /"password":"[^"]+"/g, 49 | /"refresh[-._]?token":"[^"]+"/g, 50 | /"secret":"[^"]+"/g, 51 | /"token":"[^"]+"/g, 52 | /(?<=:\/\/)([^:]+):([^@]+)(?=@)/g, 53 | ], 54 | overwrite: { 55 | transportJSON: (log) => { 56 | const logObjWithMeta = log as { 57 | [key: string]: any 58 | _meta?: Record 59 | } 60 | 61 | const output: { 62 | meta?: Record 63 | message?: string 64 | info?: Record 65 | data?: Record 66 | } = {} 67 | 68 | // set meta 69 | output.meta = logObjWithMeta._meta 70 | 71 | // set message if it's a string or set it as info 72 | if ( 73 | Object.hasOwn(logObjWithMeta, '0') && 74 | typeof logObjWithMeta['0'] === 'string' 75 | ) { 76 | output.message = logObjWithMeta['0'] 77 | } else { 78 | output.info = logObjWithMeta['0'] 79 | } 80 | 81 | // set data 82 | if (Object.hasOwn(logObjWithMeta, '1')) { 83 | output.data = logObjWithMeta['1'] 84 | } 85 | 86 | console.log(stringify(output)) 87 | }, 88 | }, 89 | }) 90 | 91 | logger.getSubLogger({ name: 'default' }).info('Initialized logger') 92 | 93 | // Redirect next logs to our logger >:( 94 | console.warn = logger 95 | .getSubLogger({ name: 'console' }) 96 | .warn.bind(logger.getSubLogger({ name: 'console' })) 97 | // Currently set to warn because of warning issued by undici showing as error 98 | console.error = logger 99 | .getSubLogger({ name: 'console' }) 100 | .warn.bind(logger.getSubLogger({ name: 'console' })) 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "private-mirrors", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "A GitHub App that allows you to contribute upstream using a 'private fork'", 6 | "author": "Andrew Henry ", 7 | "license": "MIT", 8 | "homepage": "https://github.com/github-community-projects/private-mirrors", 9 | "scripts": { 10 | "dev": "concurrently -c \"auto\" -n webhooks,app \"dotenv-load node scripts/webhook-relay.mjs\" \"next dev\"", 11 | "webhook": "dotenv-load node scripts/webhook-relay.mjs", 12 | "build": "SKIP_ENV_VALIDATIONS='true' next build", 13 | "start": "next start", 14 | "lint": "SKIP_ENV_VALIDATIONS='true' next lint && prettier --check .", 15 | "lint:fix": "SKIP_ENV_VALIDATIONS='true' next lint --fix && prettier --write .", 16 | "bot:build": "tsc ./src/bot/index.ts --noEmit false --esModuleInterop --outDir ./build", 17 | "bot:start": "probot run ./build/index.js", 18 | "test": "TEST_LOGGING=1 ts-node -O '{\"module\":\"commonjs\"}' node_modules/jest/bin/jest.js", 19 | "prepare": "husky || true" 20 | }, 21 | "dependencies": { 22 | "@octokit/auth-app": "6.1.1", 23 | "@octokit/graphql-schema": "15.26.0", 24 | "@primer/octicons-react": "19.15.2", 25 | "@primer/react": "36.27.0", 26 | "@t3-oss/env-nextjs": "0.13.7", 27 | "@tanstack/react-query": "4.36.1", 28 | "@trpc/client": "10.45.3", 29 | "@trpc/react-query": "10.45.3", 30 | "@trpc/server": "10.45.3", 31 | "fast-safe-stringify": "2.1.1", 32 | "fuse.js": "7.1.0", 33 | "next": "14.2.35", 34 | "next-auth": "4.24.12", 35 | "octokit": "3.2.1", 36 | "probot": "13.4.7", 37 | "proxy-agent": "6.5.0", 38 | "react": "18.3.1", 39 | "react-dom": "18.3.1", 40 | "simple-git": "3.28.0", 41 | "styled-components": "5.3.11", 42 | "superjson": "2.2.2", 43 | "tempy": "1.0.1", 44 | "tslog": "4.9.3", 45 | "undici": "6.21.2", 46 | "zod": "3.25.56" 47 | }, 48 | "devDependencies": { 49 | "@babel/preset-env": "7.27.2", 50 | "@tanstack/eslint-plugin-query": "5.78.0", 51 | "@types/jest": "29.5.14", 52 | "@types/node": "20.14.10", 53 | "@types/node-fetch": "2.6.12", 54 | "@types/react": "18.3.12", 55 | "@types/styled-components": "5.1.36", 56 | "@typescript-eslint/eslint-plugin": "7.16.1", 57 | "@typescript-eslint/parser": "7.16.1", 58 | "babel-jest": "29.7.0", 59 | "concurrently": "9.1.2", 60 | "dotenv-load": "3.0.0", 61 | "eslint": "8.57.0", 62 | "eslint-config-next": "15.3.3", 63 | "github-app-webhook-relay-polling": "2.0.0", 64 | "husky": "9.1.7", 65 | "jest": "29.7.0", 66 | "lint-staged": "15.5.1", 67 | "nock": "14.0.5", 68 | "prettier": "3.5.3", 69 | "ts-jest": "29.3.4", 70 | "ts-node": "10.9.2", 71 | "typescript": "5.8.3", 72 | "typescript-eslint": "7.16.1" 73 | }, 74 | "overrides": { 75 | "octokit": { 76 | "@octokit/plugin-paginate-rest": "11.4.4-cjs.2" 77 | } 78 | }, 79 | "engines": { 80 | "node": "^18 || ^20 || ^22" 81 | }, 82 | "lint-staged": { 83 | "*": "npm run lint:fix" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /test/fixtures/ping.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "ping", 3 | "installation": { 4 | "id": 43001401, 5 | "account": { 6 | "login": "github-ospo-test", 7 | "id": 148150766, 8 | "node_id": "O_kgDOCNSZ7g", 9 | "avatar_url": "https://avatars.githubusercontent.com/u/148150766?v=4", 10 | "gravatar_id": "", 11 | "url": "https://api.github.com/users/github-ospo-test", 12 | "html_url": "https://github.com/github-ospo-test", 13 | "followers_url": "https://api.github.com/users/github-ospo-test/followers", 14 | "following_url": "https://api.github.com/users/github-ospo-test/following{/other_user}", 15 | "gists_url": "https://api.github.com/users/github-ospo-test/gists{/gist_id}", 16 | "starred_url": "https://api.github.com/users/github-ospo-test/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/github-ospo-test/subscriptions", 18 | "organizations_url": "https://api.github.com/users/github-ospo-test/orgs", 19 | "repos_url": "https://api.github.com/users/github-ospo-test/repos", 20 | "events_url": "https://api.github.com/users/github-ospo-test/events{/privacy}", 21 | "received_events_url": "https://api.github.com/users/github-ospo-test/received_events", 22 | "type": "Organization", 23 | "site_admin": false 24 | }, 25 | "repository_selection": "selected", 26 | "access_tokens_url": "https://api.github.com/app/installations/43001401/access_tokens", 27 | "repositories_url": "https://api.github.com/installation/repositories", 28 | "html_url": "https://github.com/organizations/github-ospo-test/settings/installations/43001401", 29 | "app_id": 409700, 30 | "app_slug": "ospo-repo-sync-test", 31 | "target_id": 148150766, 32 | "target_type": "Organization", 33 | "permissions": {}, 34 | "events": [], 35 | "created_at": "2023-10-17T16:05:55.000-04:00", 36 | "updated_at": "2023-10-17T16:05:55.000-04:00", 37 | "single_file_name": null, 38 | "has_multiple_single_files": false, 39 | "single_file_paths": [], 40 | "suspended_by": null, 41 | "suspended_at": null 42 | }, 43 | "repositories": [], 44 | "requester": null, 45 | "sender": { 46 | "login": "ajhenry", 47 | "id": 24923406, 48 | "node_id": "MDQ6VXNlcjI0OTIzNDA2", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/24923406?v=4", 50 | "gravatar_id": "", 51 | "url": "https://api.github.com/users/ajhenry", 52 | "html_url": "https://github.com/ajhenry", 53 | "followers_url": "https://api.github.com/users/ajhenry/followers", 54 | "following_url": "https://api.github.com/users/ajhenry/following{/other_user}", 55 | "gists_url": "https://api.github.com/users/ajhenry/gists{/gist_id}", 56 | "starred_url": "https://api.github.com/users/ajhenry/starred{/owner}{/repo}", 57 | "subscriptions_url": "https://api.github.com/users/ajhenry/subscriptions", 58 | "organizations_url": "https://api.github.com/users/ajhenry/orgs", 59 | "repos_url": "https://api.github.com/users/ajhenry/repos", 60 | "events_url": "https://api.github.com/users/ajhenry/events{/privacy}", 61 | "received_events_url": "https://api.github.com/users/ajhenry/received_events", 62 | "type": "User", 63 | "site_admin": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /scripts/events/installation.created.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "installation": { 4 | "id": 43001401, 5 | "account": { 6 | "login": "github-ospo-test", 7 | "id": 148150766, 8 | "node_id": "O_kgDOCNSZ7g", 9 | "avatar_url": "https://avatars.githubusercontent.com/u/148150766?v=4", 10 | "gravatar_id": "", 11 | "url": "https://api.github.com/users/github-ospo-test", 12 | "html_url": "https://github.com/github-ospo-test", 13 | "followers_url": "https://api.github.com/users/github-ospo-test/followers", 14 | "following_url": "https://api.github.com/users/github-ospo-test/following{/other_user}", 15 | "gists_url": "https://api.github.com/users/github-ospo-test/gists{/gist_id}", 16 | "starred_url": "https://api.github.com/users/github-ospo-test/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/github-ospo-test/subscriptions", 18 | "organizations_url": "https://api.github.com/users/github-ospo-test/orgs", 19 | "repos_url": "https://api.github.com/users/github-ospo-test/repos", 20 | "events_url": "https://api.github.com/users/github-ospo-test/events{/privacy}", 21 | "received_events_url": "https://api.github.com/users/github-ospo-test/received_events", 22 | "type": "Organization", 23 | "site_admin": false 24 | }, 25 | "repository_selection": "selected", 26 | "access_tokens_url": "https://api.github.com/app/installations/43001401/access_tokens", 27 | "repositories_url": "https://api.github.com/installation/repositories", 28 | "html_url": "https://github.com/organizations/github-ospo-test/settings/installations/43001401", 29 | "app_id": 409700, 30 | "app_slug": "ospo-repo-sync-test", 31 | "target_id": 148150766, 32 | "target_type": "Organization", 33 | "permissions": {}, 34 | "events": [], 35 | "created_at": "2023-10-17T16:05:55.000-04:00", 36 | "updated_at": "2023-10-17T16:05:55.000-04:00", 37 | "single_file_name": null, 38 | "has_multiple_single_files": false, 39 | "single_file_paths": [], 40 | "suspended_by": null, 41 | "suspended_at": null 42 | }, 43 | "repositories": [], 44 | "requester": null, 45 | "sender": { 46 | "login": "ajhenry", 47 | "id": 24923406, 48 | "node_id": "MDQ6VXNlcjI0OTIzNDA2", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/24923406?v=4", 50 | "gravatar_id": "", 51 | "url": "https://api.github.com/users/ajhenry", 52 | "html_url": "https://github.com/ajhenry", 53 | "followers_url": "https://api.github.com/users/ajhenry/followers", 54 | "following_url": "https://api.github.com/users/ajhenry/following{/other_user}", 55 | "gists_url": "https://api.github.com/users/ajhenry/gists{/gist_id}", 56 | "starred_url": "https://api.github.com/users/ajhenry/starred{/owner}{/repo}", 57 | "subscriptions_url": "https://api.github.com/users/ajhenry/subscriptions", 58 | "organizations_url": "https://api.github.com/users/ajhenry/orgs", 59 | "repos_url": "https://api.github.com/users/ajhenry/repos", 60 | "events_url": "https://api.github.com/users/ajhenry/events{/privacy}", 61 | "received_events_url": "https://api.github.com/users/ajhenry/received_events", 62 | "type": "User", 63 | "site_admin": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/fixtures/installation.created.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "installation": { 4 | "id": 43001401, 5 | "account": { 6 | "login": "github-ospo-test", 7 | "id": 148150766, 8 | "node_id": "O_kgDOCNSZ7g", 9 | "avatar_url": "https://avatars.githubusercontent.com/u/148150766?v=4", 10 | "gravatar_id": "", 11 | "url": "https://api.github.com/users/github-ospo-test", 12 | "html_url": "https://github.com/github-ospo-test", 13 | "followers_url": "https://api.github.com/users/github-ospo-test/followers", 14 | "following_url": "https://api.github.com/users/github-ospo-test/following{/other_user}", 15 | "gists_url": "https://api.github.com/users/github-ospo-test/gists{/gist_id}", 16 | "starred_url": "https://api.github.com/users/github-ospo-test/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/github-ospo-test/subscriptions", 18 | "organizations_url": "https://api.github.com/users/github-ospo-test/orgs", 19 | "repos_url": "https://api.github.com/users/github-ospo-test/repos", 20 | "events_url": "https://api.github.com/users/github-ospo-test/events{/privacy}", 21 | "received_events_url": "https://api.github.com/users/github-ospo-test/received_events", 22 | "type": "Organization", 23 | "site_admin": false 24 | }, 25 | "repository_selection": "selected", 26 | "access_tokens_url": "https://api.github.com/app/installations/43001401/access_tokens", 27 | "repositories_url": "https://api.github.com/installation/repositories", 28 | "html_url": "https://github.com/organizations/github-ospo-test/settings/installations/43001401", 29 | "app_id": 409700, 30 | "app_slug": "ospo-repo-sync-test", 31 | "target_id": 148150766, 32 | "target_type": "Organization", 33 | "permissions": {}, 34 | "events": [], 35 | "created_at": "2023-10-17T16:05:55.000-04:00", 36 | "updated_at": "2023-10-17T16:05:55.000-04:00", 37 | "single_file_name": null, 38 | "has_multiple_single_files": false, 39 | "single_file_paths": [], 40 | "suspended_by": null, 41 | "suspended_at": null 42 | }, 43 | "repositories": [], 44 | "requester": null, 45 | "sender": { 46 | "login": "ajhenry", 47 | "id": 24923406, 48 | "node_id": "MDQ6VXNlcjI0OTIzNDA2", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/24923406?v=4", 50 | "gravatar_id": "", 51 | "url": "https://api.github.com/users/ajhenry", 52 | "html_url": "https://github.com/ajhenry", 53 | "followers_url": "https://api.github.com/users/ajhenry/followers", 54 | "following_url": "https://api.github.com/users/ajhenry/following{/other_user}", 55 | "gists_url": "https://api.github.com/users/ajhenry/gists{/gist_id}", 56 | "starred_url": "https://api.github.com/users/ajhenry/starred{/owner}{/repo}", 57 | "subscriptions_url": "https://api.github.com/users/ajhenry/subscriptions", 58 | "organizations_url": "https://api.github.com/users/ajhenry/orgs", 59 | "repos_url": "https://api.github.com/users/ajhenry/repos", 60 | "events_url": "https://api.github.com/users/ajhenry/events{/privacy}", 61 | "received_events_url": "https://api.github.com/users/ajhenry/received_events", 62 | "type": "User", 63 | "site_admin": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /scripts/webhook-relay.mjs: -------------------------------------------------------------------------------- 1 | import { sign } from '@octokit/webhooks-methods' 2 | import WebhookRelay from 'github-app-webhook-relay-polling' 3 | import crypto from 'node:crypto' 4 | import { App } from 'octokit' 5 | 6 | import './proxy.mjs' 7 | 8 | if (!process.env.PUBLIC_ORG) { 9 | console.error( 10 | 'Missing PUBLIC_ORG environment variable. This is required for the webhook relay to work locally.', 11 | ) 12 | process.exit(1) 13 | } 14 | 15 | const url = `${process.env.NEXTAUTH_URL}/api/webhooks` 16 | 17 | const privateKey = 18 | process.env.PRIVATE_KEY && 19 | !process.env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----') 20 | ? // Support optional base64 decoding of the private key to prevent issues with complicated environment variable passing scenarios 21 | Buffer.from(process.env.PRIVATE_KEY, 'base64').toString('utf8') 22 | : // Handle a bug with multiline envs in docker - See https://github.com/moby/moby/issues/46773 23 | (process.env.PRIVATE_KEY?.replace(/\\n/g, '\n') ?? '') 24 | 25 | const privateKeyPkcs8 = crypto.createPrivateKey(privateKey).export({ 26 | type: 'pkcs8', 27 | format: 'pem', 28 | }) 29 | 30 | const setupForwarder = (organizationOwner) => { 31 | const app = new App({ 32 | appId: process.env.APP_ID, 33 | privateKey: privateKeyPkcs8, 34 | webhooks: { 35 | // value does not matter, but has to be set. 36 | secret: 'secret', 37 | }, 38 | }) 39 | 40 | const relay = new WebhookRelay({ 41 | owner: organizationOwner, 42 | events: ['*'], 43 | app, 44 | }) 45 | 46 | relay.on('start', () => { 47 | console.log('Webhook forwarder ready') 48 | console.log(`Using '${organizationOwner}' as the organization`) 49 | }) 50 | 51 | relay.on('webhook', async (event) => { 52 | console.log( 53 | `[${organizationOwner}] Forwarding received webhook: ${event.name}`, 54 | ) 55 | 56 | const parsedEvent = JSON.stringify(event.payload) 57 | 58 | const eventNameWithAction = event.payload.action 59 | ? `${event.name}.${event.payload.action}` 60 | : event.name 61 | 62 | console.log( 63 | `[${organizationOwner}] Forwarding ${eventNameWithAction} event to ${url} ... `, 64 | ) 65 | 66 | const headers = {} 67 | 68 | headers['x-hub-signature-256'] = await sign( 69 | process.env.WEBHOOK_SECRET, 70 | parsedEvent, 71 | ) 72 | headers['x-github-event'] = eventNameWithAction 73 | headers['x-github-delivery'] = event.id 74 | headers['content-type'] = 'application/json' 75 | 76 | const response = await fetch(url, { 77 | method: 'POST', 78 | headers: headers, 79 | body: parsedEvent, 80 | }) 81 | 82 | console.log( 83 | `[${organizationOwner}] ${eventNameWithAction} event response status= ${response.status}`, 84 | ) 85 | }) 86 | 87 | relay.on('error', (error) => { 88 | console.log(`[${organizationOwner}] error: ${error}`) 89 | }) 90 | 91 | relay.start() 92 | } 93 | 94 | setupForwarder(process.env.PUBLIC_ORG) 95 | 96 | if ( 97 | process.env.PRIVATE_ORG && 98 | process.env.PUBLIC_ORG !== process.env.PRIVATE_ORG 99 | ) { 100 | console.log('Setting up private organization webhook relay') 101 | setupForwarder(process.env.PRIVATE_ORG) 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server' 2 | import { getConfig } from '../bot/config' 3 | import { personalOctokit } from '../bot/octokit' 4 | import { logger } from '../utils/logger' 5 | 6 | /** 7 | * Generates a git url with the access token in it 8 | * @param accessToken Access token for the app 9 | * @param owner Repo Owner 10 | * @param repo Repo Name 11 | * @returns formatted authenticated git url 12 | */ 13 | export const generateAuthUrl = ( 14 | accessToken: string, 15 | owner: string, 16 | repo: string, 17 | ) => { 18 | const USER = 'x-access-token' 19 | const PASS = accessToken 20 | const REPO = `github.com/${owner}/${repo}` 21 | return `https://${USER}:${PASS}@${REPO}` 22 | } 23 | 24 | const middlewareLogger = logger.getSubLogger({ name: 'middleware' }) 25 | 26 | /** 27 | * Checks if the access token has access to the mirror org and repo 28 | * 29 | * Used for checking if the git.syncRepos mutation has the correct permissions 30 | * @param accessToken Access token for the private org's installation 31 | * @param mirrorOrgOwner Mirror org owner 32 | * @param mirrorRepo Mirror repo name 33 | */ 34 | export const checkGitHubAppInstallationAuth = async ( 35 | accessToken: string | undefined, 36 | mirrorOrgOwner: string | undefined, 37 | mirrorRepo: string | undefined, 38 | ) => { 39 | if (!accessToken || !mirrorOrgOwner || !mirrorRepo) { 40 | middlewareLogger.error('No access token or mirror org/repo provided') 41 | throw new TRPCError({ code: 'UNAUTHORIZED' }) 42 | } 43 | 44 | const octokit = personalOctokit(accessToken) 45 | 46 | const data = await octokit.rest.repos 47 | .get({ 48 | owner: mirrorOrgOwner, 49 | repo: mirrorRepo, 50 | }) 51 | .catch((error: Error) => { 52 | middlewareLogger.error('Error checking github app installation auth', { 53 | error, 54 | }) 55 | return null 56 | }) 57 | 58 | if (!data?.data) { 59 | middlewareLogger.error('App does not have access to mirror repo') 60 | throw new TRPCError({ code: 'UNAUTHORIZED' }) 61 | } 62 | } 63 | 64 | /** 65 | * Checks to see if the user has access to the organization 66 | * @param accessToken Access token for a user 67 | */ 68 | export const checkGitHubAuth = async ( 69 | accessToken: string | undefined, 70 | orgId: string | undefined, 71 | ) => { 72 | if (!accessToken) { 73 | middlewareLogger.error('No access token provided') 74 | throw new TRPCError({ code: 'UNAUTHORIZED' }) 75 | } 76 | 77 | const octokit = personalOctokit(accessToken) 78 | 79 | try { 80 | // Check validity of token 81 | const user = await octokit.rest.users.getAuthenticated() 82 | if (!user) { 83 | middlewareLogger.error('No user found') 84 | throw new TRPCError({ code: 'UNAUTHORIZED' }) 85 | } 86 | 87 | // Check if user has access to the org 88 | if (orgId) { 89 | const config = await getConfig(orgId) 90 | 91 | const org = await octokit.rest.orgs.getMembershipForAuthenticatedUser({ 92 | org: config.publicOrg, 93 | }) 94 | 95 | if (!org.data) { 96 | middlewareLogger.error('User does not have access to org') 97 | throw new TRPCError({ code: 'UNAUTHORIZED' }) 98 | } 99 | } 100 | } catch (error) { 101 | middlewareLogger.error('Error checking github auth', error) 102 | throw new TRPCError({ code: 'UNAUTHORIZED' }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from '@t3-oss/env-nextjs' 2 | import { z } from 'zod' 3 | 4 | export const env = createEnv({ 5 | /* 6 | * Serverside Environment variables, not available on the client. 7 | * Will throw if you access these variables on the client. 8 | */ 9 | server: { 10 | // Mandatory environment variables 11 | APP_ID: z.string(), 12 | GITHUB_CLIENT_ID: z.string(), 13 | GITHUB_CLIENT_SECRET: z.string(), 14 | NEXTAUTH_SECRET: z.string(), 15 | NEXTAUTH_URL: z.string().url(), 16 | WEBHOOK_SECRET: z.string(), 17 | PRIVATE_KEY: z.string(), 18 | 19 | // Optional environment variables 20 | LOGGING_LEVEL: z.string().optional().default('debug'), 21 | NODE_ENV: z.string().optional().default('development'), 22 | PUBLIC_ORG: z.string().optional(), 23 | PRIVATE_ORG: z.string().optional(), 24 | // Custom validation for a comma separated list of strings 25 | // ex: ajhenry,github,ahpook 26 | ALLOWED_HANDLES: z 27 | .string() 28 | .optional() 29 | .default('') 30 | .refine((val) => { 31 | if (val === '') return true 32 | return val.split(',').every((handle) => handle.trim().length > 0) 33 | }, 'Invalid comma separated list of GitHub handles'), 34 | ALLOWED_ORGS: z 35 | .string() 36 | .optional() 37 | .default('') 38 | .refine((val) => { 39 | if (val === '') return true 40 | return val.split(',').every((org) => org.trim().length > 0) 41 | }, 'Invalid comma separated list of GitHub orgs'), 42 | SKIP_BRANCH_PROTECTION_CREATION: z 43 | .enum(['true', 'false', '']) 44 | .optional() 45 | .default('false') 46 | .transform((value) => value === 'true'), 47 | CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY: z 48 | .enum(['true', 'false', '']) 49 | .optional() 50 | .default('false') 51 | .transform((value) => value === 'true'), 52 | DELETE_INTERNAL_MERGE_COMMITS_ON_SYNC: z 53 | .enum(['true', 'false', '']) 54 | .optional() 55 | .default('false') 56 | .transform((value) => value === 'true'), 57 | }, 58 | /* 59 | * Environment variables available on the client (and server). 60 | * 61 | * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_. 62 | */ 63 | client: {}, 64 | /* 65 | * Due to how Next.js bundles environment variables on Edge and Client, 66 | * we need to manually destructure them to make sure all are included in bundle. 67 | * 68 | * 💡 You'll get type errors if not all variables from `server` & `client` are included here. 69 | */ 70 | runtimeEnv: { 71 | APP_ID: process.env.APP_ID, 72 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, 73 | GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, 74 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 75 | NEXTAUTH_URL: process.env.NEXTAUTH_URL, 76 | WEBHOOK_SECRET: process.env.WEBHOOK_SECRET, 77 | PRIVATE_KEY: process.env.PRIVATE_KEY, 78 | LOGGING_LEVEL: process.env.LOGGING_LEVEL, 79 | NODE_ENV: process.env.NODE_ENV, 80 | PUBLIC_ORG: process.env.PUBLIC_ORG, 81 | PRIVATE_ORG: process.env.PRIVATE_ORG, 82 | ALLOWED_HANDLES: process.env.ALLOWED_HANDLES, 83 | ALLOWED_ORGS: process.env.ALLOWED_ORGS, 84 | SKIP_BRANCH_PROTECTION_CREATION: 85 | process.env.SKIP_BRANCH_PROTECTION_CREATION, 86 | CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY: 87 | process.env.CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY, 88 | DELETE_INTERNAL_MERGE_COMMITS_ON_SYNC: 89 | process.env.DELETE_INTERNAL_MERGE_COMMITS_ON_SYNC, 90 | }, 91 | skipValidation: process.env.SKIP_ENV_VALIDATIONS === 'true', 92 | }) 93 | -------------------------------------------------------------------------------- /app.yml: -------------------------------------------------------------------------------- 1 | # This is a GitHub App Manifest. These settings will be used by default when 2 | # initially configuring your GitHub App. 3 | # 4 | # NOTE: changing this file will not update your GitHub App settings. 5 | # You must visit github.com/settings/apps/your-app-name to edit them. 6 | # 7 | # Read more about configuring your GitHub App: 8 | # https://probot.github.io/docs/development/#configuring-a-github-app 9 | # 10 | # Read more about GitHub App Manifests: 11 | # https://developer.github.com/apps/building-github-apps/creating-github-apps-from-a-manifest/ 12 | 13 | # The list of events the GitHub App subscribes to. 14 | # Uncomment the event names below to enable them. 15 | default_events: 16 | - installation_target 17 | - meta 18 | - branch_protection_rule 19 | - fork 20 | - public 21 | - push 22 | - repository 23 | - repository_dispatch 24 | - workflow_dispatch 25 | - workflow_job 26 | - workflow_run 27 | 28 | # The set of permissions needed by the GitHub App. The format of the object uses 29 | # the permission name for the key (for example, issues) and the access type for 30 | # the value (for example, write). 31 | # Valid values are `read`, `write`, and `none` 32 | default_permissions: 33 | # Repository actions 34 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-actions 35 | actions: write 36 | 37 | # Repository creation, deletion, settings, teams, and collaborators. 38 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-administration 39 | administration: write 40 | 41 | # Repository contents, commits, branches, downloads, releases, and merges. 42 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-contents 43 | contents: write 44 | 45 | # Search repositories, list collaborators, and access repository metadata. 46 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-metadata 47 | metadata: read 48 | 49 | # Manage access to an organization. 50 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#organization-permissions-for-administration 51 | organization_administration: read 52 | 53 | # Organization members and teams. 54 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#organization-permissions-for-members 55 | members: read 56 | 57 | # Manage a user's email addresses. 58 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#user-permissions-for-email-addresses 59 | email_addresses: read 60 | 61 | # Copy workflow files to the repository. 62 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-workflows 63 | workflows: write 64 | 65 | # Set custom properties for the repository. 66 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-custom-properties 67 | custom_properties: write 68 | 69 | # Set custom properties for the organization. 70 | # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#organization-permissions-for-custom-properties 71 | organization_custom_properties: admin 72 | 73 | # The name of the GitHub App. Defaults to the name specified in package.json 74 | name: private-mirrors 75 | 76 | # The homepage of your GitHub App. 77 | url: https://github.com/github-community-projects/private-mirrors 78 | 79 | # A description of the GitHub App. 80 | description: A GitHub App that allows you to contribute upstream using private mirrors of public repos. 81 | 82 | # Set to true when your GitHub App is available to the public or false when it is only accessible to the owner of the app. Default: true 83 | public: true 84 | -------------------------------------------------------------------------------- /src/app/components/dialog/CreateMirrorDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, FormControl, Label, Link, Text, TextInput } from '@primer/react' 2 | import { Stack } from '@primer/react/lib-esm/Stack' 3 | import { Dialog } from '@primer/react/lib-esm/drafts' 4 | 5 | import { useState } from 'react' 6 | 7 | interface CreateMirrorDialogProps { 8 | orgLogin: string 9 | forkParentOwnerLogin: string 10 | forkParentName: string 11 | isOpen: boolean 12 | closeDialog: () => void 13 | createMirror: (data: { repoName: string; branchName: string }) => void 14 | } 15 | 16 | export const CreateMirrorDialog = ({ 17 | orgLogin, 18 | forkParentOwnerLogin, 19 | forkParentName, 20 | isOpen, 21 | closeDialog, 22 | createMirror, 23 | }: CreateMirrorDialogProps) => { 24 | // set to default value of 'repository-name' for display purposes 25 | const [repoName, setRepoName] = useState('repository-name') 26 | 27 | if (!isOpen) { 28 | return null 29 | } 30 | 31 | return ( 32 | { 39 | closeDialog() 40 | setRepoName('repository-name') 41 | }, 42 | }, 43 | { 44 | content: 'Confirm', 45 | variant: 'primary', 46 | onClick: () => { 47 | createMirror({ repoName, branchName: repoName }) 48 | setRepoName('repository-name') 49 | }, 50 | disabled: repoName === 'repository-name' || repoName === '', 51 | }, 52 | ]} 53 | onClose={() => { 54 | closeDialog() 55 | setRepoName('repository-name') 56 | }} 57 | width="large" 58 | > 59 | 60 | 61 | Mirror name 62 | setRepoName(e.target.value)} 64 | block 65 | placeholder="e.g. repository-name" 66 | maxLength={100} 67 | /> 68 | 69 | This is a private mirror of{' '} 70 | 75 | {forkParentOwnerLogin}/{forkParentName} 76 | 77 | 78 | 79 | 80 | Mirror location 81 | 90 | 91 | 92 | 93 | 100 | {orgLogin}/{repoName} 101 | 102 | 103 | 104 | 105 | 112 | Forked from{' '} 113 | 119 | {forkParentOwnerLogin}/{forkParentName} 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | ) 130 | } 131 | -------------------------------------------------------------------------------- /src/bot/graphql.ts: -------------------------------------------------------------------------------- 1 | export const getBranchProtectionRulesetGQL = ` 2 | query( 3 | $owner: String! 4 | $name: String! 5 | ) { 6 | repository(owner: $owner, name: $name) { 7 | rulesets(first: 50) { 8 | nodes { 9 | name 10 | } 11 | } 12 | } 13 | } 14 | ` 15 | 16 | export const forkBranchProtectionRulesetGQL = ` 17 | mutation CreateRepositoryRuleset( 18 | $repositoryId: ID! 19 | $ruleName: String! 20 | $bypassActorId: ID! 21 | $includeRefs: [String!]! 22 | ) { 23 | createRepositoryRuleset( 24 | input: { 25 | sourceId: $repositoryId 26 | name: $ruleName 27 | target: BRANCH 28 | conditions: { 29 | refName: { 30 | include: $includeRefs 31 | exclude: [] 32 | } 33 | } 34 | rules: [ 35 | { 36 | type: CREATION 37 | }, 38 | { 39 | type: UPDATE 40 | parameters:{ 41 | update:{ 42 | updateAllowsFetchAndMerge: true 43 | } 44 | } 45 | }, 46 | { 47 | type: DELETION 48 | } 49 | ] 50 | enforcement: ACTIVE 51 | bypassActors: { 52 | actorId: $bypassActorId 53 | bypassMode: ALWAYS 54 | } 55 | } 56 | ) { 57 | ruleset { 58 | id 59 | } 60 | } 61 | } 62 | ` 63 | 64 | export const mirrorBranchProtectionRulesetGQL = ` 65 | mutation CreateRepositoryRuleset( 66 | $repositoryId: ID! 67 | $ruleName: String! 68 | $bypassActorId: ID! 69 | $includeRefs: [String!]! 70 | ) { 71 | createRepositoryRuleset( 72 | input: { 73 | sourceId: $repositoryId 74 | name: $ruleName 75 | target: BRANCH 76 | conditions: { 77 | refName: { 78 | include: $includeRefs 79 | exclude: [] 80 | } 81 | } 82 | rules: [ 83 | { 84 | type: PULL_REQUEST 85 | parameters: { 86 | pullRequest: { 87 | dismissStaleReviewsOnPush: true 88 | requireCodeOwnerReview: false 89 | requireLastPushApproval: false 90 | requiredApprovingReviewCount: 1 91 | requiredReviewThreadResolution: true 92 | } 93 | } 94 | } 95 | ] 96 | enforcement: ACTIVE 97 | bypassActors: { 98 | actorId: $bypassActorId 99 | bypassMode: ALWAYS 100 | } 101 | } 102 | ) { 103 | ruleset { 104 | id 105 | } 106 | } 107 | } 108 | ` 109 | 110 | export const forkBranchProtectionGQL = ` 111 | mutation AddBranchProtection( 112 | $repositoryId: ID! 113 | $actorId: ID! 114 | $pattern: String! 115 | ) { 116 | createBranchProtectionRule( 117 | input: { 118 | repositoryId: $repositoryId 119 | isAdminEnforced: true 120 | pushActorIds: [$actorId] 121 | pattern: $pattern 122 | restrictsPushes: true 123 | blocksCreations: true 124 | } 125 | ) { 126 | branchProtectionRule { 127 | id 128 | } 129 | } 130 | } 131 | ` 132 | 133 | export const mirrorBranchProtectionGQL = ` 134 | mutation AddBranchProtection( 135 | $repositoryId: ID! 136 | $actorId: ID! 137 | $pattern: String! 138 | ) { 139 | createBranchProtectionRule( 140 | input: { 141 | repositoryId: $repositoryId 142 | requiresApprovingReviews:true 143 | requiredApprovingReviewCount: 1 144 | pattern: $pattern 145 | dismissesStaleReviews:true 146 | pushActorIds: [$actorId] 147 | } 148 | ) { 149 | branchProtectionRule { 150 | id 151 | } 152 | } 153 | } 154 | ` 155 | 156 | export const getReposInOrgGQL = ` 157 | query( 158 | $login: String! 159 | $isFork: Boolean 160 | $cursor: String 161 | ) { 162 | organization(login: $login) { 163 | repositories(isFork: $isFork, after: $cursor, first: 25, orderBy: {field: UPDATED_AT, direction: DESC}) { 164 | totalCount 165 | nodes { 166 | databaseId 167 | name 168 | isPrivate 169 | updatedAt 170 | owner { 171 | login 172 | avatarUrl 173 | } 174 | parent { 175 | name 176 | owner { 177 | login 178 | avatarUrl 179 | } 180 | } 181 | languages(first: 4) { 182 | nodes { 183 | color 184 | name 185 | } 186 | } 187 | refs(refPrefix: "refs/") { 188 | totalCount 189 | } 190 | } 191 | pageInfo { 192 | hasNextPage 193 | endCursor 194 | } 195 | } 196 | } 197 | } 198 | ` 199 | -------------------------------------------------------------------------------- /docs/developing.md: -------------------------------------------------------------------------------- 1 | # Developing 2 | 3 | ## Prerequisites 4 | 5 | - Node.js (LTS versions, 18 or higher with 22 preferred) 6 | - Use your preferred version manager to install Node.js 7 | - npm (version 10 or higher) 8 | - npm comes bundled with Node.js 9 | - Docker (optional, for running the app in a container) 10 | 11 | ## Getting Started 12 | 13 | 1. Clone the repository: 14 | 15 | ```sh 16 | git clone https://github.com/github-community-projects/private-mirrors.git 17 | cd private-mirrors 18 | ``` 19 | 20 | 2. Install the Node.js version used by the project using your preferred version manager. For example, using `nvm` or `fnm`: 21 | 22 | ```sh 23 | nvm install 24 | ``` 25 | 26 | ```sh 27 | fnm install 28 | ``` 29 | 30 | This will install the Node.js version specified in the `.nvmrc` file. 31 | 32 | 3. Install the dependencies: 33 | 34 | ```sh 35 | npm install 36 | ``` 37 | 38 | 4. Create a `.env` file in the root of the repository and add the necessary environment variables. Use the `.env.example` file as a reference. 39 | 40 | 5. Run the development server: 41 | 42 | ```sh 43 | npm run dev 44 | ``` 45 | 46 | The app should now be running on `http://localhost:3000`. 47 | 48 | ## GitHub App 49 | 50 | To use the app, you'll need to create a GitHub App and configure it to point to your local development environment. 51 | 52 | 1. Go to your Organization's profile, **Settings**, and select **GitHub Apps**. 53 | 2. Fill in the required fields: 54 | - **GitHub App name**: Private Mirrors App (or any name you prefer) 55 | - **Homepage URL**: `http://localhost:3000` 56 | - **Webhook URL**: `http://localhost:3000/api/webhooks` 57 | - **Webhook secret**: Generate a random secret and add it to your `.env` file as `WEBHOOK_SECRET` 58 | 3. Under **Repository permissions**, set the following permissions: 59 | - **Actions**: Read and write 60 | - **Administration**: Read and write 61 | - **Contents**: Read and write 62 | - **Custom Properties**: Read and write 63 | - **Workflows**: Read and write 64 | 4. Under **Organization permissions**, set the following permissions: 65 | - **Custom properties**: Admin 66 | - **Members**: Read and write 67 | 5. Under **Account permissions**, set the following permissions: 68 | 69 | - **Email addresses**: Read-only 70 | 71 | 6. Under **Subscribe to events**, select the following events: 72 | 73 | - **Installation target** 74 | - **Meta** 75 | - **Branch protection rule** 76 | - **Fork** 77 | - **Public** 78 | - **Push** 79 | - **Repository** 80 | - **Repository dispatch** 81 | - **Workflow dispatch** 82 | - **Workflow job** 83 | - **Workflow run** 84 | 85 | 7. Click **Create GitHub App**. 86 | 8. Generate a private key for the app and add it to your `.env` file as `PRIVATE_KEY`, a base64 encoded version can be used if desired. 87 | 9. Note the **App ID** and **Client ID** and add them to your `.env` file as `APP_ID` and `GITHUB_CLIENT_ID`, respectively. 88 | 10. Generate a new **Client Secret** and add it to your `.env` file as `GITHUB_CLIENT_SECRET`. 89 | 90 | ## Running the App with Docker 91 | 92 | If you prefer to run the app in a Docker container, follow these steps: 93 | 94 | 1. Pull the docker image from the GitHub Container Registry: 95 | 96 | ```sh 97 | docker pull ghcr.io/github-community-projects/private-mirrors:latest 98 | ``` 99 | 100 | Or, if you prefer to make your own, build the Docker image: 101 | 102 | ```sh 103 | docker build -t private-mirrors . 104 | ``` 105 | 106 | 2. Run the Docker container: 107 | 108 | ```sh 109 | docker run --env-file=.env -p 3000:3000 private-mirrors 110 | ``` 111 | 112 | The app should now be running on `http://localhost:3000`. 113 | 114 | ## Testing 115 | 116 | To run the tests, use the following command: 117 | 118 | ```sh 119 | npm test 120 | ``` 121 | 122 | This will run the test suite and display the results in the terminal. 123 | 124 | ## Linting 125 | 126 | To check for linting errors, use the following command: 127 | 128 | ```sh 129 | npm run lint 130 | ``` 131 | 132 | This will run ESLint and display any linting errors in the terminal. 133 | 134 | ## Building 135 | 136 | To build the app for production, use the following command: 137 | 138 | ```sh 139 | npm run build 140 | ``` 141 | 142 | This will create an optimized production build of the app in the `out` directory. 143 | 144 | ## Deployment 145 | 146 | To deploy the app, follow the instructions for your preferred hosting provider. The app can be deployed to any hosting provider that supports Next.js/Docker. 147 | -------------------------------------------------------------------------------- /src/app/components/dialog/EditMirrorDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, FormControl, Label, Link, Text, TextInput } from '@primer/react' 2 | import { Stack } from '@primer/react/lib-esm/Stack' 3 | import { Dialog } from '@primer/react/lib-esm/drafts' 4 | 5 | import { useEffect, useState } from 'react' 6 | 7 | interface EditMirrorDialogProps { 8 | orgLogin: string 9 | forkParentOwnerLogin: string 10 | forkParentName: string 11 | orgId: string 12 | mirrorName: string 13 | isOpen: boolean 14 | closeDialog: () => void 15 | editMirror: (data: { 16 | orgId: string 17 | mirrorName: string 18 | newMirrorName: string 19 | }) => void 20 | } 21 | 22 | export const EditMirrorDialog = ({ 23 | orgLogin, 24 | forkParentOwnerLogin, 25 | forkParentName, 26 | orgId, 27 | mirrorName, 28 | isOpen, 29 | closeDialog, 30 | editMirror, 31 | }: EditMirrorDialogProps) => { 32 | // set to the current mirror name for display purposes 33 | const [newMirrorName, setNewMirrorName] = useState(mirrorName) 34 | 35 | useEffect(() => { 36 | setNewMirrorName(mirrorName) 37 | }, [mirrorName, setNewMirrorName]) 38 | 39 | if (!isOpen) { 40 | return null 41 | } 42 | 43 | return ( 44 | { 51 | closeDialog() 52 | setNewMirrorName(mirrorName) 53 | }, 54 | }, 55 | { 56 | content: 'Confirm', 57 | variant: 'primary', 58 | onClick: () => { 59 | editMirror({ 60 | orgId, 61 | mirrorName, 62 | newMirrorName, 63 | }) 64 | setNewMirrorName(mirrorName) 65 | }, 66 | disabled: newMirrorName === mirrorName || newMirrorName === '', 67 | }, 68 | ]} 69 | onClose={() => { 70 | closeDialog() 71 | setNewMirrorName(mirrorName) 72 | }} 73 | width="large" 74 | > 75 | 76 | 77 | Mirror name 78 | setNewMirrorName(e.target.value)} 80 | block 81 | placeholder={mirrorName} 82 | maxLength={100} 83 | /> 84 | 85 | This is a private mirror of{' '} 86 | 91 | {forkParentOwnerLogin}/{forkParentName} 92 | 93 | 94 | 95 | 96 | Mirror location 97 | 106 | 107 | 108 | 109 | 116 | {orgLogin}/{newMirrorName} 117 | 118 | 119 | 120 | 121 | 128 | Forked from{' '} 129 | 135 | {forkParentOwnerLogin}/{forkParentName} 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | ) 146 | } 147 | -------------------------------------------------------------------------------- /src/bot/octokit.ts: -------------------------------------------------------------------------------- 1 | import { createAppAuth } from '@octokit/auth-app' 2 | import { generatePKCS8Key } from 'utils/pem' 3 | import { logger } from '../utils/logger' 4 | import { Octokit } from './rest' 5 | 6 | const personalOctokitLogger = logger.getSubLogger({ name: 'personal-octokit' }) 7 | const appOctokitLogger = logger.getSubLogger({ name: 'app-octokit' }) 8 | 9 | const privateKey = 10 | process.env.PRIVATE_KEY && 11 | !process.env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----') 12 | ? // Support optional base64 decoding of the private key to prevent issues with complicated environment variable passing scenarios 13 | Buffer.from(process.env.PRIVATE_KEY, 'base64').toString('utf8') 14 | : // Handle a bug with multiline envs in docker - See https://github.com/moby/moby/issues/46773 15 | (process.env.PRIVATE_KEY?.replace(/\\n/g, '\n') ?? '') 16 | 17 | /** 18 | * Generates an app access token for the app or an installation (if installationId is provided) 19 | * @param installationId An optional installation ID to generate an app access token for 20 | * @returns An access token for the app or installation 21 | */ 22 | export const generateAppAccessToken = async (installationId?: string) => { 23 | const convertedKey = generatePKCS8Key(privateKey) 24 | 25 | if (installationId) { 26 | const auth = createAppAuth({ 27 | appId: process.env.APP_ID!, 28 | privateKey: convertedKey, 29 | installationId: installationId, 30 | }) 31 | 32 | const appAuthentication = await auth({ 33 | type: 'installation', 34 | }) 35 | 36 | return appAuthentication.token 37 | } 38 | 39 | const auth = createAppAuth({ 40 | appId: process.env.APP_ID!, 41 | privateKey, 42 | clientId: process.env.CLIENT_ID!, 43 | clientSecret: process.env.CLIENT_SECRET!, 44 | }) 45 | 46 | const appAuthentication = await auth({ 47 | type: 'app', 48 | }) 49 | 50 | return appAuthentication.token 51 | } 52 | 53 | /** 54 | * Creates a new octokit instance that is authenticated as the app 55 | * @returns Octokit authorized as the app 56 | */ 57 | export const appOctokit = () => { 58 | const convertedKey = generatePKCS8Key(privateKey) 59 | 60 | return new Octokit({ 61 | authStrategy: createAppAuth, 62 | auth: { 63 | appId: process.env.APP_ID!, 64 | privateKey: convertedKey, 65 | clientId: process.env.CLIENT_ID!, 66 | clientSecret: process.env.CLIENT_SECRET!, 67 | }, 68 | log: appOctokitLogger, 69 | }) 70 | } 71 | 72 | /** 73 | * Creates a new octokit instance that is authenticated as the installation 74 | * @param installationId installation ID to authenticate as 75 | * @returns Octokit authorized as the installation 76 | */ 77 | export const installationOctokit = (installationId: string) => { 78 | const convertedKey = generatePKCS8Key(privateKey) 79 | 80 | return new Octokit({ 81 | authStrategy: createAppAuth, 82 | auth: { 83 | appId: process.env.APP_ID!, 84 | privateKey: convertedKey, 85 | installationId: installationId, 86 | }, 87 | log: appOctokitLogger, 88 | }) 89 | } 90 | 91 | /** 92 | * Creates a new octokit instance that is authenticated as the user 93 | * @param token personal access token 94 | * @returns Octokit authorized with the personal access token 95 | */ 96 | export const personalOctokit = (token: string) => { 97 | return new Octokit({ 98 | auth: token, 99 | log: personalOctokitLogger, 100 | }) 101 | } 102 | 103 | /** 104 | * Fetches octokit installations for both the contribution org and the private org 105 | * @param contributionOrgId Id of the contribution org 106 | * @param privateOrgId Id of the private org 107 | * @returns octokit instances for both the contribution and private orgs 108 | */ 109 | export const getAuthenticatedOctokit = async ( 110 | contributionOrgId: string, 111 | privateOrgId: string, 112 | ) => { 113 | const contributionInstallationId = 114 | await appOctokit().rest.apps.getOrgInstallation({ 115 | org: contributionOrgId, 116 | }) 117 | 118 | const contributionAccessToken = await generateAppAccessToken( 119 | String(contributionInstallationId.data.id), 120 | ) 121 | const contributionOctokit = installationOctokit( 122 | String(contributionInstallationId.data.id), 123 | ) 124 | 125 | const privateInstallationId = await appOctokit().rest.apps.getOrgInstallation( 126 | { 127 | org: privateOrgId, 128 | }, 129 | ) 130 | 131 | const privateAccessToken = await generateAppAccessToken( 132 | String(privateInstallationId.data.id), 133 | ) 134 | const privateOctokit = installationOctokit( 135 | String(privateInstallationId.data.id), 136 | ) 137 | 138 | return { 139 | contribution: { 140 | accessToken: contributionAccessToken, 141 | octokit: contributionOctokit, 142 | installationId: String(contributionInstallationId.data.id), 143 | }, 144 | private: { 145 | accessToken: privateAccessToken, 146 | octokit: privateOctokit, 147 | installationId: String(privateInstallationId.data.id), 148 | }, 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /test/app.test.ts: -------------------------------------------------------------------------------- 1 | import nock from 'nock' 2 | 3 | // Requiring our app implementation 4 | import { Probot, ProbotOctokit } from 'probot' 5 | import { Octomock } from './octomock' 6 | 7 | // Requiring our fixtures 8 | import fs from 'fs' 9 | import path from 'path' 10 | import { 11 | forkBranchProtectionRulesetGQL, 12 | getBranchProtectionRulesetGQL, 13 | mirrorBranchProtectionRulesetGQL, 14 | } from '../src/bot/graphql' 15 | import forkCreatedPayload from './fixtures/fork.created.json' 16 | import mirrorCreatedPayload from './fixtures/mirror.created.json' 17 | 18 | const om = new Octomock() 19 | 20 | const privateKey = fs.readFileSync( 21 | path.join(__dirname, 'fixtures/mock-cert.pem'), 22 | 'utf-8', 23 | ) 24 | 25 | process.env.PRIVATE_KEY = privateKey 26 | 27 | import * as app from '../src/bot' // import app after setting up the environment variable 28 | 29 | describe('Webhooks events', () => { 30 | let probot: Probot 31 | 32 | beforeEach(() => { 33 | nock.disableNetConnect() 34 | om.resetMocks() 35 | probot = new Probot({ 36 | appId: 12345, 37 | privateKey, 38 | // disable request throttling and retries for testing 39 | Octokit: ProbotOctokit.defaults({ 40 | retry: { enabled: false }, 41 | throttle: { enabled: false }, 42 | }), 43 | }) 44 | // Load our app into probot 45 | probot.load(app.default) 46 | }) 47 | 48 | test('creates branch protections for a fork', async () => { 49 | const mock = nock('https://api.github.com') 50 | // Test that we can hit the app endpoints 51 | .get('/app') 52 | .reply(200, { 53 | // Return data about the app 54 | data: { 55 | id: 12345, 56 | node_id: 'fake-node-id', 57 | }, 58 | }) 59 | 60 | // Test that we correctly return a test token 61 | .post('/app/installations/12345/access_tokens') 62 | .reply(200, { 63 | token: 'test-token', 64 | permissions: { 65 | issues: 'write', 66 | }, 67 | }) 68 | 69 | // Test to see that we check for the branch protection ruleset with gql 70 | .post('/graphql', (body) => { 71 | expect(body).toMatchObject({ 72 | query: getBranchProtectionRulesetGQL, 73 | variables: { 74 | owner: forkCreatedPayload.repository.owner.login, 75 | name: forkCreatedPayload.repository.name, 76 | }, 77 | }) 78 | return body 79 | }) 80 | .reply(200, { 81 | data: { 82 | repository: { 83 | rulesets: { 84 | nodes: [], 85 | }, 86 | }, 87 | }, 88 | }) 89 | 90 | // Test to see that we create the branch protection ruleset with gql 91 | .post('/graphql', (body) => { 92 | expect(body).toMatchObject({ 93 | query: forkBranchProtectionRulesetGQL, 94 | variables: { 95 | repositoryId: forkCreatedPayload.repository.node_id, 96 | ruleName: 'all-branch-protections-pma', 97 | }, 98 | }) 99 | return body 100 | }) 101 | .reply(200) 102 | 103 | // Receive a webhook event 104 | await probot.receive({ 105 | id: forkCreatedPayload.action, 106 | name: 'repository', 107 | payload: forkCreatedPayload as any, 108 | }) 109 | 110 | expect(mock.pendingMocks()).toStrictEqual([]) 111 | }) 112 | 113 | test('creates branch protections for a mirror', async () => { 114 | const mock = nock('https://api.github.com') 115 | // Test that we can hit the app endpoints 116 | .get('/app') 117 | .reply(200, { 118 | // Return data about the app 119 | data: { 120 | id: 12345, 121 | node_id: 'fake-node-id', 122 | }, 123 | }) 124 | 125 | // Test that we correctly return a test token 126 | .post('/app/installations/12345/access_tokens') 127 | .reply(200, { 128 | token: 'test-token', 129 | permissions: { 130 | issues: 'write', 131 | }, 132 | }) 133 | 134 | // Test to see that we check for the branch protection ruleset with gql 135 | .post('/graphql', (body) => { 136 | expect(body).toMatchObject({ 137 | query: getBranchProtectionRulesetGQL, 138 | variables: { 139 | owner: mirrorCreatedPayload.repository.owner.login, 140 | name: mirrorCreatedPayload.repository.name, 141 | }, 142 | }) 143 | return body 144 | }) 145 | .reply(200, { 146 | data: { 147 | repository: { 148 | rulesets: { 149 | nodes: [], 150 | }, 151 | }, 152 | }, 153 | }) 154 | 155 | // Test to see that we create the branch protection ruleset with gql 156 | .post('/graphql', (body) => { 157 | expect(body).toMatchObject({ 158 | query: mirrorBranchProtectionRulesetGQL, 159 | variables: { 160 | repositoryId: mirrorCreatedPayload.repository.node_id, 161 | ruleName: 'default-branch-protection-pma', 162 | }, 163 | }) 164 | return body 165 | }) 166 | .reply(200) 167 | 168 | // Receive a webhook event 169 | await probot.receive({ 170 | id: mirrorCreatedPayload.action, 171 | name: 'repository', 172 | payload: mirrorCreatedPayload as any, 173 | }) 174 | 175 | expect(mock.pendingMocks()).toStrictEqual([]) 176 | }) 177 | }) 178 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Avatar, Box, Link, Octicon } from '@primer/react' 4 | import { useState } from 'react' 5 | import { OrgsData, useOrgsData } from 'hooks/useOrganizations' 6 | import { Search } from './components/search/Search' 7 | import { DataTable, Table } from '@primer/react/lib-esm/DataTable' 8 | import Fuse from 'fuse.js' 9 | import Blankslate from '@primer/react/lib-esm/Blankslate/Blankslate' 10 | import { OrganizationIcon } from '@primer/octicons-react' 11 | import { Stack } from '@primer/react/lib-esm/Stack' 12 | import { WelcomeHeader } from './components/header/WelcomeHeader' 13 | import { ErrorFlash } from './components/flash/ErrorFlash' 14 | 15 | const Home = () => { 16 | const orgsData = useOrgsData() 17 | 18 | // set search value to be empty string by default 19 | const [searchValue, setSearchValue] = useState('') 20 | 21 | // values for pagination 22 | const pageSize = 10 23 | const [pageIndex, setPageIndex] = useState(0) 24 | const start = pageIndex * pageSize 25 | const end = start + pageSize 26 | 27 | // show loading table 28 | if (orgsData.isLoading) { 29 | return ( 30 | 31 | 32 | 37 | 38 | 48 | 49 | 50 | 51 | ) 52 | } 53 | 54 | // show blankslate if no organizations are found 55 | if (!orgsData.data || orgsData.data.length === 0) { 56 | return ( 57 | 58 | 59 | 60 | {orgsData.error && ( 61 | 64 | )} 65 | 66 | 71 | 79 | 80 | 81 | 82 | 87 | 88 | 89 | No organizations found 90 | 91 | Please install the app in an organization to see it here. 92 | 93 | 94 | 95 | 96 | ) 97 | } 98 | 99 | // set up search 100 | const fuse = new Fuse(orgsData.data, { 101 | keys: ['login'], 102 | threshold: 0.2, 103 | }) 104 | 105 | // perform search if there is a search value 106 | let orgsSet: OrgsData = [] 107 | if (searchValue) { 108 | orgsSet = fuse.search(searchValue).map((result) => result.item) 109 | } else { 110 | orgsSet = orgsData.data 111 | } 112 | 113 | // slice the data based on the pagination 114 | const orgsPaginationSet = orgsSet.slice(start, end) 115 | 116 | return ( 117 | 118 | 119 | 124 | 125 | { 136 | return ( 137 | 138 | 139 | 140 | 141 | 142 | 150 | {row.login} 151 | 152 | 153 | 154 | ) 155 | }, 156 | }, 157 | ]} 158 | cellPadding="spacious" 159 | /> 160 | { 165 | setPageIndex(pageIndex) 166 | }} 167 | /> 168 | 169 | 170 | ) 171 | } 172 | 173 | export default Home 174 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true /* Enable incremental compilation */, 5 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | "lib": [ 8 | "es2015", 9 | "es2017", 10 | "DOM" 11 | ] /* Specify library files to be included in the compilation. */, 12 | "allowJs": true /* Allow javascript files to be compiled. */, 13 | "checkJs": true /* Report errors in .js files. */, 14 | "jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 15 | // "declaration": true /* Generates corresponding '.d.ts' file. */, 16 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 17 | // "sourceMap": true /* Generates corresponding '.map' file. */, 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "./build" /* Redirect output structure to the directory. */, 20 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 21 | // "composite": true, /* Enable project compilation */ 22 | // "tsBuildInfoFile": "./" /* Specify file to store incremental compilation information */, 23 | "removeComments": true /* Do not emit comments to output. */, 24 | "noEmit": true /* Do not emit outputs. */, 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | "isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */, 28 | /* Strict Type-Checking Options */ 29 | "strict": true /* Enable all strict type-checking options. */, 30 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | "strictNullChecks": true /* Enable strict null checks. */, 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | /* Additional Checks */ 38 | "noUnusedLocals": false /* Report errors on unused locals. */, 39 | "noUnusedParameters": true /* Report errors on unused parameters. */, 40 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 41 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 42 | /* Module Resolution Options */ 43 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 44 | "baseUrl": "src" /* Base directory to resolve non-absolute module names. */, 45 | "paths": { 46 | /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | "@/bot": ["bot"], 48 | "@/utils": ["utils"] 49 | }, 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */, 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | /* Advanced Options */ 66 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 67 | "resolveJsonModule": true, 68 | "pretty": false, 69 | "skipLibCheck": true, 70 | "incremental": true, 71 | "plugins": [ 72 | { 73 | "name": "next" 74 | } 75 | ] 76 | }, 77 | "include": ["src", ".next/types/**/*.ts"], 78 | "exclude": ["node_modules", "test"], 79 | "compileOnSave": false 80 | } 81 | -------------------------------------------------------------------------------- /docs/attribution-flow.md: -------------------------------------------------------------------------------- 1 | # Attribution Flow 2 | 3 | ## Overview 4 | 5 | Enterprise Managed Users (EMU) are a [feature of GitHub Enterprise Cloud](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-iam/understanding-iam-for-enterprises/about-enterprise-managed-users) which provides a "walled garden" user account that cannot interact with public repositories on github.com, including filing issues, commenting on discussions, and raising pull requests. Large organizations use EMUs to provide a tighter degree of control over their user accounts, but this control can come at the cost of participation in open source communities. 6 | 7 | One of the goals of the Private Mirrors App is to enable contributions from EMUs, and this doc explains how to do it. 8 | 9 | ## How Attributions Work 10 | 11 | Git commits under the hood are associated with email addresses. GitHub makes a convenient association between email addresses and user accounts for purposes of attribution, contribution graphs, etc, but it's email underneath all that. So in the case where a contribution is crossing user accounts and especially across EMU boundaries, as long as there is some association between the public github.com user and an email address, the attributions will be linked up automatically. 12 | 13 | For contributions which originate on a private mirror and PMA syncs to the public fork, this linkage can go in either direction: 14 | 15 | - the public github.com account can have the EMU account's email address added as a secondary address, and commits made with that email will be attributed to the user. **or** 16 | - a user inside the EMU boundary can configure their git client to commit with an address associated with their public account. :arrow_left: 17 | 18 | We recommend the second option because it does not expose any internal information, namely the user's email address, to the public contribution graph. 19 | 20 | Sometimes stakeholders will raise the idea of funneling all contributions through a "role" account like `@bigcorp-opensource`, but we strongly discourage this. It's both bad for maintainers (people want contributions from humans not corporations) and for contributors (the attribution of their work to the open source world is often a primary driver for wanting to contribute in the first place). 21 | 22 | ## Configuring git-config 23 | 24 | So, to ensure that contributions made by an EMU are properly attributed to their public GitHub account, the user needs to configure their local git-config to use an email address associated with their public account. This should be done at the repository level, when they are working in a private mirror managed by PMA, rather than as a global configuration. 25 | 26 | ```sh 27 | git config --local user.email "your-public-email@example.com" 28 | ``` 29 | 30 | ## Example 31 | 32 | Here is an example of how the attribution flow works: 33 | 34 | 1. An EMU user configures their local git-config to use the email address associated with their public GitHub account. 35 | 2. The user makes a contribution to a private mirror repository. 36 | 3. The contribution is reviewed and merged into the private mirror's default branch. 37 | 4. The Private Mirrors App automatically syncs the private mirror to the public fork. 38 | 5. The contribution is now visible in the public fork and is attributed to the user's public GitHub account. 39 | 6. The user can then switch to their public account and open a pull request from the public fork to the upstream repository, and the contribution will be properly attributed to their public identity. 40 | 41 | ### Automatically Configuring Git (Advanced) 42 | 43 | Git config supports a [conditional includes feature](https://git-scm.com/docs/git-config#_conditional_includes) to automatically include configuration conditionally based on metadata of the repository locally. Email, a [key for signing commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits), and other helpful settings can be configured automatically with this feature. It helps prevent mistakes from forgetting to configure settings within each repository. 44 | 45 | To configure conditional includes, start by creating a file in the same user directory where your `.gitconfig` is stored. Add the following content to the file: 46 | 47 | ```ini 48 | [user] 49 | # replace this with your email address 50 | email = your-public-email@example.com 51 | # remove this option if not used 52 | signingKey = 53 | ``` 54 | 55 | This file can be named anything, but for this example, we'll call it `.gitconfig-pma`. 56 | 57 | Then, add configuration to your `.gitconfig` to conditionally use the `.gitconfig-pma` file when in a repository used for contributions through PMA: 58 | 59 | ```ini 60 | # Include config based on the remote HTTPS URL of the repository 61 | # Replace the github.com remote URL below with the remote URL used for private mirror contributions 62 | [includeIf "hasconfig:remote.*.url:https://github.com/**"] 63 | path = .gitconfig-pma 64 | 65 | # Include config based on the remote git URL of the repository 66 | # Replace the github.com remote URL below with the remote URL used for private mirror contributions 67 | [includeIf "hasconfig:remote.*.url:git@github.com*/**"] 68 | path = .gitconfig-pma 69 | 70 | # Include config based on directory in which the repository resides 71 | # Replace the **/pma/** directory example below with the directory used for private mirror contributions 72 | [includeIf "gitdir:**/pma/**/.git"] 73 | path = .gitconfig-pma 74 | ``` 75 | 76 | Choose one or more of the above options, based on your needs; not all configuration approaches may be needed. 77 | 78 | With this configured, git should automatically set the user email and any other settings defined in the conditional config within matching repositories. You can verify the settings are applied by running `git config get