import { App, type Octokit } from "octokit"; import { env } from "../env.server"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; 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 { 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 { 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, })); } /** * Checks if a branch exists in a GitHub repository */ export function checkGitHubBranchExists( installationId: number, fullRepoName: string, branch: string ): ResultAsync { if (!githubApp) { return errAsync({ type: "github_app_not_enabled" as const }); } if (!branch || branch.trim() === "") { return okAsync(false); } const [owner, repo] = fullRepoName.split("/"); const getOctokit = () => fromPromise(githubApp.getInstallationOctokit(installationId), (error) => ({ type: "other" as const, cause: error, })); const getBranch = (octokit: Octokit) => fromPromise( octokit.rest.repos.getBranch({ owner, repo, branch, }), (error) => ({ type: "other" as const, cause: error, }) ); return getOctokit() .andThen((octokit) => getBranch(octokit)) .map(() => true) .orElse((error) => { if ( error.cause && error.cause instanceof Error && "status" in error.cause && error.cause.status === 404 ) { return okAsync(false); } return errAsync(error); }); }