diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index b22a53866b..6ee44a665b 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -1,5 +1,5 @@ import { - Prisma, + type Prisma, type WorkerDeploymentStatus, type WorkerInstanceGroupType, } from "@trigger.dev/database"; @@ -9,6 +9,7 @@ import { type Project } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; import { processGitMetadata } from "./BranchesPresenter.server"; +import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github"; const pageSize = 20; @@ -56,6 +57,18 @@ export class DeploymentListPresenter { }, }, }, + connectedGithubRepository: { + select: { + branchTracking: true, + previewDeploymentsEnabled: true, + repository: { + select: { + htmlUrl: true, + fullName: true, + }, + }, + }, + }, }, where: { slug: projectSlug, @@ -140,9 +153,28 @@ ORDER BY string_to_array(wd."version", '.')::int[] DESC LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; + const { connectedGithubRepository } = project; + + const branchTrackingOrError = + connectedGithubRepository && + BranchTrackingConfigSchema.safeParse(connectedGithubRepository.branchTracking); + const environmentGitHubBranch = + branchTrackingOrError && branchTrackingOrError.success + ? getTrackedBranchForEnvironment( + branchTrackingOrError.data, + connectedGithubRepository.previewDeploymentsEnabled, + { + type: environment.type, + branchName: environment.branchName ?? undefined, + } + ) + : undefined; + return { currentPage: page, totalPages: Math.ceil(totalCount / pageSize), + connectedGithubRepository: project.connectedGithubRepository ?? undefined, + environmentGitHubBranch, deployments: deployments.map((deployment, index) => { const label = labeledDeployments.find( (labeledDeployment) => labeledDeployment.deploymentId === deployment.id diff --git a/apps/webapp/app/routes/_app.github.callback/route.tsx b/apps/webapp/app/routes/_app.github.callback/route.tsx index 44c7f37c13..44d9d96b68 100644 --- a/apps/webapp/app/routes/_app.github.callback/route.tsx +++ b/apps/webapp/app/routes/_app.github.callback/route.tsx @@ -1,9 +1,13 @@ -import { type LoaderFunctionArgs } from "@remix-run/node"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; import { z } from "zod"; import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server"; import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server"; import { logger } from "~/services/logger.server"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { + redirectWithErrorMessage, + setRequestSuccessMessage, + commitSession, +} from "~/models/message.server"; import { tryCatch } from "@trigger.dev/core"; import { $replica } from "~/db.server"; import { requireUser } from "~/services/session.server"; @@ -88,7 +92,14 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App"); } - return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully"); + const session = await setRequestSuccessMessage(request, "GitHub App installed successfully"); + session.flash("gitHubAppInstalled", true); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); } case "update": { @@ -101,7 +112,14 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App"); } - return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully"); + const session = await setRequestSuccessMessage(request, "GitHub App updated successfully"); + session.flash("gitHubAppInstalled", true); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); } case "request": { @@ -111,7 +129,13 @@ export async function loader({ request }: LoaderFunctionArgs) { callbackData, }); - return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested"); + const session = await setRequestSuccessMessage(request, "GitHub App installation requested"); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); } default: diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 4ae43e3e8c..610df3ed61 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -1,11 +1,13 @@ import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid"; import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { CogIcon, GitBranchIcon } from "lucide-react"; import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PromoteIcon } from "~/assets/icons/PromoteIcon"; import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels"; +import { OctoKitty } from "~/components/GitHubLoginButton"; import { GitMetadata } from "~/components/GitMetadata"; import { RuntimeIcon } from "~/components/RuntimeIcon"; import { UserAvatar } from "~/components/UserProfilePhoto"; @@ -50,7 +52,13 @@ import { } from "~/presenters/v3/DeploymentListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { titleCase } from "~/utils"; -import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; +import { + EnvironmentParamSchema, + docsPath, + v3DeploymentPath, + v3ProjectSettingsPath, +} from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; @@ -122,8 +130,14 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { deployments, currentPage, totalPages, selectedDeployment } = - useTypedLoaderData(); + const { + deployments, + currentPage, + totalPages, + selectedDeployment, + connectedGithubRepository, + environmentGitHubBranch, + } = useTypedLoaderData(); const hasDeployments = totalPages > 0; const { deploymentParam } = useParams(); @@ -160,8 +174,8 @@ export default function Page() { {hasDeployments ? ( -
- +
+
Deploy @@ -286,11 +300,38 @@ export default function Page() { )}
- {totalPages > 1 && ( -
- -
- )} +
+ {connectedGithubRepository && environmentGitHubBranch && ( +
+ + Automatically triggered by pushes to{" "} +
+ + {environmentGitHubBranch} +
{" "} + in + + {connectedGithubRepository.repository.fullName} + + +
+ )} + +
) : environment.type === "DEVELOPMENT" ? ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index db6f641f5d..7884fcc035 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -1,17 +1,34 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { + CheckCircleIcon, + ExclamationTriangleIcon, + FolderIcon, + TrashIcon, + LockClosedIcon, + PlusIcon, +} from "@heroicons/react/20/solid"; +import { + Form, + type MetaFunction, + useActionData, + useNavigation, + useNavigate, +} from "@remix-run/react"; +import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { InlineCode } from "~/components/code/InlineCode"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { OctoKitty } from "~/components/GitHubLoginButton"; import { MainHorizontallyCenteredContainer, PageBody, PageContainer, } from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -25,13 +42,41 @@ import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/Page import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { prisma } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { DeleteProjectService } from "~/services/deleteProject.server"; +import { + redirectBackWithErrorMessage, + redirectBackWithSuccessMessage, + redirectWithErrorMessage, + redirectWithSuccessMessage, + getSession, + commitSession, +} from "~/models/message.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; +import { + organizationPath, + v3ProjectPath, + githubAppInstallPath, + EnvironmentParamSchema, + v3ProjectSettingsPath, +} from "~/utils/pathBuilder"; +import { useEffect, useState } from "react"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { Switch } from "~/components/primitives/Switch"; +import { type BranchTrackingConfig } from "~/v3/github"; +import { + EnvironmentIcon, + environmentFullTitle, + environmentTextClassName, +} from "~/components/environments/EnvironmentLabel"; +import { GitBranchIcon } from "lucide-react"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { DateTime } from "~/components/primitives/DateTime"; +import { TextLink } from "~/components/primitives/TextLink"; +import { cn } from "~/utils/cn"; +import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; export const meta: MetaFunction = () => { return [ @@ -41,6 +86,75 @@ export const meta: MetaFunction = () => { ]; }; +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); + + const projectSettingsPresenter = new ProjectSettingsPresenter(); + const resultOrFail = await projectSettingsPresenter.getProjectSettings( + organizationSlug, + projectParam, + userId + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "project_not_found": { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed loading project settings", { + error: resultOrFail.error, + }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, please try again!", + }); + } + } + } + + const { gitHubApp } = resultOrFail.value; + + const session = await getSession(request.headers.get("Cookie")); + const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true; + const headers = new Headers({ + "Set-Cookie": await commitSession(session), + }); + + return typedjson( + { + githubAppEnabled: gitHubApp.enabled, + githubAppInstallations: gitHubApp.installations, + connectedGithubRepository: gitHubApp.connectedRepository, + openGitHubRepoConnectionModal, + }, + { headers } + ); +}; + +const ConnectGitHubRepoFormSchema = z.object({ + action: z.literal("connect-repo"), + installationId: z.string(), + repositoryId: z.string(), +}); + +const UpdateGitSettingsFormSchema = z.object({ + action: z.literal("update-git-settings"), + productionBranch: z.string().trim().optional(), + stagingBranch: z.string().trim().optional(), + previewDeploymentsEnabled: z + .string() + .optional() + .transform((val) => val === "on"), +}); + export function createSchema( constraints: { getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; @@ -72,6 +186,11 @@ export function createSchema( } }), }), + ConnectGitHubRepoFormSchema, + UpdateGitSettingsFormSchema, + z.object({ + action: z.literal("disconnect-repo"), + }), ]); } @@ -95,63 +214,190 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission); } - try { - switch (submission.value.action) { - case "rename": { - await prisma.project.update({ - where: { - slug: projectParam, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - data: { - name: submission.value.projectName, - }, - }); + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); - return redirectWithSuccessMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project renamed to ${submission.value.projectName}` - ); + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } + + const { projectId, organizationId } = membershipResultOrFail.value; + + switch (submission.value.action) { + case "rename": { + const resultOrFail = await projectSettingsService.renameProject( + projectId, + submission.value.projectName + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to rename project", { + error: resultOrFail.error, + }); + return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); + } + } } - case "delete": { - const deleteProjectService = new DeleteProjectService(); - try { - await deleteProjectService.call({ projectSlug: projectParam, userId }); - - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - "Project deleted" - ); - } catch (error: unknown) { - logger.error("Project could not be deleted", { - error: error instanceof Error ? error.message : JSON.stringify(error), - }); - return redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project ${projectParam} could not be deleted` - ); + + return redirectWithSuccessMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project renamed to ${submission.value.projectName}` + ); + } + case "delete": { + const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to delete project", { + error: resultOrFail.error, + }); + return redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project ${projectParam} could not be deleted` + ); + } } } + + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + "Project deleted" + ); + } + case "disconnect-repo": { + const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to disconnect GitHub repository", { + error: resultOrFail.error, + }); + return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository"); + } + } + } + + return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully"); + } + case "update-git-settings": { + const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value; + + const resultOrFail = await projectSettingsService.updateGitSettings( + projectId, + productionBranch, + stagingBranch, + previewDeploymentsEnabled + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "github_app_not_enabled": { + return redirectBackWithErrorMessage(request, "GitHub app is not enabled"); + } + case "connected_gh_repository_not_found": { + return redirectBackWithErrorMessage(request, "Connected GitHub repository not found"); + } + case "production_tracking_branch_not_found": { + return redirectBackWithErrorMessage(request, "Production tracking branch not found"); + } + case "staging_tracking_branch_not_found": { + return redirectBackWithErrorMessage(request, "Staging tracking branch not found"); + } + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to update Git settings", { + error: resultOrFail.error, + }); + return redirectBackWithErrorMessage(request, "Failed to update Git settings"); + } + } + } + + return redirectBackWithSuccessMessage(request, "Git settings updated successfully"); + } + case "connect-repo": { + const { repositoryId, installationId } = submission.value; + + const resultOrFail = await projectSettingsService.connectGitHubRepo( + projectId, + organizationId, + repositoryId, + installationId + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "gh_repository_not_found": { + return redirectBackWithErrorMessage(request, "GitHub repository not found"); + } + case "project_already_has_connected_repository": { + return redirectBackWithErrorMessage( + request, + "Project already has a connected repository" + ); + } + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to connect GitHub repository", { + error: resultOrFail.error, + }); + return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository"); + } + } + } + + return json({ + ...submission, + success: true, + }); + } + default: { + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); } - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); } }; export default function Page() { + const { + githubAppInstallations, + connectedGithubRepository, + githubAppEnabled, + openGitHubRepoConnectionModal, + } = useTypedLoaderData(); const project = useProject(); + const organization = useOrganization(); + const environment = useEnvironment(); const lastSubmission = useActionData(); const navigation = useNavigation(); + const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); + const [renameForm, { projectName }] = useForm({ id: "rename-project", // TODO: type this @@ -187,10 +433,12 @@ export default function Page() { navigation.formData?.get("action") === "delete" && (navigation.state === "submitting" || navigation.state === "loading"); + const [deleteInputValue, setDeleteInputValue] = useState(""); + return ( - + @@ -212,91 +460,114 @@ export default function Page() { - -
- Project settings -
+
-
- - - - - This goes in your{" "} - trigger.config file. - - -
- -
- -
- - - - {projectName.error} - - - Rename project - - } - className="border-t-0" - /> -
-
-
- Danger zone -
- -
- - - - {projectSlug.error} - {deleteForm.error} + General +
+
+ + + - This change is irreversible, so please be certain. Type in the Project slug - {project.slug} and then press - Delete. + This goes in your{" "} + trigger.config file. - - Delete project - - } - />
- +
+
+ + + { + setHasRenameFormChanges(e.target.value !== project.name); + }} + /> + {projectName.error} + + + Save + + } + /> +
+
+
+
+ + {githubAppEnabled && ( +
+ Git settings +
+ {connectedGithubRepository ? ( + + ) : ( + + )} +
+
+ )} + +
+ Danger zone +
+
+
+ + + setDeleteInputValue(e.target.value)} + /> + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete + + } + /> +
+
+
@@ -304,3 +575,461 @@ export default function Page() {
); } + +type GitHubRepository = { + id: string; + name: string; + fullName: string; + private: boolean; + htmlUrl: string; +}; + +type GitHubAppInstallation = { + id: string; + appInstallationId: bigint; + targetType: string; + accountHandle: string; + repositories: GitHubRepository[]; +}; + +function ConnectGitHubRepoModal({ + gitHubAppInstallations, + organizationSlug, + projectSlug, + environmentSlug, + open = false, +}: { + gitHubAppInstallations: GitHubAppInstallation[]; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + open?: boolean; +}) { + const [isModalOpen, setIsModalOpen] = useState(open); + const lastSubmission = useActionData() as any; + const navigate = useNavigate(); + + const [selectedInstallation, setSelectedInstallation] = useState< + GitHubAppInstallation | undefined + >(gitHubAppInstallations.at(0)); + + const [selectedRepository, setSelectedRepository] = useState( + undefined + ); + + const navigation = useNavigation(); + const isConnectRepositoryLoading = + navigation.formData?.get("action") === "connect-repo" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const [form, { installationId, repositoryId }] = useForm({ + id: "connect-repo", + lastSubmission: lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: ConnectGitHubRepoFormSchema, + }); + }, + }); + + useEffect(() => { + if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) { + setIsModalOpen(false); + } + }, [lastSubmission]); + + return ( + + + + + + Connect GitHub repository +
+
+ + Choose a GitHub repository to connect to your project. + +
+ + + + {installationId.error} + + + + + + Configure repository access in{" "} + + GitHub + + . + + {repositoryId.error} + + {form.error} + + Connect repository + + } + cancelButton={ + + + + } + /> +
+
+
+
+
+ ); +} + +function GitHubConnectionPrompt({ + gitHubAppInstallations, + organizationSlug, + projectSlug, + environmentSlug, + openGitHubRepoConnectionModal = false, +}: { + gitHubAppInstallations: GitHubAppInstallation[]; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + openGitHubRepoConnectionModal?: boolean; +}) { + return ( +
+ + {gitHubAppInstallations.length === 0 && ( + + Install GitHub app + + )} + {gitHubAppInstallations.length !== 0 && ( +
+ + + GitHub app is installed + +
+ )} + + Connect your GitHub repository to automatically deploy your changes. +
+
+ ); +} + +type ConnectedGitHubRepo = { + branchTracking: BranchTrackingConfig | undefined; + previewDeploymentsEnabled: boolean; + createdAt: Date; + repository: GitHubRepository; +}; + +function ConnectedGitHubRepoForm({ + connectedGitHubRepo, +}: { + connectedGitHubRepo: ConnectedGitHubRepo; +}) { + const lastSubmission = useActionData() as any; + const navigation = useNavigation(); + + const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false); + const [gitSettingsValues, setGitSettingsValues] = useState({ + productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "", + stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "", + previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled, + }); + + useEffect(() => { + const hasChanges = + gitSettingsValues.productionBranch !== + (connectedGitHubRepo.branchTracking?.prod?.branch || "") || + gitSettingsValues.stagingBranch !== + (connectedGitHubRepo.branchTracking?.staging?.branch || "") || + gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled; + setHasGitSettingsChanges(hasChanges); + }, [gitSettingsValues, connectedGitHubRepo]); + + const [gitSettingsForm, fields] = useForm({ + id: "update-git-settings", + lastSubmission: lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: UpdateGitSettingsFormSchema, + }); + }, + }); + + const isGitSettingsLoading = + navigation.formData?.get("action") === "update-git-settings" && + (navigation.state === "submitting" || navigation.state === "loading"); + + return ( + <> +
+
+ + + {connectedGitHubRepo.repository.fullName} + + {connectedGitHubRepo.repository.private && ( + + )} + + + +
+ + + + + + Disconnect GitHub repository +
+ + Are you sure you want to disconnect{" "} + {connectedGitHubRepo.repository.fullName}? + This will stop automatic deployments from GitHub. + + + + + + } + cancelButton={ + + + + } + /> +
+
+
+
+ +
+
+ + + Every commit on the selected tracking branch creates a deployment in the corresponding + environment. + +
+
+ + + {environmentFullTitle({ type: "PRODUCTION" })} + +
+ { + setGitSettingsValues((prev) => ({ + ...prev, + productionBranch: e.target.value, + })); + }} + /> +
+ + + {environmentFullTitle({ type: "STAGING" })} + +
+ { + setGitSettingsValues((prev) => ({ + ...prev, + stagingBranch: e.target.value, + })); + }} + /> + +
+ + + {environmentFullTitle({ type: "PREVIEW" })} + +
+ { + setGitSettingsValues((prev) => ({ + ...prev, + previewDeploymentsEnabled: checked, + })); + }} + /> +
+ {fields.productionBranch?.error} + {fields.stagingBranch?.error} + {fields.previewDeploymentsEnabled?.error} + {gitSettingsForm.error} +
+ + + Save + + } + /> +
+
+ + ); +} diff --git a/apps/webapp/app/services/gitHub.server.ts b/apps/webapp/app/services/gitHub.server.ts index 9e4c26a554..4363c68050 100644 --- a/apps/webapp/app/services/gitHub.server.ts +++ b/apps/webapp/app/services/gitHub.server.ts @@ -2,6 +2,7 @@ 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" @@ -133,3 +134,57 @@ async function fetchInstallationRepositories(octokit: Octokit, installationId: n 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); + }); +} diff --git a/apps/webapp/app/services/projectSettings.server.ts b/apps/webapp/app/services/projectSettings.server.ts new file mode 100644 index 0000000000..3ff35d9435 --- /dev/null +++ b/apps/webapp/app/services/projectSettings.server.ts @@ -0,0 +1,281 @@ +import { type PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { DeleteProjectService } from "~/services/deleteProject.server"; +import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github"; +import { checkGitHubBranchExists } from "~/services/gitHub.server"; +import { errAsync, fromPromise, okAsync, ResultAsync } from "neverthrow"; + +export class ProjectSettingsService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + renameProject(projectId: string, newName: string) { + return fromPromise( + this.#prismaClient.project.update({ + where: { + id: projectId, + }, + data: { + name: newName, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ); + } + + deleteProject(projectSlug: string, userId: string) { + const deleteProjectService = new DeleteProjectService(this.#prismaClient); + + return fromPromise(deleteProjectService.call({ projectSlug, userId }), (error) => ({ + type: "other" as const, + cause: error, + })); + } + + connectGitHubRepo( + projectId: string, + organizationId: string, + repositoryId: string, + installationId: string + ) { + const getRepository = () => + fromPromise( + this.#prismaClient.githubRepository.findFirst({ + where: { + id: repositoryId, + installationId, + installation: { + organizationId: organizationId, + }, + }, + select: { + id: true, + name: true, + defaultBranch: true, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).andThen((repository) => { + if (!repository) { + return errAsync({ type: "gh_repository_not_found" as const }); + } + return okAsync(repository); + }); + + const findExistingConnection = () => + fromPromise( + this.#prismaClient.connectedGithubRepository.findFirst({ + where: { + projectId: projectId, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ); + + const createConnectedRepo = (defaultBranch: string) => + fromPromise( + this.#prismaClient.connectedGithubRepository.create({ + data: { + projectId: projectId, + repositoryId: repositoryId, + branchTracking: { + prod: { branch: defaultBranch }, + staging: { branch: defaultBranch }, + } satisfies BranchTrackingConfig, + previewDeploymentsEnabled: true, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ); + + return ResultAsync.combine([getRepository(), findExistingConnection()]).andThen( + ([repository, existingConnection]) => { + if (existingConnection) { + return errAsync({ type: "project_already_has_connected_repository" as const }); + } + + return createConnectedRepo(repository.defaultBranch); + } + ); + } + + disconnectGitHubRepo(projectId: string) { + return fromPromise( + this.#prismaClient.connectedGithubRepository.delete({ + where: { + projectId: projectId, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ); + } + + updateGitSettings( + projectId: string, + productionBranch?: string, + stagingBranch?: string, + previewDeploymentsEnabled?: boolean + ) { + const getExistingConnectedRepo = () => + fromPromise( + this.#prismaClient.connectedGithubRepository.findFirst({ + where: { + projectId: projectId, + }, + include: { + repository: { + include: { + installation: true, + }, + }, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ) + .andThen((connectedRepo) => { + if (!connectedRepo) { + return errAsync({ type: "connected_gh_repository_not_found" as const }); + } + return okAsync(connectedRepo); + }) + .map((connectedRepo) => { + const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse( + connectedRepo.branchTracking + ); + const branchTracking = branchTrackingOrFailure.success + ? branchTrackingOrFailure.data + : undefined; + + return { + ...connectedRepo, + branchTracking, + }; + }); + + const validateProductionBranch = ({ + installationId, + fullRepoName, + oldProductionBranch, + }: { + installationId: number; + fullRepoName: string; + oldProductionBranch?: string; + }) => { + if (productionBranch && oldProductionBranch !== productionBranch) { + return checkGitHubBranchExists(installationId, fullRepoName, productionBranch).andThen( + (exists) => { + if (!exists) { + return errAsync({ type: "production_tracking_branch_not_found" as const }); + } + return okAsync(productionBranch); + } + ); + } + + return okAsync(productionBranch); + }; + + const validateStagingBranch = ({ + installationId, + fullRepoName, + oldStagingBranch, + }: { + installationId: number; + fullRepoName: string; + oldStagingBranch?: string; + }) => { + if (stagingBranch && oldStagingBranch !== stagingBranch) { + return checkGitHubBranchExists(installationId, fullRepoName, stagingBranch).andThen( + (exists) => { + if (!exists) { + return errAsync({ type: "staging_tracking_branch_not_found" as const }); + } + return okAsync(stagingBranch); + } + ); + } + + return okAsync(stagingBranch); + }; + + const updateConnectedRepo = () => + fromPromise( + this.#prismaClient.connectedGithubRepository.update({ + where: { + projectId: projectId, + }, + data: { + branchTracking: { + prod: productionBranch ? { branch: productionBranch } : {}, + staging: stagingBranch ? { branch: stagingBranch } : {}, + } satisfies BranchTrackingConfig, + previewDeploymentsEnabled: previewDeploymentsEnabled, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ); + + return getExistingConnectedRepo() + .andThen((connectedRepo) => { + const installationId = Number(connectedRepo.repository.installation.appInstallationId); + + return ResultAsync.combine([ + validateProductionBranch({ + installationId, + fullRepoName: connectedRepo.repository.fullName, + oldProductionBranch: connectedRepo.branchTracking?.prod?.branch, + }), + validateStagingBranch({ + installationId, + fullRepoName: connectedRepo.repository.fullName, + oldStagingBranch: connectedRepo.branchTracking?.staging?.branch, + }), + ]); + }) + .andThen(updateConnectedRepo); + } + + verifyProjectMembership(organizationSlug: string, projectSlug: string, userId: string) { + const findProject = () => + fromPromise( + this.#prismaClient.project.findFirst({ + where: { + slug: projectSlug, + organization: { + slug: organizationSlug, + members: { + some: { + userId, + }, + }, + }, + }, + select: { + id: true, + organizationId: true, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ); + + return findProject().andThen((project) => { + if (!project) { + return errAsync({ type: "user_not_in_project" as const }); + } + + return okAsync({ + projectId: project.id, + organizationId: project.organizationId, + }); + }); + } +} diff --git a/apps/webapp/app/services/projectSettingsPresenter.server.ts b/apps/webapp/app/services/projectSettingsPresenter.server.ts new file mode 100644 index 0000000000..b4095ff809 --- /dev/null +++ b/apps/webapp/app/services/projectSettingsPresenter.server.ts @@ -0,0 +1,146 @@ +import { type PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { BranchTrackingConfigSchema } from "~/v3/github"; +import { env } from "~/env.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { err, fromPromise, ok, okAsync } from "neverthrow"; + +export class ProjectSettingsPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + getProjectSettings(organizationSlug: string, projectSlug: string, userId: string) { + const githubAppEnabled = env.GITHUB_APP_ENABLED === "1"; + + if (!githubAppEnabled) { + return okAsync({ + gitHubApp: { + enabled: false, + connectedRepository: undefined, + installations: undefined, + }, + }); + } + + const getProject = () => + fromPromise(findProjectBySlug(organizationSlug, projectSlug, userId), (error) => ({ + type: "other" as const, + cause: error, + })).andThen((project) => { + if (!project) { + return err({ type: "project_not_found" as const }); + } + return ok(project); + }); + + const findConnectedGithubRepository = (projectId: string) => + fromPromise( + this.#prismaClient.connectedGithubRepository.findFirst({ + where: { + projectId, + }, + select: { + branchTracking: true, + previewDeploymentsEnabled: true, + createdAt: true, + repository: { + select: { + id: true, + name: true, + fullName: true, + htmlUrl: true, + private: true, + }, + }, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).map((connectedGithubRepository) => { + if (!connectedGithubRepository) { + return undefined; + } + + const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse( + connectedGithubRepository.branchTracking + ); + const branchTracking = branchTrackingOrFailure.success + ? branchTrackingOrFailure.data + : undefined; + + return { + ...connectedGithubRepository, + branchTracking, + }; + }); + + const listGithubAppInstallations = (organizationId: string) => + fromPromise( + this.#prismaClient.githubAppInstallation.findMany({ + where: { + organizationId, + deletedAt: null, + suspendedAt: null, + }, + select: { + id: true, + accountHandle: true, + targetType: true, + appInstallationId: true, + repositories: { + select: { + id: true, + name: true, + fullName: true, + htmlUrl: true, + private: true, + }, + // Most installations will only have a couple of repos so loading them here should be fine. + // However, there might be outlier organizations so it's best to expose the installation repos + // via a resource endpoint and filter on user input. + take: 200, + }, + }, + take: 20, + orderBy: { + createdAt: "desc", + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ); + + return getProject().andThen((project) => + findConnectedGithubRepository(project.id).andThen((connectedGithubRepository) => { + if (connectedGithubRepository) { + return okAsync({ + gitHubApp: { + enabled: true, + connectedRepository: connectedGithubRepository, + // skip loading installations if there is a connected repository + // a project can have only a single connected repository + installations: undefined, + }, + }); + } + + return listGithubAppInstallations(project.organizationId).map((githubAppInstallations) => { + return { + gitHubApp: { + enabled: true, + connectedRepository: undefined, + installations: githubAppInstallations, + }, + }; + }); + }) + ); + } +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 9ff7ff9c1e..75c6c56447 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -141,6 +141,12 @@ export function v3ProjectPath(organization: OrgForPath, project: ProjectForPath) return `/orgs/${organizationParam(organization)}/projects/${projectParam(project)}`; } +export function githubAppInstallPath(organizationSlug: string, redirectTo: string) { + return `/github/install?org_slug=${organizationSlug}&redirect_to=${encodeURIComponent( + redirectTo + )}`; +} + export function v3EnvironmentPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/github.ts b/apps/webapp/app/v3/github.ts new file mode 100644 index 0000000000..570a987cc6 --- /dev/null +++ b/apps/webapp/app/v3/github.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const BranchTrackingConfigSchema = z.object({ + prod: z.object({ + branch: z.string().optional(), + }), + staging: z.object({ + branch: z.string().optional(), + }), +}); + +export type BranchTrackingConfig = z.infer; + +export function getTrackedBranchForEnvironment( + branchTracking: BranchTrackingConfig | undefined, + previewDeploymentsEnabled: boolean, + environment: { + type: "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW"; + branchName?: string; + } +): string | undefined { + switch (environment.type) { + case "PRODUCTION": + return branchTracking?.prod?.branch; + case "STAGING": + return branchTracking?.staging?.branch; + case "PREVIEW": + return previewDeploymentsEnabled ? environment.branchName : undefined; + case "DEVELOPMENT": + return undefined; + default: + environment.type satisfies never; + return undefined; + } +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index a029494240..fc4122ab49 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -159,6 +159,7 @@ "match-sorter": "^6.3.4", "morgan": "^1.10.0", "nanoid": "3.3.8", + "neverthrow": "^8.2.0", "non.geist": "^1.0.2", "octokit": "^3.2.1", "ohash": "^1.1.3", diff --git a/internal-packages/database/prisma/migrations/20250902135000_add_gh_connected_repo_schema/migration.sql b/internal-packages/database/prisma/migrations/20250902135000_add_gh_connected_repo_schema/migration.sql new file mode 100644 index 0000000000..b3e170bdbb --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250902135000_add_gh_connected_repo_schema/migration.sql @@ -0,0 +1,19 @@ +CREATE TABLE "public"."ConnectedGithubRepository" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "repositoryId" TEXT NOT NULL, + "branchTracking" JSONB NOT NULL, + "previewDeploymentsEnabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ConnectedGithubRepository_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ConnectedGithubRepository_repositoryId_idx" ON "public"."ConnectedGithubRepository"("repositoryId"); + +CREATE UNIQUE INDEX "ConnectedGithubRepository_projectId_key" ON "public"."ConnectedGithubRepository"("projectId"); + +ALTER TABLE "public"."ConnectedGithubRepository" ADD CONSTRAINT "ConnectedGithubRepository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "public"."ConnectedGithubRepository" ADD CONSTRAINT "ConnectedGithubRepository_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "public"."GithubRepository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index edcc82f535..240e72a506 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -372,27 +372,28 @@ model Project { /// The master queues they are allowed to use (impacts what they can set as default and trigger runs with) allowedWorkerQueues String[] @default([]) @map("allowedMasterQueues") - environments RuntimeEnvironment[] - backgroundWorkers BackgroundWorker[] - backgroundWorkerTasks BackgroundWorkerTask[] - taskRuns TaskRun[] - runTags TaskRunTag[] - taskQueues TaskQueue[] - environmentVariables EnvironmentVariable[] - checkpoints Checkpoint[] - WorkerDeployment WorkerDeployment[] - CheckpointRestoreEvent CheckpointRestoreEvent[] - taskSchedules TaskSchedule[] - alertChannels ProjectAlertChannel[] - alerts ProjectAlert[] - alertStorages ProjectAlertStorage[] - bulkActionGroups BulkActionGroup[] - BackgroundWorkerFile BackgroundWorkerFile[] - waitpoints Waitpoint[] - taskRunWaitpoints TaskRunWaitpoint[] - taskRunCheckpoints TaskRunCheckpoint[] - executionSnapshots TaskRunExecutionSnapshot[] - waitpointTags WaitpointTag[] + environments RuntimeEnvironment[] + backgroundWorkers BackgroundWorker[] + backgroundWorkerTasks BackgroundWorkerTask[] + taskRuns TaskRun[] + runTags TaskRunTag[] + taskQueues TaskQueue[] + environmentVariables EnvironmentVariable[] + checkpoints Checkpoint[] + WorkerDeployment WorkerDeployment[] + CheckpointRestoreEvent CheckpointRestoreEvent[] + taskSchedules TaskSchedule[] + alertChannels ProjectAlertChannel[] + alerts ProjectAlert[] + alertStorages ProjectAlertStorage[] + bulkActionGroups BulkActionGroup[] + BackgroundWorkerFile BackgroundWorkerFile[] + waitpoints Waitpoint[] + taskRunWaitpoints TaskRunWaitpoint[] + taskRunCheckpoints TaskRunCheckpoint[] + executionSnapshots TaskRunExecutionSnapshot[] + waitpointTags WaitpointTag[] + connectedGithubRepository ConnectedGithubRepository? } enum ProjectVersion { @@ -2282,9 +2283,39 @@ model GithubRepository { installation GithubAppInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade, onUpdate: Cascade) installationId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ConnectedGithubRepository ConnectedGithubRepository[] @@unique([installationId, githubId]) @@index([installationId]) } + +model ConnectedGithubRepository { + id String @id @default(cuid()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + repository GithubRepository @relation(fields: [repositoryId], references: [id], onDelete: Cascade, onUpdate: Cascade) + repositoryId String + + // Branch configuration by environment slug, stored as JSON + // Example: { + // "prod": { "branch": "main" }, + // "staging": { "branch": "staging" }, + // } + // We could alternatively store tracking branch configurations in a separate table and reference the runtime environments properly. + // We don't need that functionality currently so it's simpler to store it here. + // Should we need to introduce such functionality in the future, e.g., supporting custom environments, the migration should be straightforward. + branchTracking Json + + previewDeploymentsEnabled Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // A project can only be connected to one repository + @@unique([projectId]) + @@index([repositoryId]) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2c6c79547..c5111371c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -584,6 +584,9 @@ importers: nanoid: specifier: 3.3.8 version: 3.3.8 + neverthrow: + specifier: ^8.2.0 + version: 8.2.0 non.geist: specifier: ^1.0.2 version: 1.0.2 @@ -15671,7 +15674,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: true optional: true /@rollup/rollup-linux-x64-musl@4.36.0: @@ -27514,6 +27516,13 @@ packages: engines: {node: '>= 0.4.0'} dev: false + /neverthrow@8.2.0: + resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} + engines: {node: '>=18'} + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.36.0 + dev: false + /next@14.1.0(react-dom@18.2.0)(react@18.3.1): resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} engines: {node: '>=18.17.0'}