Files
Saadi Myftija 436d951b65 feat(webapp): github app installation flow (#2463)
* Add schemas for gh app installations

* Implement gh app installation flow

* Make the gh app configs optional

* Add additional org check on gh app installation callback

* Save account handle and repo default branch on install

* Do repo hard deletes in favor of simplicity

* Disable github app by default

* Fix gh env schema union issue

* Use octokit's iterator for paginating repos

* Parse gh app install callback with a discriminated union

* Remove duplicate env vars

* Use bigint for github integer IDs

* Sanitize redirect paths in the gh installation and auth flow

* Regenerate migration after rebase on main to fix ordering

* Handle gh install updates separately from new installs
2025-09-02 16:35:33 +02:00

136 lines
3.8 KiB
TypeScript

import { App, type Octokit } from "octokit";
import { env } from "../env.server";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
export const githubApp =
env.GITHUB_APP_ENABLED === "1"
? new App({
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_APP_PRIVATE_KEY,
webhooks: {
secret: env.GITHUB_APP_WEBHOOK_SECRET,
},
})
: null;
/**
* Links a GitHub App installation to a Trigger organization
*/
export async function linkGitHubAppInstallation(
installationId: number,
organizationId: string
): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}
const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});
const repositories = await fetchInstallationRepositories(octokit, installationId);
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
await prisma.githubAppInstallation.create({
data: {
appInstallationId: installationId,
organizationId,
targetId: installation.target_id,
targetType: installation.target_type,
accountHandle: installation.account
? "login" in installation.account
? installation.account.login
: "slug" in installation.account
? installation.account.slug
: "-"
: "-",
permissions: installation.permissions,
repositorySelection,
repositories: {
create: repositories,
},
},
});
}
/**
* Links a GitHub App installation to a Trigger organization
*/
export async function updateGitHubAppInstallation(installationId: number): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}
const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});
const existingInstallation = await prisma.githubAppInstallation.findFirst({
where: { appInstallationId: installationId },
});
if (!existingInstallation) {
throw new Error("GitHub App installation not found");
}
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
// repos are updated asynchronously via webhook events
await prisma.githubAppInstallation.update({
where: { id: existingInstallation?.id },
data: {
appInstallationId: installationId,
targetId: installation.target_id,
targetType: installation.target_type,
accountHandle: installation.account
? "login" in installation.account
? installation.account.login
: "slug" in installation.account
? installation.account.slug
: "-"
: "-",
permissions: installation.permissions,
suspendedAt: existingInstallation?.suspendedAt,
repositorySelection,
},
});
}
async function fetchInstallationRepositories(octokit: Octokit, installationId: number) {
const iterator = octokit.paginate.iterator(octokit.rest.apps.listReposAccessibleToInstallation, {
installation_id: installationId,
per_page: 100,
});
const allRepos = [];
const maxPages = 3;
let pageCount = 0;
for await (const { data } of iterator) {
pageCount++;
allRepos.push(...data);
if (maxPages && pageCount >= maxPages) {
logger.warn("GitHub installation repository fetch truncated", {
installationId,
maxPages,
totalReposFetched: allRepos.length,
});
break;
}
}
return allRepos.map((repo) => ({
githubId: repo.id,
name: repo.name,
fullName: repo.full_name,
htmlUrl: repo.html_url,
private: repo.private,
defaultBranch: repo.default_branch,
}));
}