diff --git a/e2e/e2e.spec.ts b/e2e/e2e.spec.ts index 35996af..34a2b66 100644 --- a/e2e/e2e.spec.ts +++ b/e2e/e2e.spec.ts @@ -106,7 +106,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive = await makeReleaseTarball(repo, "unversioned-1.0.0"); @@ -118,6 +118,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -144,7 +145,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive = await makeReleaseTarball(repo, "versioned-1.0.0"); @@ -156,6 +157,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -183,7 +185,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive = await makeReleaseTarball(repo, "tarball-1.0.0"); @@ -195,6 +197,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -222,7 +225,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive = await makeReleaseZip(repo, "zip-1.0.0"); @@ -234,6 +237,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -261,7 +265,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive = await makeReleaseTarball(repo); @@ -273,6 +277,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -300,7 +305,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive = await makeReleaseTarball(repo); @@ -312,6 +317,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -327,12 +333,6 @@ describe("e2e tests", () => { }); test("happy path", async () => { - // This test checks for authentication token requests which may be cached. - // Restart the cloud functions server to ensure a consistently uncached - // state for this test. - await cloudFunctions.shutdown(); - await cloudFunctions.start(); - const repo = Fixture.Versioned; const tag = "v1.0.0"; await setupLocalRemoteRulesetRepo(repo, tag, releaser); @@ -357,6 +357,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + rulesetInstallationId, { owner: testOrg, repo, @@ -422,7 +423,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive1 = await makeReleaseTarball(repo, "module-1.0.0"); @@ -441,6 +442,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -510,7 +512,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive = await makeReleaseTarball( @@ -525,6 +527,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -556,7 +559,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(releaser.login!, "bazel-central-registry"); const releaseArchive = await makeReleaseTarball(repo, "versioned-1.0.0"); @@ -568,6 +571,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -605,7 +609,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); // App not installed to fork // fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); @@ -618,6 +622,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, @@ -652,7 +657,7 @@ describe("e2e tests", () => { "bazelbuild", "bazel-central-registry" ); - fakeGitHub.mockAppInstallation(testOrg, repo); + const installationId = fakeGitHub.mockAppInstallation(testOrg, repo); fakeGitHub.mockAppInstallation(testOrg, "bazel-central-registry"); const releaseArchive = await makeReleaseTarball(repo, "versioned-1.0.0"); @@ -664,6 +669,7 @@ describe("e2e tests", () => { const response = await publishReleaseEvent( cloudFunctions.getBaseUrl(), secrets.webhookSecret, + installationId, { owner: testOrg, repo, diff --git a/e2e/helpers/webhook.ts b/e2e/helpers/webhook.ts index c78c5eb..e82c500 100644 --- a/e2e/helpers/webhook.ts +++ b/e2e/helpers/webhook.ts @@ -7,6 +7,7 @@ import type { DeepPartial } from "./types"; export async function publishReleaseEvent( webhookUrl: string, webhookSecret: string, + installationId: number, release: { owner: string; repo: string; tag: string; releaser: Partial } ): Promise { const body: DeepPartial = { @@ -22,6 +23,9 @@ export async function publishReleaseEvent( html_url: `https://github.com/${release.owner}/${release.repo}/releases/tag/${release.tag}`, tag_name: release.tag, }, + installation: { + id: installationId, + }, }; const signature = await sign(webhookSecret, JSON.stringify(body)); diff --git a/e2e/stubs/fake-github.ts b/e2e/stubs/fake-github.ts index a56edc1..4f49fe1 100644 --- a/e2e/stubs/fake-github.ts +++ b/e2e/stubs/fake-github.ts @@ -1,5 +1,5 @@ import { User } from "@octokit/webhooks-types"; -import { randomInt, randomUUID } from "crypto"; +import { randomUUID } from "crypto"; import * as mockttp from "mockttp"; import url from "node:url"; import { StubbedServer } from "./stubbed-server"; @@ -74,14 +74,15 @@ export class FakeGitHub implements StubbedServer { this.repositories.clear(); } + private nextInstallationId = 1; public mockAppInstallation(owner: string, repo: string): number { - const installationId = randomInt(50000); + const installationId = this.nextInstallationId++; this.appInstallations.set(`${owner}/${repo}`, installationId); return installationId; } public mockBotAppInstallation(owner: string, repo: string): number { - const installationId = randomInt(50000); + const installationId = this.nextInstallationId++; this.botAppInstallations.set(`${owner}/${repo}`, installationId); return installationId; } diff --git a/src/application/cloudfunction/github-webhook-entrypoint.ts b/src/application/cloudfunction/github-webhook-entrypoint.ts index 68fec66..f7c1e8a 100644 --- a/src/application/cloudfunction/github-webhook-entrypoint.ts +++ b/src/application/cloudfunction/github-webhook-entrypoint.ts @@ -1,46 +1,19 @@ import { HttpFunction } from "@google-cloud/functions-framework"; import { Webhooks } from "@octokit/webhooks"; -import { CreateEntryService } from "../../domain/create-entry.js"; -import { FindRegistryForkService } from "../../domain/find-registry-fork.js"; -import { PublishEntryService } from "../../domain/publish-entry.js"; -import { Repository } from "../../domain/repository.js"; -import { EmailClient } from "../../infrastructure/email.js"; -import { GitClient } from "../../infrastructure/git.js"; -import { GitHubClient } from "../../infrastructure/github.js"; import { SecretsClient } from "../../infrastructure/secrets.js"; -import { NotificationsService } from "../notifications.js"; import { ReleaseEventHandler } from "../release-event-handler.js"; -// Setup application dependencies using constructor dependency injection. -const secretsClient = new SecretsClient(); -const gitClient = new GitClient(); -const githubClient = new GitHubClient(); -const emailClient = new EmailClient(); -const findRegistryForkService = new FindRegistryForkService(githubClient); -const createEntryService = new CreateEntryService(gitClient, githubClient); -const publishEntryService = new PublishEntryService(githubClient); -const notificationsService = new NotificationsService( - emailClient, - secretsClient, - githubClient -); - -const releaseEventHandler = new ReleaseEventHandler( - githubClient, - secretsClient, - findRegistryForkService, - createEntryService, - publishEntryService, - notificationsService -); -Repository.gitClient = gitClient; - // Handle incoming GitHub webhook messages. This is the entrypoint for // the webhook cloud function. export const handleGithubWebhookEvent: HttpFunction = async ( request, response ) => { + // Setup application dependencies using constructor dependency injection. + const secretsClient = new SecretsClient(); + + const releaseEventHandler = new ReleaseEventHandler(secretsClient); + const githubWebhookSecret = await secretsClient.accessSecret( "github-app-webhook-secret" ); diff --git a/src/application/octokit.ts b/src/application/octokit.ts new file mode 100644 index 0000000..0e8a3eb --- /dev/null +++ b/src/application/octokit.ts @@ -0,0 +1,41 @@ +import { Octokit } from "@octokit/rest"; +import { getAppAuthorizedOctokit } from "../infrastructure/github.js"; +import { SecretsClient } from "../infrastructure/secrets.js"; + +export async function createAppAuthorizedOctokit( + secretsClient: SecretsClient +): Promise { + const [githubAppPrivateKey, githubAppClientId, githubAppClientSecret] = + await Promise.all([ + secretsClient.accessSecret("github-app-private-key"), + secretsClient.accessSecret("github-app-client-id"), + secretsClient.accessSecret("github-app-client-secret"), + ]); + + return getAppAuthorizedOctokit( + Number(process.env.GITHUB_APP_ID), + githubAppPrivateKey, + githubAppClientId, + githubAppClientSecret + ); +} + +export async function createBotAppAuthorizedOctokit( + secretsClient: SecretsClient +): Promise { + const [ + githubBotAppPrivateKey, + githubBotAppClientId, + githubBotAppClientSecret, + ] = await Promise.all([ + secretsClient.accessSecret("github-bot-app-private-key"), + secretsClient.accessSecret("github-bot-app-client-id"), + secretsClient.accessSecret("github-bot-app-client-secret"), + ]); + return getAppAuthorizedOctokit( + Number(process.env.GITHUB_BOT_APP_ID), + githubBotAppPrivateKey, + githubBotAppClientId, + githubBotAppClientSecret + ); +} diff --git a/src/application/release-event-handler.ts b/src/application/release-event-handler.ts index b4d4d5c..31de442 100644 --- a/src/application/release-event-handler.ts +++ b/src/application/release-event-handler.ts @@ -1,4 +1,3 @@ -import { StrategyOptions as GitHubAuth } from "@octokit/auth-app"; import { ReleasePublishedEvent } from "@octokit/webhooks-types"; import { HandlerFunction } from "@octokit/webhooks/dist-types/types"; import { CreateEntryService } from "../domain/create-entry.js"; @@ -11,9 +10,15 @@ import { RulesetRepository, } from "../domain/ruleset-repository.js"; import { User } from "../domain/user.js"; +import { EmailClient } from "../infrastructure/email.js"; +import { GitClient } from "../infrastructure/git.js"; import { GitHubClient } from "../infrastructure/github.js"; import { SecretsClient } from "../infrastructure/secrets.js"; import { NotificationsService } from "./notifications.js"; +import { + createAppAuthorizedOctokit, + createBotAppAuthorizedOctokit, +} from "./octokit.js"; interface PublishAttempt { readonly successful: boolean; @@ -22,30 +27,50 @@ interface PublishAttempt { } export class ReleaseEventHandler { - constructor( - private readonly githubClient: GitHubClient, - private readonly secretsClient: SecretsClient, - private readonly findRegistryForkService: FindRegistryForkService, - private readonly createEntryService: CreateEntryService, - private readonly publishEntryService: PublishEntryService, - private readonly notificationsService: NotificationsService - ) {} + constructor(private readonly secretsClient: SecretsClient) {} public readonly handle: HandlerFunction<"release.published", unknown> = async (event) => { + const repository = repositoryFromPayload(event.payload); const bcr = Repository.fromCanonicalName( process.env.BAZEL_CENTRAL_REGISTRY ); - const [webhookAppAuth, botAppAuth] = await Promise.all([ - this.getGitHubWebhookAppAuth(), - this.getGitHubBotAppAuth(), - ]); - this.githubClient.setAppAuth(webhookAppAuth); + // The "app" refers to the public facing GitHub app installed to users' + // ruleset repos and BCR Forks that creates and pushes the entry to the + // fork. The "bot app" refers to the private app only installed to the + // canonical BCR which has reduced permissions and only opens PRs. + const appOctokit = await createAppAuthorizedOctokit(this.secretsClient); + const rulesetGitHubClient = await GitHubClient.forRepoInstallation( + appOctokit, + repository, + event.payload.installation.id + ); + + const botAppOctokit = await createBotAppAuthorizedOctokit( + this.secretsClient + ); + const bcrGitHubClient = await GitHubClient.forRepoInstallation( + botAppOctokit, + bcr + ); + + const gitClient = new GitClient(); + Repository.gitClient = gitClient; + + const emailClient = new EmailClient(); + const findRegistryForkService = new FindRegistryForkService( + rulesetGitHubClient + ); + const publishEntryService = new PublishEntryService(bcrGitHubClient); + const notificationsService = new NotificationsService( + emailClient, + this.secretsClient, + rulesetGitHubClient + ); const repoCanonicalName = `${event.payload.repository.owner.login}/${event.payload.repository.name}`; - const repository = repositoryFromPayload(event.payload); - let releaser = await this.githubClient.getRepoUser( + let releaser = await rulesetGitHubClient.getRepoUser( event.payload.sender.login, repository ); @@ -57,7 +82,8 @@ export class ReleaseEventHandler { const createRepoResult = await this.validateRulesetRepoOrNotifyFailure( repository, tag, - releaser + releaser, + notificationsService ); if (!createRepoResult.successful) { return; @@ -67,14 +93,18 @@ export class ReleaseEventHandler { console.log(`Release author: ${releaser.username}`); - releaser = await this.overrideReleaser(releaser, rulesetRepo); + releaser = await this.overrideReleaser( + releaser, + rulesetRepo, + rulesetGitHubClient + ); console.log( `Release published: ${rulesetRepo.canonicalName}@${tag} by @${releaser.username}` ); const candidateBcrForks = - await this.findRegistryForkService.findCandidateForks( + await findRegistryForkService.findCandidateForks( rulesetRepo, releaser ); @@ -91,6 +121,15 @@ export class ReleaseEventHandler { const attempts: PublishAttempt[] = []; for (let bcrFork of candidateBcrForks) { + const forkGitHubClient = await GitHubClient.forRepoInstallation( + appOctokit, + bcrFork + ); + const createEntryService = new CreateEntryService( + gitClient, + forkGitHubClient + ); + const attempt = await this.attemptPublish( rulesetRepo, bcrFork, @@ -99,8 +138,8 @@ export class ReleaseEventHandler { moduleRoot, releaser, releaseUrl, - webhookAppAuth, - botAppAuth + createEntryService, + publishEntryService ); attempts.push(attempt); @@ -112,7 +151,7 @@ export class ReleaseEventHandler { // Send out error notifications if none of the attempts succeeded if (!attempts.some((a) => a.successful)) { - await this.notificationsService.notifyError( + await notificationsService.notifyError( releaser, rulesetRepo.metadataTemplate(moduleRoot).maintainers, rulesetRepo, @@ -125,7 +164,7 @@ export class ReleaseEventHandler { // Handle any other unexpected errors console.log(error); - await this.notificationsService.notifyError( + await notificationsService.notifyError( releaser, [], Repository.fromCanonicalName(repoCanonicalName), @@ -140,7 +179,8 @@ export class ReleaseEventHandler { private async validateRulesetRepoOrNotifyFailure( repository: Repository, tag: string, - releaser: User + releaser: User, + notificationsService: NotificationsService ): Promise<{ rulesetRepo?: RulesetRepository; successful: boolean }> { try { const rulesetRepo = await RulesetRepository.create( @@ -176,7 +216,7 @@ export class ReleaseEventHandler { ); } - await this.notificationsService.notifyError( + await notificationsService.notifyError( releaser, maintainers, repository, @@ -199,36 +239,32 @@ export class ReleaseEventHandler { moduleRoot: string, releaser: User, releaseUrl: string, - webhookAppAuth: GitHubAuth, - botAppAuth: GitHubAuth + createEntryService: CreateEntryService, + publishEntryService: PublishEntryService ): Promise { console.log(`Attempting publish to fork ${bcrFork.canonicalName}.`); try { - await this.createEntryService.createEntryFiles( + await createEntryService.createEntryFiles( rulesetRepo, bcr, tag, moduleRoot ); - this.githubClient.setAppAuth(webhookAppAuth); - - const branch = await this.createEntryService.commitEntryToNewBranch( + const branch = await createEntryService.commitEntryToNewBranch( rulesetRepo, bcr, tag, releaser ); - await this.createEntryService.pushEntryToFork(bcrFork, bcr, branch); + await createEntryService.pushEntryToFork(bcrFork, bcr, branch); console.log( `Pushed bcr entry for module '${moduleRoot}' to fork ${bcrFork.canonicalName} on branch ${branch}` ); - this.githubClient.setAppAuth(botAppAuth); - - await this.publishEntryService.sendRequest( + await publishEntryService.sendRequest( tag, bcrFork, bcr, @@ -262,7 +298,8 @@ export class ReleaseEventHandler { private async overrideReleaser( releaser: User, - rulesetRepo: RulesetRepository + rulesetRepo: RulesetRepository, + githubClient: GitHubClient ): Promise { // Use the release author unless a fixedReleaser is configured if (rulesetRepo.config.fixedReleaser) { @@ -271,7 +308,7 @@ export class ReleaseEventHandler { ); // Fetch the releaser to get their name - const fixedReleaser = await this.githubClient.getRepoUser( + const fixedReleaser = await githubClient.getRepoUser( rulesetRepo.config.fixedReleaser.login, rulesetRepo ); @@ -285,36 +322,6 @@ export class ReleaseEventHandler { return releaser; } - - private async getGitHubWebhookAppAuth(): Promise { - const [githubAppPrivateKey, githubAppClientId, githubAppClientSecret] = - await Promise.all([ - this.secretsClient.accessSecret("github-app-private-key"), - this.secretsClient.accessSecret("github-app-client-id"), - this.secretsClient.accessSecret("github-app-client-secret"), - ]); - return { - appId: process.env.GITHUB_APP_ID, - privateKey: githubAppPrivateKey, - clientId: githubAppClientId, - clientSecret: githubAppClientSecret, - }; - } - - private async getGitHubBotAppAuth(): Promise { - const [githubAppPrivateKey, githubAppClientId, githubAppClientSecret] = - await Promise.all([ - this.secretsClient.accessSecret("github-bot-app-private-key"), - this.secretsClient.accessSecret("github-bot-app-client-id"), - this.secretsClient.accessSecret("github-bot-app-client-secret"), - ]); - return { - appId: process.env.GITHUB_BOT_APP_ID, - privateKey: githubAppPrivateKey, - clientId: githubAppClientId, - clientSecret: githubAppClientSecret, - }; - } } function repositoryFromPayload(payload: ReleasePublishedEvent): Repository { diff --git a/src/domain/create-entry.spec.ts b/src/domain/create-entry.spec.ts index e5987ac..0d38d91 100644 --- a/src/domain/create-entry.spec.ts +++ b/src/domain/create-entry.spec.ts @@ -88,7 +88,7 @@ beforeEach(() => { }); mockGitClient = mocked(new GitClient()); - mockGithubClient = mocked(new GitHubClient()); + mockGithubClient = mocked(new GitHubClient({} as any)); mocked(computeIntegrityHash).mockReturnValue(`sha256-${randomUUID()}`); Repository.gitClient = mockGitClient; createEntryService = new CreateEntryService(mockGitClient, mockGithubClient); diff --git a/src/domain/find-registry-fork.spec.ts b/src/domain/find-registry-fork.spec.ts index 8e6d69e..49603c5 100644 --- a/src/domain/find-registry-fork.spec.ts +++ b/src/domain/find-registry-fork.spec.ts @@ -25,7 +25,7 @@ beforeEach(() => { mocked(GitHubClient, true).mockClear(); mockRulesetRepoCreate.mockClear(); - mockGithubClient = mocked(new GitHubClient()); + mockGithubClient = mocked(new GitHubClient({} as any)); findRegistryForkService = new FindRegistryForkService(mockGithubClient); }); diff --git a/src/domain/publish-entry.spec.ts b/src/domain/publish-entry.spec.ts index 2c53a9c..4cf435e 100644 --- a/src/domain/publish-entry.spec.ts +++ b/src/domain/publish-entry.spec.ts @@ -9,7 +9,7 @@ let publishEntryService: PublishEntryService; let mockGithubClient: Mocked; beforeEach(() => { mocked(GitHubClient, true).mockClear(); - mockGithubClient = mocked(new GitHubClient()); + mockGithubClient = mocked(new GitHubClient({} as any)); publishEntryService = new PublishEntryService(mockGithubClient); }); diff --git a/src/infrastructure/github.ts b/src/infrastructure/github.ts index 6af5424..4d5d7ff 100644 --- a/src/infrastructure/github.ts +++ b/src/infrastructure/github.ts @@ -1,14 +1,71 @@ -import { createAppAuth, StrategyOptions } from "@octokit/auth-app"; +import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; +import { Endpoints } from "@octokit/types"; import { Repository } from "../domain/repository.js"; import { User } from "../domain/user.js"; +export type Installation = + Endpoints["GET /repos/{owner}/{repo}/installation"]["response"]["data"]; + export class MissingRepositoryInstallationError extends Error { constructor(repository: Repository) { super(`Missing installation for repository ${repository.canonicalName}`); } } +export function getUnauthorizedOctokit(): Octokit { + return new Octokit({ + ...((process.env.INTEGRATION_TESTING && { + baseUrl: process.env.GITHUB_API_ENDPOINT, + }) || + {}), + }); +} + +export function getAppAuthorizedOctokit( + appId: number, + privateKey: string, + clientId: string, + clientSecret: string +): Octokit { + return new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: appId, + privateKey: privateKey, + clientId: clientId, + clientSecret: clientSecret, + }, + ...((process.env.INTEGRATION_TESTING && { + baseUrl: process.env.GITHUB_API_ENDPOINT, + }) || + {}), + }); +} + +export async function getInstallationAuthorizedOctokit( + appOctokit: Octokit, + installationId: number, + repo: string +): Promise { + const octokit = await appOctokit.auth({ + type: "installation", + installationId, + repositoryNames: [repo], + factory: (auth: any) => + new Octokit({ + authStrategy: createAppAuth, + auth, + ...((process.env.INTEGRATION_TESTING && { + baseUrl: process.env.GITHUB_API_ENDPOINT, + }) || + {}), + }), + }); + + return octokit as any as Octokit; +} + export class GitHubClient { // The GitHub API does not return a name or an email for the github-actions[bot]. // See https://api.github.com/users/github-actions%5Bbot%5D @@ -22,54 +79,36 @@ export class GitHubClient { email: "41898282+github-actions[bot]@users.noreply.github.com", }; - // Cache installation tokens as they expire after an hour, which is more than - // enough time for a cloud function to run. - private readonly _installationTokenCache: any = {}; - - private appAuth: StrategyOptions | null = null; - - public setAppAuth(appAuth: StrategyOptions) { - this.appAuth = appAuth; - } + public static async forRepoInstallation( + appOctokit: Octokit, + repository: Repository, + installationId?: number + ): Promise { + if (installationId === undefined) { + const appClient = new GitHubClient(appOctokit); + const installation = await appClient.getRepositoryInstallation( + repository + ); + installationId = installation.id; + } - private getOctokit(): Octokit { - return new Octokit({ - ...((process.env.INTEGRATION_TESTING && { - baseUrl: process.env.GITHUB_API_ENDPOINT, - }) || - {}), - }); - } + const installationOctokit = await getInstallationAuthorizedOctokit( + appOctokit, + installationId, + repository.name + ); + const client = new GitHubClient(installationOctokit); - private getAppAuthorizedOctokit(): Octokit { - return new Octokit({ - authStrategy: createAppAuth, - auth: this.appAuth, - ...((process.env.INTEGRATION_TESTING && { - baseUrl: process.env.GITHUB_API_ENDPOINT, - }) || - {}), - }); + return client; } - private async getRepoAuthorizedOctokit( - repository: Repository - ): Promise { - const token = await this.getInstallationToken(repository); - return new Octokit({ - auth: token, - ...((process.env.INTEGRATION_TESTING && { - baseUrl: process.env.GITHUB_API_ENDPOINT, - }) || - {}), - }); - } + public constructor(private readonly octokit: Octokit) {} public async getForkedRepositoriesByOwner( owner: string ): Promise { // This endpoint works for org owners as well as user owners - const response = await this.getOctokit().rest.repos.listForUser({ + const response = await this.octokit.rest.repos.listForUser({ username: owner, type: "owner", per_page: 100, @@ -83,7 +122,7 @@ export class GitHubClient { public async getSourceRepository( repository: Repository ): Promise { - const response = await this.getOctokit().rest.repos.get({ + const response = await this.octokit.rest.repos.get({ owner: repository.owner, repo: repository.name, }); @@ -103,8 +142,7 @@ export class GitHubClient { title: string, body: string ): Promise { - const app = await this.getRepoAuthorizedOctokit(toRepo); - const { data: pull } = await app.rest.pulls.create({ + const { data: pull } = await this.octokit.rest.pulls.create({ owner: toRepo.owner, repo: toRepo.name, title: title, @@ -124,8 +162,7 @@ export class GitHubClient { if (username === GitHubClient.GITHUB_ACTIONS_BOT.username) { return GitHubClient.GITHUB_ACTIONS_BOT; } - const octokit = await this.getRepoAuthorizedOctokit(repository); - const { data } = await octokit.rest.users.getByUsername({ username }); + const { data } = await this.octokit.rest.users.getByUsername({ username }); return { name: data.name, username, email: data.email }; } @@ -141,13 +178,12 @@ export class GitHubClient { } } - private async getRepositoryInstallation( + public async getRepositoryInstallation( repository: Repository - ): Promise { - const octokit = this.getAppAuthorizedOctokit(); + ): Promise { try { const { data: installation } = - await octokit.rest.apps.getRepoInstallation({ + await this.octokit.rest.apps.getRepoInstallation({ owner: repository.owner, repo: repository.name, }); @@ -163,21 +199,16 @@ export class GitHubClient { } public async getInstallationToken(repository: Repository): Promise { - if (!this._installationTokenCache[repository.canonicalName]) { - const installationId = (await this.getRepositoryInstallation(repository)) - .id; - - const octokit = this.getAppAuthorizedOctokit(); - const auth = (await octokit.auth({ - type: "installation", - installationId: installationId, - repositoryNames: [repository.name], - })) as any; - - this._installationTokenCache[repository.canonicalName] = auth.token; - } + const installationId = (await this.getRepositoryInstallation(repository)) + .id; + + const auth = (await this.octokit.auth({ + type: "installation", + installationId: installationId, + repositoryNames: [repository.name], + })) as any; - return this._installationTokenCache[repository.canonicalName]; + return auth.token; } public async getAuthenticatedRemoteUrl(