Files
Saadi Myftija 5567f49846 feat(webapp): expose project git settings (#2464)
* Fix settigns page delete project width issue

* Apply a couple of touch-ups to the project settings page

* Add UI flow to connect gh repos

* Enabling adding another gh account in the ui

* Enable connecting a repo to a project

* Enable updating git settings

* Enable disconnecting gh repos from a project

* Remove prisma migration drifts

* Hide git settings when github app is disabled

* Fix migration order

* Avoid using `location` to avoid SSR issues

* Make branch tracking optional

* Disable save buttons when there are no field changes

* Disable delete project button unless the input matches the project slug

* Show connected repo connectedAt date

* Check that tracking branch exists when updating git settings

* Show tracking branch hint in the deployments page

* Fix positioning issue of the pagination pane in the deployments page

* Use mono font for branch names

* Add link to git settings

* Show tracking branch hint for the preview env too

* Add a confirmation prompt on repo disconnect

* Add link to configure repo access in gh

* Add rel prop to github links

* Automatically open repo connection modal after app installation

* Apply some fixes suggested by mr rabbit

* Fix flash cookie issue

* Extract project settings actions into a service

* Extract project settings loader into a presenter service

* Introduce neverthrow for error handling

* Try out neverthrow for error handling in the project setting flows

* Move env gh branch resolution to the presenter service
2025-09-09 13:03:43 +02:00

191 lines
5.1 KiB
TypeScript

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<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,
}));
}
/**
* Checks if a branch exists in a GitHub repository
*/
export function checkGitHubBranchExists(
installationId: number,
fullRepoName: string,
branch: string
): ResultAsync<boolean, { type: "other" | "github_app_not_enabled"; cause?: unknown }> {
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);
});
}