From 8432261a449596963811e3c14fc29a22a24dc9f5 Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 29 Aug 2025 16:48:50 +0200 Subject: [PATCH 01/32] Fix settigns page delete project width issue --- .../route.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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..9dd0a4fba0 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 @@ -190,7 +190,7 @@ export default function Page() { return ( - + @@ -267,7 +267,7 @@ export default function Page() { >
- + Date: Fri, 29 Aug 2025 17:13:08 +0200 Subject: [PATCH 02/32] Apply a couple of touch-ups to the project settings page --- .../route.tsx | 154 +++++++++--------- 1 file changed, 77 insertions(+), 77 deletions(-) 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 9dd0a4fba0..8419d2aa5c 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 @@ -213,90 +213,90 @@ export default function Page() { -
- Project settings -
-
- - - - - This goes in your{" "} - trigger.config file. - - -
- -
- -
- - - - {projectName.error} - - - Rename project - - } - className="border-t-0" - /> -
-
-
- Danger zone -
- -
+ General +
+
- - - {projectSlug.error} - {deleteForm.error} + + - 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 - - } - />
- +
+
+ + + + {projectName.error} + + + Save + + } + /> +
+
+
+
+ +
+ Danger zone +
+
+
+ + + + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete + + } + /> +
+
+
From 1b828241d597fb0c1812a0d3b1c804fd8b583982 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 1 Sep 2025 10:29:33 +0200 Subject: [PATCH 03/32] Add UI flow to connect gh repos --- .../route.tsx | 283 +++++++++++++++++- apps/webapp/app/utils/pathBuilder.ts | 4 + 2 files changed, 282 insertions(+), 5 deletions(-) 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 8419d2aa5c..83117d9461 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 { + CheckCircleIcon, + ExclamationTriangleIcon, + FolderIcon, + TrashIcon, + LockClosedIcon, +} from "@heroicons/react/20/solid"; +import { + Form, + type MetaFunction, + useActionData, + useLocation, + useNavigation, +} from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/router"; import { type ActionFunction, 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, DialogDescription } 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"; @@ -26,12 +43,21 @@ 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 { findProjectBySlug } from "~/models/project.server"; import { DeleteProjectService } from "~/services/deleteProject.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; +import { + organizationPath, + v3ProjectPath, + githubAppInstallPath, + EnvironmentParamSchema, +} from "~/utils/pathBuilder"; +import { useState } from "react"; +import { Select, SelectItem } from "~/components/primitives/Select"; export const meta: MetaFunction = () => { return [ @@ -41,6 +67,51 @@ export const meta: MetaFunction = () => { ]; }; +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const githubAppInstallations = await prisma.githubAppInstallation.findMany({ + where: { + organizationId: project.organizationId, + deletedAt: null, + suspendedAt: null, + }, + select: { + id: true, + targetType: true, + repositories: { + select: { + id: true, + name: true, + private: true, + }, + where: { + removedAt: null, + }, + // 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: 100, + }, + }, + take: 20, + orderBy: { + createdAt: "desc", + }, + }); + + return typedjson({ githubAppInstallations }); +}; + export function createSchema( constraints: { getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; @@ -148,9 +219,12 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function Page() { + const { githubAppInstallations } = useTypedLoaderData(); const project = useProject(); + const organization = useOrganization(); const lastSubmission = useActionData(); const navigation = useNavigation(); + const location = useLocation(); const [renameForm, { projectName }] = useForm({ id: "rename-project", @@ -212,7 +286,7 @@ export default function Page() { - +
General @@ -259,6 +333,41 @@ export default function Page() {
+
+ Git settings +
+
+ + {githubAppInstallations.length === 0 && ( + + Install GitHub App + + )} + {githubAppInstallations.length !== 0 && ( +
+ + + GitHub app is + installed + +
+ )} + + + Connect your GitHub repository to automatically deploy your changes. + +
+
+
+
+
Danger zone
@@ -304,3 +413,167 @@ export default function Page() { ); } + +const ConnectGitHubRepoFormSchema = z.object({ + installationId: z.string(), + repositoryId: z.string(), + projectId: z.string(), +}); + +type GitHubRepository = { + id: string; + name: string; + private: boolean; +}; + +type GitHubAppInstallation = { + id: string; + targetType: string; + repositories: GitHubRepository[]; +}; + +function ConnectGitHubRepoModal({ + gitHubAppInstallations, + projectId: triggerProjectId, +}: { + gitHubAppInstallations: GitHubAppInstallation[]; + projectId: string; +}) { + const [isModalOpen, setIsModalOpen] = useState(false); + const lastSubmission = useActionData(); + + const [selectedInstallation, setSelectedInstallation] = useState< + GitHubAppInstallation | undefined + >(gitHubAppInstallations.at(0)); + + const [selectedRepository, setSelectedRepository] = useState( + undefined + ); + + const navigation = useNavigation(); + const isConnectRepositoryLoading = + navigation.formData?.get("action") === "connect-repository" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const [form, { installationId, repositoryId, projectId }] = useForm({ + id: "connect-repository", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: ConnectGitHubRepoFormSchema, + }); + }, + }); + + return ( + + + + + + Connect GitHub repository +
+
+ + + Choose a GitHub repository to connect to your project. + +
+ + + + {installationId.error} + + + + + {repositoryId.error} + + {form.error} + + Connect repository + + } + cancelButton={ + + + + } + /> +
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 9ff7ff9c1e..2438297de1 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -141,6 +141,10 @@ 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=${redirectTo}`; +} + export function v3EnvironmentPath( organization: OrgForPath, project: ProjectForPath, From 829cdd6925ebb706c3c0f273c39156a3afde6db6 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 1 Sep 2025 14:21:09 +0200 Subject: [PATCH 04/32] Enabling adding another gh account in the ui --- .../route.tsx | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) 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 83117d9461..5fc9cdce9d 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 @@ -6,6 +6,7 @@ import { FolderIcon, TrashIcon, LockClosedIcon, + PlusIcon, } from "@heroicons/react/20/solid"; import { Form, @@ -13,6 +14,7 @@ import { useActionData, useLocation, useNavigation, + useNavigate, } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/router"; import { type ActionFunction, json } from "@remix-run/server-runtime"; @@ -87,6 +89,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, select: { id: true, + accountHandle: true, targetType: true, repositories: { select: { @@ -429,6 +432,7 @@ type GitHubRepository = { type GitHubAppInstallation = { id: string; targetType: string; + accountHandle: string; repositories: GitHubRepository[]; }; @@ -439,8 +443,11 @@ function ConnectGitHubRepoModal({ gitHubAppInstallations: GitHubAppInstallation[]; projectId: string; }) { - const [isModalOpen, setIsModalOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(true); const lastSubmission = useActionData(); + const organization = useOrganization(); + const location = useLocation(); + const navigate = useNavigate(); const [selectedInstallation, setSelectedInstallation] = useState< GitHubAppInstallation | undefined @@ -452,12 +459,11 @@ function ConnectGitHubRepoModal({ const navigation = useNavigation(); const isConnectRepositoryLoading = - navigation.formData?.get("action") === "connect-repository" && + navigation.formData?.get("action") === "connect-repo" && (navigation.state === "submitting" || navigation.state === "loading"); const [form, { installationId, repositoryId, projectId }] = useForm({ - id: "connect-repository", - // TODO: type this + id: "connect-repo", lastSubmission: lastSubmission as any, shouldRevalidate: "onSubmit", onValidate({ formData }) { @@ -500,17 +506,29 @@ function ConnectGitHubRepoModal({ variant="tertiary/small" placeholder="Select account" dropdownIcon - text={ - selectedInstallation - ? `${selectedInstallation.targetType} ${selectedInstallation.id}` - : undefined - } + text={selectedInstallation ? selectedInstallation.accountHandle : undefined} > - {gitHubAppInstallations.map((installation) => ( - - {installation.targetType} ({installation.id}) - - ))} + {[ + ...gitHubAppInstallations.map((installation) => ( + } + > + {installation.accountHandle} + + )), + { + e.preventDefault(); + navigate(githubAppInstallPath(organization.slug, location.pathname)); + }} + key="new-account" + icon={} + > + Add account + , + ]} {installationId.error} @@ -555,7 +573,7 @@ function ConnectGitHubRepoModal({ +
+
+
+ + + + {projectName.error} + + + Save + + } + /> +
+
+ + ); +} diff --git a/apps/webapp/app/v3/github.ts b/apps/webapp/app/v3/github.ts new file mode 100644 index 0000000000..8ffee05853 --- /dev/null +++ b/apps/webapp/app/v3/github.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const BranchTrackingConfigSchema = z.object({ + production: z.object({ + branch: z.string(), + }), + staging: z.object({ + branch: z.string(), + }), +}); + +export type BranchTrackingConfig = z.infer; diff --git a/internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql b/internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql new file mode 100644 index 0000000000..6641a46cb0 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql @@ -0,0 +1,72 @@ +-- DropIndex +DROP INDEX "public"."SecretStore_key_idx"; + +-- DropIndex +DROP INDEX "public"."TaskRun_runtimeEnvironmentId_createdAt_idx"; + +-- DropIndex +DROP INDEX "public"."TaskRun_runtimeEnvironmentId_id_idx"; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_WaitpointRunConnections_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_completedWaitpoints_AB_unique"; + +-- CreateTable +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") +); + +-- CreateIndex +CREATE INDEX "ConnectedGithubRepository_repositoryId_idx" ON "public"."ConnectedGithubRepository"("repositoryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ConnectedGithubRepository_projectId_key" ON "public"."ConnectedGithubRepository"("projectId"); + +-- CreateIndex +CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC); + +-- AddForeignKey +ALTER TABLE "public"."ConnectedGithubRepository" ADD CONSTRAINT "ConnectedGithubRepository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +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..69f7bf9f95 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, stored as JSON + // Example: { + // "production": { "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]) +} From 90914202b62d5693664a38f867b46de797cd86d9 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 2 Sep 2025 09:45:24 +0200 Subject: [PATCH 06/32] Enable updating git settings --- .../route.tsx | 147 ++++++++++++++---- 1 file changed, 118 insertions(+), 29 deletions(-) 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 c7982d79bb..72aac58173 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 @@ -23,7 +23,7 @@ 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, DialogDescription } from "@radix-ui/react-dialog"; +import { DialogClose } from "@radix-ui/react-dialog"; import { OctoKitty } from "~/components/GitHubLoginButton"; import { MainHorizontallyCenteredContainer, @@ -49,6 +49,7 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectBackWithErrorMessage, + redirectBackWithSuccessMessage, redirectWithErrorMessage, redirectWithSuccessMessage, } from "~/models/message.server"; @@ -62,9 +63,16 @@ import { githubAppInstallPath, EnvironmentParamSchema, } from "~/utils/pathBuilder"; -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Select, SelectItem } from "~/components/primitives/Select"; +import { Switch } from "~/components/primitives/Switch"; import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github"; +import { + EnvironmentIcon, + environmentFullTitle, + environmentTextClassName, +} from "~/components/environments/EnvironmentLabel"; +import { GitBranchIcon } from "lucide-react"; export const meta: MetaFunction = () => { return [ @@ -138,9 +146,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { htmlUrl: true, private: true, }, - where: { - removedAt: null, - }, // 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. @@ -156,6 +161,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ githubAppInstallations, connectedGithubRepository: undefined }); }; +const UpdateGitSettingsFormSchema = z.object({ + action: z.literal("update-git-settings"), + projectId: z.string(), + productionBranch: z.string().min(1, "Production branch is required"), + stagingBranch: z.string().min(1, "Staging branch is required"), + previewDeploymentsEnabled: z + .string() + .optional() + .transform((val) => val === "on"), +}); + export function createSchema( constraints: { getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; @@ -193,6 +209,7 @@ export function createSchema( repositoryId: z.string().min(1, "Repository is required"), projectId: z.string().min(1, "Project ID is required"), }), + UpdateGitSettingsFormSchema, ]); } @@ -276,6 +293,35 @@ export const action: ActionFunction = async ({ request, params }) => { ); } } + case "update-git-settings": { + const { projectId, productionBranch, stagingBranch, previewDeploymentsEnabled } = + submission.value; + + const existingConnection = await prisma.connectedGithubRepository.findFirst({ + where: { + projectId: projectId, + }, + }); + + if (!existingConnection) { + return redirectBackWithErrorMessage(request, "No connected GitHub repository found"); + } + + await prisma.connectedGithubRepository.update({ + where: { + projectId: projectId, + }, + data: { + branchTracking: { + production: { branch: productionBranch }, + staging: { branch: stagingBranch }, + } satisfies BranchTrackingConfig, + previewDeploymentsEnabled: previewDeploymentsEnabled, + }, + }); + + return redirectBackWithSuccessMessage(request, "Git settings updated successfully"); + } case "connect-repo": { const { repositoryId, projectId } = submission.value; @@ -343,7 +389,6 @@ export default function Page() { const organization = useOrganization(); const lastSubmission = useActionData(); const navigation = useNavigation(); - const location = useLocation(); const [renameForm, { projectName }] = useForm({ id: "rename-project", @@ -458,7 +503,6 @@ export default function Page() { {connectedGithubRepository ? ( ) : ( @@ -752,34 +796,32 @@ type ConnectedGitHubRepo = { function ConnectedGitHubRepoForm({ connectedGitHubRepo, - organizationSlug, projectId, }: { connectedGitHubRepo: ConnectedGitHubRepo; - organizationSlug: string; projectId: string; }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); - const [renameForm, { projectName }] = useForm({ - id: "rename-project", + const [gitSettingsForm, fields] = useForm({ + id: "update-git-settings", lastSubmission: lastSubmission, shouldRevalidate: "onSubmit", onValidate({ formData }) { return parse(formData, { - schema: createSchema(), + schema: UpdateGitSettingsFormSchema, }); }, }); - const isRenameLoading = - navigation.formData?.get("action") === "rename" && + const isGitSettingsLoading = + navigation.formData?.get("action") === "update-git-settings" && (navigation.state === "submitting" || navigation.state === "loading"); return ( <> -
+
{connectedGitHubRepo.repository.fullName} + {connectedGitHubRepo.repository.private && ( + + )}
-
+ + +
- - - {projectName.error} + + Every commit on the selected tracking branch creates a deployment in the corresponding + environment. + +
+
+ + + {environmentFullTitle({ type: "PRODUCTION" })} + +
+ +
+ + + {environmentFullTitle({ type: "STAGING" })} + +
+ + +
+ + + {environmentFullTitle({ type: "PREVIEW" })} + +
+ +
+ {fields.productionBranch?.error} + {fields.stagingBranch?.error} + {fields.previewDeploymentsEnabled?.error} + {gitSettingsForm.error}
+ Save From b7278fc1e96c422234f3e94345e5c2e1c9996da4 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 2 Sep 2025 11:21:39 +0200 Subject: [PATCH 07/32] Enable disconnecting gh repos from a project --- .../route.tsx | 75 +++++++++---------- 1 file changed, 37 insertions(+), 38 deletions(-) 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 72aac58173..6f221f129e 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 @@ -161,9 +161,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson({ githubAppInstallations, connectedGithubRepository: undefined }); }; +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"), - projectId: z.string(), productionBranch: z.string().min(1, "Production branch is required"), stagingBranch: z.string().min(1, "Staging branch is required"), previewDeploymentsEnabled: z @@ -203,13 +208,11 @@ export function createSchema( } }), }), + ConnectGitHubRepoFormSchema, + UpdateGitSettingsFormSchema, z.object({ - action: z.literal("connect-repo"), - installationId: z.string().min(1, "Installation is required"), - repositoryId: z.string().min(1, "Repository is required"), - projectId: z.string().min(1, "Project ID is required"), + action: z.literal("disconnect-repo"), }), - UpdateGitSettingsFormSchema, ]); } @@ -254,6 +257,8 @@ export const action: ActionFunction = async ({ request, params }) => { return json({ errors: { body: "project not found" } }, { status: 404 }); } + console.log(submission.value); + try { switch (submission.value.action) { case "rename": { @@ -293,13 +298,24 @@ export const action: ActionFunction = async ({ request, params }) => { ); } } + case "disconnect-repo": { + await prisma.connectedGithubRepository.delete({ + where: { + projectId: project.id, + }, + }); + + return redirectBackWithSuccessMessage( + request, + "GitHub repository disconnected successfully" + ); + } case "update-git-settings": { - const { projectId, productionBranch, stagingBranch, previewDeploymentsEnabled } = - submission.value; + const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value; const existingConnection = await prisma.connectedGithubRepository.findFirst({ where: { - projectId: projectId, + projectId: project.id, }, }); @@ -309,7 +325,7 @@ export const action: ActionFunction = async ({ request, params }) => { await prisma.connectedGithubRepository.update({ where: { - projectId: projectId, + projectId: project.id, }, data: { branchTracking: { @@ -323,7 +339,7 @@ export const action: ActionFunction = async ({ request, params }) => { return redirectBackWithSuccessMessage(request, "Git settings updated successfully"); } case "connect-repo": { - const { repositoryId, projectId } = submission.value; + const { repositoryId } = submission.value; const [repository, existingConnection] = await Promise.all([ prisma.githubRepository.findFirst({ @@ -341,7 +357,7 @@ export const action: ActionFunction = async ({ request, params }) => { }), prisma.connectedGithubRepository.findFirst({ where: { - projectId: projectId, + projectId: project.id, }, }), ]); @@ -359,7 +375,7 @@ export const action: ActionFunction = async ({ request, params }) => { await prisma.connectedGithubRepository.create({ data: { - projectId: projectId, + projectId: project.id, repositoryId: repositoryId, branchTracking: { production: { branch: repository.defaultBranch }, @@ -501,15 +517,11 @@ export default function Page() { Git settings
{connectedGithubRepository ? ( - + ) : ( )}
@@ -561,12 +573,6 @@ export default function Page() { ); } -const ConnectGitHubRepoFormSchema = z.object({ - installationId: z.string(), - repositoryId: z.string(), - projectId: z.string(), -}); - type GitHubRepository = { id: string; name: string; @@ -584,10 +590,8 @@ type GitHubAppInstallation = { function ConnectGitHubRepoModal({ gitHubAppInstallations, - projectId: triggerProjectId, }: { gitHubAppInstallations: GitHubAppInstallation[]; - projectId: string; }) { const [isModalOpen, setIsModalOpen] = useState(false); const lastSubmission = useActionData() as any; @@ -608,7 +612,7 @@ function ConnectGitHubRepoModal({ navigation.formData?.get("action") === "connect-repo" && (navigation.state === "submitting" || navigation.state === "loading"); - const [form, { installationId, repositoryId, projectId }] = useForm({ + const [form, { installationId, repositoryId }] = useForm({ id: "connect-repo", lastSubmission: lastSubmission, shouldRevalidate: "onSubmit", @@ -636,7 +640,6 @@ function ConnectGitHubRepoModal({ Connect GitHub repository
- Choose a GitHub repository to connect to your project. @@ -751,11 +754,9 @@ function ConnectGitHubRepoModal({ function GitHubConnectionPrompt({ gitHubAppInstallations, organizationSlug, - projectId, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; - projectId: string; }) { return (
@@ -771,10 +772,7 @@ function GitHubConnectionPrompt({ )} {gitHubAppInstallations.length !== 0 && (
- + GitHub app is installed @@ -796,10 +794,8 @@ type ConnectedGitHubRepo = { function ConnectedGitHubRepoForm({ connectedGitHubRepo, - projectId, }: { connectedGitHubRepo: ConnectedGitHubRepo; - projectId: string; }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); @@ -835,11 +831,14 @@ function ConnectedGitHubRepoForm({ )}
- + + +
-
From 3fc7fd59bf05e3e6cd75fb5938f1d58f55f87a47 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 2 Sep 2025 11:29:50 +0200 Subject: [PATCH 08/32] Remove prisma migration drifts --- .../migration.sql | 53 ------------------- 1 file changed, 53 deletions(-) diff --git a/internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql b/internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql index 6641a46cb0..b3e170bdbb 100644 --- a/internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql +++ b/internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql @@ -1,43 +1,3 @@ --- DropIndex -DROP INDEX "public"."SecretStore_key_idx"; - --- DropIndex -DROP INDEX "public"."TaskRun_runtimeEnvironmentId_createdAt_idx"; - --- DropIndex -DROP INDEX "public"."TaskRun_runtimeEnvironmentId_id_idx"; - --- AlterTable -ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B"); - --- DropIndex -DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique"; - --- AlterTable -ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B"); - --- DropIndex -DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique"; - --- AlterTable -ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B"); - --- DropIndex -DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique"; - --- AlterTable -ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B"); - --- DropIndex -DROP INDEX "public"."_WaitpointRunConnections_AB_unique"; - --- AlterTable -ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B"); - --- DropIndex -DROP INDEX "public"."_completedWaitpoints_AB_unique"; - --- CreateTable CREATE TABLE "public"."ConnectedGithubRepository" ( "id" TEXT NOT NULL, "projectId" TEXT NOT NULL, @@ -50,23 +10,10 @@ CREATE TABLE "public"."ConnectedGithubRepository" ( CONSTRAINT "ConnectedGithubRepository_pkey" PRIMARY KEY ("id") ); --- CreateIndex CREATE INDEX "ConnectedGithubRepository_repositoryId_idx" ON "public"."ConnectedGithubRepository"("repositoryId"); --- CreateIndex CREATE UNIQUE INDEX "ConnectedGithubRepository_projectId_key" ON "public"."ConnectedGithubRepository"("projectId"); --- CreateIndex -CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops); - --- CreateIndex -CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC); - --- CreateIndex -CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC); - --- AddForeignKey ALTER TABLE "public"."ConnectedGithubRepository" ADD CONSTRAINT "ConnectedGithubRepository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; --- AddForeignKey ALTER TABLE "public"."ConnectedGithubRepository" ADD CONSTRAINT "ConnectedGithubRepository_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "public"."GithubRepository"("id") ON DELETE CASCADE ON UPDATE CASCADE; From 0aa1c5f96101dccfa8554b2014f7866dc52c2576 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 2 Sep 2025 11:33:57 +0200 Subject: [PATCH 09/32] Hide git settings when github app is disabled --- .../route.tsx | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) 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 6f221f129e..12c3f5d9c9 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 @@ -73,6 +73,7 @@ import { environmentTextClassName, } from "~/components/environments/EnvironmentLabel"; import { GitBranchIcon } from "lucide-react"; +import { env } from "~/env.server"; export const meta: MetaFunction = () => { return [ @@ -83,6 +84,16 @@ export const meta: MetaFunction = () => { }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const githubAppEnabled = env.GITHUB_APP_ENABLED === "1"; + + if (!githubAppEnabled) { + return typedjson({ + githubAppEnabled, + githubAppInstallations: undefined, + connectedGithubRepository: undefined, + }); + } + const userId = await requireUserId(request); const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); @@ -120,6 +131,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ); return typedjson({ + githubAppEnabled, connectedGithubRepository: { ...connectedGithubRepository, branchTracking: branchTrackingOrFailure.success ? branchTrackingOrFailure.data : undefined, @@ -158,7 +170,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, }); - return typedjson({ githubAppInstallations, connectedGithubRepository: undefined }); + return typedjson({ + githubAppEnabled, + githubAppInstallations, + connectedGithubRepository: undefined, + }); }; const ConnectGitHubRepoFormSchema = z.object({ @@ -257,8 +273,6 @@ export const action: ActionFunction = async ({ request, params }) => { return json({ errors: { body: "project not found" } }, { status: 404 }); } - console.log(submission.value); - try { switch (submission.value.action) { case "rename": { @@ -400,7 +414,8 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function Page() { - const { githubAppInstallations, connectedGithubRepository } = useTypedLoaderData(); + const { githubAppInstallations, connectedGithubRepository, githubAppEnabled } = + useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); const lastSubmission = useActionData(); @@ -513,19 +528,21 @@ export default function Page() {
-
- Git settings -
- {connectedGithubRepository ? ( - - ) : ( - - )} + {githubAppEnabled && ( +
+ Git settings +
+ {connectedGithubRepository ? ( + + ) : ( + + )} +
-
+ )}
Danger zone From 03fab55357580a137c09758f1900239c4e04eee0 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 2 Sep 2025 15:49:05 +0200 Subject: [PATCH 10/32] Fix migration order --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal-packages/database/prisma/migrations/{20250901151550_add_gh_connected_repo_schema => 20250902135000_add_gh_connected_repo_schema}/migration.sql (100%) diff --git a/internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql b/internal-packages/database/prisma/migrations/20250902135000_add_gh_connected_repo_schema/migration.sql similarity index 100% rename from internal-packages/database/prisma/migrations/20250901151550_add_gh_connected_repo_schema/migration.sql rename to internal-packages/database/prisma/migrations/20250902135000_add_gh_connected_repo_schema/migration.sql From 69df7db87d8b0fcc0450b5f497c7f067f85ee31d Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 10:29:33 +0200 Subject: [PATCH 11/32] Avoid using `location` to avoid SSR issues --- .../route.tsx | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) 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 12c3f5d9c9..a117120ea0 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 @@ -12,7 +12,6 @@ import { Form, type MetaFunction, useActionData, - useLocation, useNavigation, useNavigate, } from "@remix-run/react"; @@ -62,6 +61,7 @@ import { v3ProjectPath, githubAppInstallPath, EnvironmentParamSchema, + v3ProjectSettingsPath, } from "~/utils/pathBuilder"; import React, { useEffect, useState } from "react"; import { Select, SelectItem } from "~/components/primitives/Select"; @@ -74,6 +74,7 @@ import { } from "~/components/environments/EnvironmentLabel"; import { GitBranchIcon } from "lucide-react"; import { env } from "~/env.server"; +import { useEnvironment } from "~/hooks/useEnvironment"; export const meta: MetaFunction = () => { return [ @@ -418,6 +419,7 @@ export default function Page() { useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); + const environment = useEnvironment(); const lastSubmission = useActionData(); const navigation = useNavigation(); @@ -538,6 +540,8 @@ export default function Page() { )}
@@ -607,13 +611,17 @@ type GitHubAppInstallation = { function ConnectGitHubRepoModal({ gitHubAppInstallations, + organizationSlug, + projectSlug, + environmentSlug, }: { gitHubAppInstallations: GitHubAppInstallation[]; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; }) { const [isModalOpen, setIsModalOpen] = useState(false); const lastSubmission = useActionData() as any; - const organization = useOrganization(); - const location = useLocation(); const navigate = useNavigate(); const [selectedInstallation, setSelectedInstallation] = useState< @@ -693,7 +701,16 @@ function ConnectGitHubRepoModal({ { e.preventDefault(); - navigate(githubAppInstallPath(organization.slug, location.pathname)); + navigate( + githubAppInstallPath( + organizationSlug, + v3ProjectSettingsPath( + { slug: organizationSlug }, + { slug: projectSlug }, + { slug: environmentSlug } + ) + ) + ); }} key="new-account" icon={} @@ -771,16 +788,27 @@ function ConnectGitHubRepoModal({ function GitHubConnectionPrompt({ gitHubAppInstallations, organizationSlug, + projectSlug, + environmentSlug, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; + projectSlug: string; + environmentSlug: string; }) { return (
{gitHubAppInstallations.length === 0 && ( @@ -789,7 +817,12 @@ function GitHubConnectionPrompt({ )} {gitHubAppInstallations.length !== 0 && (
- + GitHub app is installed @@ -872,7 +905,7 @@ function ConnectedGitHubRepoForm({ @@ -885,7 +918,7 @@ function ConnectedGitHubRepoForm({ From c462546e1be5b2f48f9614ab29af9799731fb3f2 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 10:42:33 +0200 Subject: [PATCH 12/32] Make branch tracking optional --- .../route.tsx | 8 ++++---- apps/webapp/app/v3/github.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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 a117120ea0..57cebce6ab 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 @@ -186,8 +186,8 @@ const ConnectGitHubRepoFormSchema = z.object({ const UpdateGitSettingsFormSchema = z.object({ action: z.literal("update-git-settings"), - productionBranch: z.string().min(1, "Production branch is required"), - stagingBranch: z.string().min(1, "Staging branch is required"), + productionBranch: z.string().optional(), + stagingBranch: z.string().optional(), previewDeploymentsEnabled: z .string() .optional() @@ -344,8 +344,8 @@ export const action: ActionFunction = async ({ request, params }) => { }, data: { branchTracking: { - production: { branch: productionBranch }, - staging: { branch: stagingBranch }, + production: productionBranch ? { branch: productionBranch } : {}, + staging: stagingBranch ? { branch: stagingBranch } : {}, } satisfies BranchTrackingConfig, previewDeploymentsEnabled: previewDeploymentsEnabled, }, diff --git a/apps/webapp/app/v3/github.ts b/apps/webapp/app/v3/github.ts index 8ffee05853..abcda44708 100644 --- a/apps/webapp/app/v3/github.ts +++ b/apps/webapp/app/v3/github.ts @@ -2,10 +2,10 @@ import { z } from "zod"; export const BranchTrackingConfigSchema = z.object({ production: z.object({ - branch: z.string(), + branch: z.string().optional(), }), staging: z.object({ - branch: z.string(), + branch: z.string().optional(), }), }); From 344d33bb2fd714d52e66df898efe1a74c155cd89 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 11:45:43 +0200 Subject: [PATCH 13/32] Disable save buttons when there are no field changes --- .../route.tsx | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) 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 57cebce6ab..f8b5986a8f 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 @@ -423,6 +423,8 @@ export default function Page() { const lastSubmission = useActionData(); const navigation = useNavigation(); + const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); + const [renameForm, { projectName }] = useForm({ id: "rename-project", // TODO: type this @@ -508,6 +510,9 @@ export default function Page() { placeholder="Project name" icon={FolderIcon} autoFocus + onChange={(e) => { + setHasRenameFormChanges(e.target.value !== project.name); + }} /> {projectName.error} @@ -518,7 +523,7 @@ export default function Page() { name="action" value="rename" variant={"secondary/small"} - disabled={isRenameLoading} + disabled={isRenameLoading || !hasRenameFormChanges} LeadingIcon={isRenameLoading ? SpinnerWhite : undefined} > Save @@ -812,7 +817,7 @@ function GitHubConnectionPrompt({ variant={"secondary/medium"} LeadingIcon={OctoKitty} > - Install GitHub App + Install GitHub app )} {gitHubAppInstallations.length !== 0 && ( @@ -850,6 +855,23 @@ function ConnectedGitHubRepoForm({ const lastSubmission = useActionData() as any; const navigation = useNavigation(); + const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false); + const [gitSettingsValues, setGitSettingsValues] = useState({ + productionBranch: connectedGitHubRepo.branchTracking?.production?.branch || "", + stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "", + previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled, + }); + + useEffect(() => { + const hasChanges = + gitSettingsValues.productionBranch !== + (connectedGitHubRepo.branchTracking?.production?.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, @@ -908,6 +930,12 @@ function ConnectedGitHubRepoForm({ placeholder="none" variant="tertiary" icon={GitBranchIcon} + onChange={(e) => { + setGitSettingsValues((prev) => ({ + ...prev, + productionBranch: e.target.value, + })); + }} />
@@ -921,6 +949,12 @@ function ConnectedGitHubRepoForm({ placeholder="none" variant="tertiary" icon={GitBranchIcon} + onChange={(e) => { + setGitSettingsValues((prev) => ({ + ...prev, + stagingBranch: e.target.value, + })); + }} />
@@ -935,6 +969,12 @@ function ConnectedGitHubRepoForm({ variant="small" label="create preview deployments for pull requests" labelPosition="right" + onCheckedChange={(checked) => { + setGitSettingsValues((prev) => ({ + ...prev, + previewDeploymentsEnabled: checked, + })); + }} />
{fields.productionBranch?.error} @@ -950,7 +990,7 @@ function ConnectedGitHubRepoForm({ name="action" value="update-git-settings" variant="secondary/small" - disabled={isGitSettingsLoading} + disabled={isGitSettingsLoading || !hasGitSettingsChanges} LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined} > Save From 6d3fda0ff4a652d7dd9f52ed07bc0c3826162e2c Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 11:51:01 +0200 Subject: [PATCH 14/32] Disable delete project button unless the input matches the project slug --- .../route.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 f8b5986a8f..f6b21e2864 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 @@ -460,6 +460,8 @@ export default function Page() { navigation.formData?.get("action") === "delete" && (navigation.state === "submitting" || navigation.state === "loading"); + const [deleteInputValue, setDeleteInputValue] = useState(""); + return ( @@ -564,6 +566,7 @@ export default function Page() { {...conform.input(projectSlug, { type: "text" })} placeholder="Your project slug" icon={ExclamationTriangleIcon} + onChange={(e) => setDeleteInputValue(e.target.value)} /> {projectSlug.error} {deleteForm.error} @@ -582,7 +585,7 @@ export default function Page() { variant={"danger/small"} LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon} leadingIconClassName="text-white" - disabled={isDeleteLoading} + disabled={isDeleteLoading || deleteInputValue !== project.slug} > Delete From 2428ce008c8ff6f53ea981cad4d09590e1e40f8e Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 11:58:58 +0200 Subject: [PATCH 15/32] Show connected repo connectedAt date --- .../route.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 f6b21e2864..f8a15aeccb 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 @@ -75,6 +75,7 @@ import { import { GitBranchIcon } from "lucide-react"; import { env } from "~/env.server"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { DateTime, DateTimeShort } from "~/components/primitives/DateTime"; export const meta: MetaFunction = () => { return [ @@ -898,13 +899,22 @@ function ConnectedGitHubRepoForm({ {connectedGitHubRepo.repository.fullName} {connectedGitHubRepo.repository.private && ( )} + + +
) : 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 89fe002593..2a7dfcbb0b 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 @@ -357,7 +357,7 @@ export const action: ActionFunction = async ({ request, params }) => { const [error, branchValidationsOrFail] = await tryCatch( Promise.all([ - productionBranch && existingBranchTracking.data?.production?.branch !== productionBranch + productionBranch && existingBranchTracking.data?.prod?.branch !== productionBranch ? checkGitHubBranchExists(installationId, owner, repo, productionBranch) : Promise.resolve(true), stagingBranch && existingBranchTracking.data?.staging?.branch !== stagingBranch @@ -392,7 +392,7 @@ export const action: ActionFunction = async ({ request, params }) => { }, data: { branchTracking: { - production: productionBranch ? { branch: productionBranch } : {}, + prod: productionBranch ? { branch: productionBranch } : {}, staging: stagingBranch ? { branch: stagingBranch } : {}, } satisfies BranchTrackingConfig, previewDeploymentsEnabled: previewDeploymentsEnabled, @@ -441,7 +441,7 @@ export const action: ActionFunction = async ({ request, params }) => { projectId: project.id, repositoryId: repositoryId, branchTracking: { - production: { branch: repository.defaultBranch }, + prod: { branch: repository.defaultBranch }, staging: { branch: repository.defaultBranch }, } satisfies BranchTrackingConfig, previewDeploymentsEnabled: true, @@ -908,7 +908,7 @@ function ConnectedGitHubRepoForm({ const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false); const [gitSettingsValues, setGitSettingsValues] = useState({ - productionBranch: connectedGitHubRepo.branchTracking?.production?.branch || "", + productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "", stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "", previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled, }); @@ -916,7 +916,7 @@ function ConnectedGitHubRepoForm({ useEffect(() => { const hasChanges = gitSettingsValues.productionBranch !== - (connectedGitHubRepo.branchTracking?.production?.branch || "") || + (connectedGitHubRepo.branchTracking?.prod?.branch || "") || gitSettingsValues.stagingBranch !== (connectedGitHubRepo.branchTracking?.staging?.branch || "") || gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled; @@ -986,7 +986,7 @@ function ConnectedGitHubRepoForm({
; + +export function getTrackedBranchForEnvironment( + branchTracking: BranchTrackingConfig | undefined, + environmentType: "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW" +): string | undefined { + if (!branchTracking) return undefined; + switch (environmentType) { + case "PRODUCTION": + return branchTracking.prod?.branch; + case "STAGING": + return branchTracking.staging?.branch; + default: + return undefined; + } +} diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 69f7bf9f95..240e72a506 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -393,7 +393,7 @@ model Project { taskRunCheckpoints TaskRunCheckpoint[] executionSnapshots TaskRunExecutionSnapshot[] waitpointTags WaitpointTag[] - ConnectedGithubRepository ConnectedGithubRepository? + connectedGithubRepository ConnectedGithubRepository? } enum ProjectVersion { @@ -2300,9 +2300,9 @@ model ConnectedGithubRepository { repository GithubRepository @relation(fields: [repositoryId], references: [id], onDelete: Cascade, onUpdate: Cascade) repositoryId String - // Branch configuration by environment, stored as JSON + // Branch configuration by environment slug, stored as JSON // Example: { - // "production": { "branch": "main" }, + // "prod": { "branch": "main" }, // "staging": { "branch": "staging" }, // } // We could alternatively store tracking branch configurations in a separate table and reference the runtime environments properly. From 86a98e25453b18865652e6ccba7c3b33ee07c609 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 14:44:04 +0200 Subject: [PATCH 18/32] Fix positioning issue of the pagination pane in the deployments page --- .../route.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 b9969e1272..7516abc082 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 @@ -52,6 +52,7 @@ import { } from "~/presenters/v3/DeploymentListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { titleCase } from "~/utils"; +import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github"; @@ -170,8 +171,8 @@ export default function Page() { {hasDeployments ? ( -
- +
+
Deploy @@ -296,9 +297,14 @@ export default function Page() { )}
-
+
{connectedGithubRepository && environmentGitHubBranch && ( -
+
Automatically triggered by pushes to{" "}
@@ -315,7 +321,7 @@ export default function Page() {
)} - +
) : environment.type === "DEVELOPMENT" ? ( From d5aa38d7576e3cf2c3550fddeef09a4e155a2871 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 14:50:15 +0200 Subject: [PATCH 19/32] Use mono font for branch names --- .../route.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 2a7dfcbb0b..b5e20476eb 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 @@ -989,6 +989,7 @@ function ConnectedGitHubRepoForm({ defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch} placeholder="none" variant="tertiary" + className="font-mono" icon={GitBranchIcon} onChange={(e) => { setGitSettingsValues((prev) => ({ @@ -1008,6 +1009,7 @@ function ConnectedGitHubRepoForm({ defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch} placeholder="none" variant="tertiary" + className="font-mono" icon={GitBranchIcon} onChange={(e) => { setGitSettingsValues((prev) => ({ From 588dda4a682290e948964dbb4928ea3b929599ba Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 15:34:43 +0200 Subject: [PATCH 20/32] Add link to git settings --- .../route.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 7516abc082..0004dae42a 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,7 +1,7 @@ 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 { GitBranchIcon } from "lucide-react"; +import { CogIcon, GitBranchIcon } from "lucide-react"; import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -53,7 +53,12 @@ import { import { requireUserId } from "~/services/session.server"; import { titleCase } from "~/utils"; import { cn } from "~/utils/cn"; -import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder"; +import { + EnvironmentParamSchema, + docsPath, + v3DeploymentPath, + v3ProjectSettingsPath, +} from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; @@ -319,6 +324,11 @@ export default function Page() { > {connectedGithubRepository.repository.fullName} +
)} From cd7d915550ee0bf424e64527b214f6d2c7889c4e Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 15:43:56 +0200 Subject: [PATCH 21/32] Show tracking branch hint for the preview env too --- .../v3/DeploymentListPresenter.server.ts | 1 + .../route.tsx | 11 +++++++++-- apps/webapp/app/v3/github.ts | 14 +++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index d1ed55de3e..108b03eb25 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -59,6 +59,7 @@ export class DeploymentListPresenter { connectedGithubRepository: { select: { branchTracking: true, + previewDeploymentsEnabled: true, repository: { select: { htmlUrl: true, 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 0004dae42a..a6371e05e2 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 @@ -138,7 +138,14 @@ export default function Page() { BranchTrackingConfigSchema.safeParse(connectedGithubRepository.branchTracking); const environmentGitHubBranch = branchTrackingOrError && branchTrackingOrError.success - ? getTrackedBranchForEnvironment(branchTrackingOrError.data, environment.type) + ? getTrackedBranchForEnvironment( + branchTrackingOrError.data, + connectedGithubRepository.previewDeploymentsEnabled, + { + type: environment.type, + branchName: environment.branchName ?? undefined, + } + ) : undefined; const hasDeployments = totalPages > 0; @@ -309,7 +316,7 @@ export default function Page() { )} > {connectedGithubRepository && environmentGitHubBranch && ( -
+
Automatically triggered by pushes to{" "}
diff --git a/apps/webapp/app/v3/github.ts b/apps/webapp/app/v3/github.ts index ac2a3bab77..fb0b85019a 100644 --- a/apps/webapp/app/v3/github.ts +++ b/apps/webapp/app/v3/github.ts @@ -13,15 +13,23 @@ export type BranchTrackingConfig = z.infer; export function getTrackedBranchForEnvironment( branchTracking: BranchTrackingConfig | undefined, - environmentType: "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW" + previewDeploymentsEnabled: boolean, + environment: { + type: "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW"; + branchName?: string; + } ): string | undefined { if (!branchTracking) return undefined; - switch (environmentType) { + switch (environment.type) { case "PRODUCTION": return branchTracking.prod?.branch; case "STAGING": return branchTracking.staging?.branch; - default: + case "PREVIEW": + return previewDeploymentsEnabled ? environment.branchName : undefined; + case "DEVELOPMENT": return undefined; + default: + return environment.type satisfies never; } } From 7e9dff3f1046d207ea09a1a7a2db6ef2112147c0 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 15:48:17 +0200 Subject: [PATCH 22/32] Add a confirmation prompt on repo disconnect --- .../route.tsx | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) 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 b5e20476eb..470b0847ba 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 @@ -963,11 +963,38 @@ function ConnectedGitHubRepoForm({ />
- - - + + + + + + Disconnect GitHub repository +
+ + Are you sure you want to disconnect{" "} + {connectedGitHubRepo.repository.fullName}? + This will stop automatic deployments from GitHub. + + + + + + } + cancelButton={ + + + + } + /> +
+
+
From c74f1c8c048926d4ad4f32594842107def7c761c Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 16:03:47 +0200 Subject: [PATCH 23/32] Add link to configure repo access in gh --- .../route.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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 470b0847ba..9a727907b2 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 @@ -78,6 +78,8 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { DateTime } from "~/components/primitives/DateTime"; import { checkGitHubBranchExists } from "~/services/gitHub.server"; import { tryCatch } from "@trigger.dev/core"; +import { TextLink } from "~/components/primitives/TextLink"; +import { cn } from "~/utils/cn"; export const meta: MetaFunction = () => { return [ @@ -154,6 +156,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { id: true, accountHandle: true, targetType: true, + appInstallationId: true, repositories: { select: { id: true, @@ -660,6 +663,7 @@ type GitHubRepository = { type GitHubAppInstallation = { id: string; + appInstallationId: bigint; targetType: string; accountHandle: string; repositories: GitHubRepository[]; @@ -810,6 +814,16 @@ function ConnectGitHubRepoModal({ )) } + + Configure repository access in{" "} + + GitHub + + . + {repositoryId.error} {form.error} @@ -965,16 +979,14 @@ function ConnectedGitHubRepoForm({
- + Disconnect GitHub repository
Are you sure you want to disconnect{" "} - {connectedGitHubRepo.repository.fullName}? + {connectedGitHubRepo.repository.fullName}? This will stop automatic deployments from GitHub. Date: Wed, 3 Sep 2025 16:19:26 +0200 Subject: [PATCH 24/32] Add rel prop to github links --- .../route.tsx | 1 + .../route.tsx | 2 ++ 2 files changed, 3 insertions(+) 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 a6371e05e2..18ff720691 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 @@ -327,6 +327,7 @@ export default function Page() { {connectedGithubRepository.repository.fullName} 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 9a727907b2..5f1034b5d6 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 @@ -818,6 +818,7 @@ function ConnectGitHubRepoModal({ Configure repository access in{" "} GitHub @@ -960,6 +961,7 @@ function ConnectedGitHubRepoForm({ {connectedGitHubRepo.repository.fullName} From 9171eff01a264e094c2ed91741c51ceab20891b4 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 16:30:47 +0200 Subject: [PATCH 25/32] Automatically open repo connection modal after app installation --- .../app/routes/_app.github.callback/route.tsx | 34 ++++++++++++++++--- .../route.tsx | 23 +++++++++++-- 2 files changed, 49 insertions(+), 8 deletions(-) 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.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 5f1034b5d6..e9597e38f1 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 @@ -51,6 +51,7 @@ import { redirectBackWithSuccessMessage, redirectWithErrorMessage, redirectWithSuccessMessage, + getSession, } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { DeleteProjectService } from "~/services/deleteProject.server"; @@ -92,11 +93,15 @@ export const meta: MetaFunction = () => { export const loader = async ({ request, params }: LoaderFunctionArgs) => { const githubAppEnabled = env.GITHUB_APP_ENABLED === "1"; + const session = await getSession(request.headers.get("Cookie")); + const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true; + if (!githubAppEnabled) { return typedjson({ githubAppEnabled, githubAppInstallations: undefined, connectedGithubRepository: undefined, + openGitHubRepoConnectionModal, }); } @@ -143,6 +148,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { branchTracking: branchTrackingOrFailure.success ? branchTrackingOrFailure.data : undefined, }, githubAppInstallations: undefined, + openGitHubRepoConnectionModal, }); } @@ -181,6 +187,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { githubAppEnabled, githubAppInstallations, connectedGithubRepository: undefined, + openGitHubRepoConnectionModal, }); }; @@ -466,8 +473,12 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function Page() { - const { githubAppInstallations, connectedGithubRepository, githubAppEnabled } = - useTypedLoaderData(); + const { + githubAppInstallations, + connectedGithubRepository, + githubAppEnabled, + openGitHubRepoConnectionModal, + } = useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); const environment = useEnvironment(); @@ -600,6 +611,7 @@ export default function Page() { organizationSlug={organization.slug} projectSlug={project.slug} environmentSlug={environment.slug} + openGitHubRepoConnectionModal={openGitHubRepoConnectionModal} /> )}
@@ -674,13 +686,15 @@ function ConnectGitHubRepoModal({ organizationSlug, projectSlug, environmentSlug, + open = false, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; projectSlug: string; environmentSlug: string; + open?: boolean; }) { - const [isModalOpen, setIsModalOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(open); const lastSubmission = useActionData() as any; const navigate = useNavigate(); @@ -861,11 +875,13 @@ function GitHubConnectionPrompt({ organizationSlug, projectSlug, environmentSlug, + openGitHubRepoConnectionModal = false, }: { gitHubAppInstallations: GitHubAppInstallation[]; organizationSlug: string; projectSlug: string; environmentSlug: string; + openGitHubRepoConnectionModal?: boolean; }) { return (
@@ -893,6 +909,7 @@ function GitHubConnectionPrompt({ organizationSlug={organizationSlug} projectSlug={projectSlug} environmentSlug={environmentSlug} + open={openGitHubRepoConnectionModal} /> GitHub app is installed From 994014cb2a7b02db068aa67b186ae1857677a7ca Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 16:41:34 +0200 Subject: [PATCH 26/32] Apply some fixes suggested by mr rabbit --- .../route.tsx | 8 ++++---- apps/webapp/app/services/gitHub.server.ts | 2 +- apps/webapp/app/v3/github.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) 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 e9597e38f1..099080c410 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 @@ -15,8 +15,7 @@ import { useNavigation, useNavigate, } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/router"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; +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"; @@ -78,7 +77,7 @@ import { env } from "~/env.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { DateTime } from "~/components/primitives/DateTime"; import { checkGitHubBranchExists } from "~/services/gitHub.server"; -import { tryCatch } from "@trigger.dev/core"; +import { tryCatch } from "@trigger.dev/core/utils"; import { TextLink } from "~/components/primitives/TextLink"; import { cn } from "~/utils/cn"; @@ -412,12 +411,13 @@ export const action: ActionFunction = async ({ request, params }) => { return redirectBackWithSuccessMessage(request, "Git settings updated successfully"); } case "connect-repo": { - const { repositoryId } = submission.value; + const { repositoryId, installationId } = submission.value; const [repository, existingConnection] = await Promise.all([ prisma.githubRepository.findFirst({ where: { id: repositoryId, + installationId, installation: { organizationId: project.organizationId, }, diff --git a/apps/webapp/app/services/gitHub.server.ts b/apps/webapp/app/services/gitHub.server.ts index 5f373cfcc6..35d0e34c51 100644 --- a/apps/webapp/app/services/gitHub.server.ts +++ b/apps/webapp/app/services/gitHub.server.ts @@ -2,7 +2,7 @@ import { App, type Octokit } from "octokit"; import { env } from "../env.server"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; -import { tryCatch } from "@trigger.dev/core"; +import { tryCatch } from "@trigger.dev/core/utils"; export const githubApp = env.GITHUB_APP_ENABLED === "1" diff --git a/apps/webapp/app/v3/github.ts b/apps/webapp/app/v3/github.ts index fb0b85019a..570a987cc6 100644 --- a/apps/webapp/app/v3/github.ts +++ b/apps/webapp/app/v3/github.ts @@ -19,17 +19,17 @@ export function getTrackedBranchForEnvironment( branchName?: string; } ): string | undefined { - if (!branchTracking) return undefined; switch (environment.type) { case "PRODUCTION": - return branchTracking.prod?.branch; + return branchTracking?.prod?.branch; case "STAGING": - return branchTracking.staging?.branch; + return branchTracking?.staging?.branch; case "PREVIEW": return previewDeploymentsEnabled ? environment.branchName : undefined; case "DEVELOPMENT": return undefined; default: - return environment.type satisfies never; + environment.type satisfies never; + return undefined; } } From 4a0c234a938bac914a90563517ca0e5ebace45cd Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 3 Sep 2025 16:57:36 +0200 Subject: [PATCH 27/32] Fix flash cookie issue --- .../route.tsx | 55 ++++++++++++------- apps/webapp/app/utils/pathBuilder.ts | 4 +- 2 files changed, 38 insertions(+), 21 deletions(-) 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 099080c410..cdacf34ba3 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 @@ -51,6 +51,7 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage, getSession, + commitSession, } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { DeleteProjectService } from "~/services/deleteProject.server"; @@ -94,14 +95,20 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const session = await getSession(request.headers.get("Cookie")); const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true; + const headers = new Headers({ + "Set-Cookie": await commitSession(session), + }); if (!githubAppEnabled) { - return typedjson({ - githubAppEnabled, - githubAppInstallations: undefined, - connectedGithubRepository: undefined, - openGitHubRepoConnectionModal, - }); + return typedjson( + { + githubAppEnabled, + githubAppInstallations: undefined, + connectedGithubRepository: undefined, + openGitHubRepoConnectionModal, + }, + { headers } + ); } const userId = await requireUserId(request); @@ -140,15 +147,20 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { connectedGithubRepository.branchTracking ); - return typedjson({ - githubAppEnabled, - connectedGithubRepository: { - ...connectedGithubRepository, - branchTracking: branchTrackingOrFailure.success ? branchTrackingOrFailure.data : undefined, + return typedjson( + { + githubAppEnabled, + connectedGithubRepository: { + ...connectedGithubRepository, + branchTracking: branchTrackingOrFailure.success + ? branchTrackingOrFailure.data + : undefined, + }, + githubAppInstallations: undefined, + openGitHubRepoConnectionModal, }, - githubAppInstallations: undefined, - openGitHubRepoConnectionModal, - }); + { headers } + ); } const githubAppInstallations = await prisma.githubAppInstallation.findMany({ @@ -182,12 +194,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, }); - return typedjson({ - githubAppEnabled, - githubAppInstallations, - connectedGithubRepository: undefined, - openGitHubRepoConnectionModal, - }); + return typedjson( + { + githubAppEnabled, + githubAppInstallations, + connectedGithubRepository: undefined, + openGitHubRepoConnectionModal, + }, + { headers } + ); }; const ConnectGitHubRepoFormSchema = z.object({ diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 2438297de1..75c6c56447 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -142,7 +142,9 @@ export function v3ProjectPath(organization: OrgForPath, project: ProjectForPath) } export function githubAppInstallPath(organizationSlug: string, redirectTo: string) { - return `/github/install?org_slug=${organizationSlug}&redirect_to=${redirectTo}`; + return `/github/install?org_slug=${organizationSlug}&redirect_to=${encodeURIComponent( + redirectTo + )}`; } export function v3EnvironmentPath( From 0ac67227f46a9b28b91b21dd25c4d594840b5c54 Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 5 Sep 2025 10:55:59 +0200 Subject: [PATCH 28/32] Extract project settings actions into a service --- .../route.tsx | 179 +++------------- .../app/services/projectSettings.server.ts | 192 ++++++++++++++++++ 2 files changed, 224 insertions(+), 147 deletions(-) create mode 100644 apps/webapp/app/services/projectSettings.server.ts 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 cdacf34ba3..1b8c5cf354 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 @@ -54,7 +54,7 @@ import { commitSession, } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; -import { DeleteProjectService } from "~/services/deleteProject.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { @@ -77,8 +77,6 @@ import { GitBranchIcon } from "lucide-react"; import { env } from "~/env.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { DateTime } from "~/components/primitives/DateTime"; -import { checkGitHubBranchExists } from "~/services/gitHub.server"; -import { tryCatch } from "@trigger.dev/core/utils"; import { TextLink } from "~/components/primitives/TextLink"; import { cn } from "~/utils/cn"; @@ -280,22 +278,12 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission); } - const project = await prisma.project.findFirst({ - where: { - slug: projectParam, - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - select: { - id: true, - organizationId: true, - }, - }); + const projectSettingsService = new ProjectSettingsService(); + const project = await projectSettingsService.verifyProjectMembership( + projectParam, + organizationSlug, + userId + ); if (!project) { return json({ errors: { body: "project not found" } }, { status: 404 }); @@ -304,14 +292,7 @@ export const action: ActionFunction = async ({ request, params }) => { try { switch (submission.value.action) { case "rename": { - await prisma.project.update({ - where: { - id: project.id, - }, - data: { - name: submission.value.projectName, - }, - }); + await projectSettingsService.renameProject(project.id, submission.value.projectName); return redirectWithSuccessMessage( v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), @@ -320,9 +301,8 @@ export const action: ActionFunction = async ({ request, params }) => { ); } case "delete": { - const deleteProjectService = new DeleteProjectService(); try { - await deleteProjectService.call({ projectSlug: projectParam, userId }); + await projectSettingsService.deleteProject(projectParam, userId); return redirectWithSuccessMessage( organizationPath({ slug: organizationSlug }), @@ -341,11 +321,7 @@ export const action: ActionFunction = async ({ request, params }) => { } } case "disconnect-repo": { - await prisma.connectedGithubRepository.delete({ - where: { - projectId: project.id, - }, - }); + await projectSettingsService.disconnectGitHubRepo(project.id); return redirectBackWithSuccessMessage( request, @@ -355,128 +331,37 @@ export const action: ActionFunction = async ({ request, params }) => { case "update-git-settings": { const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value; - const existingConnection = await prisma.connectedGithubRepository.findFirst({ - where: { - projectId: project.id, - }, - include: { - repository: { - include: { - installation: true, - }, - }, - }, - }); - - if (!existingConnection) { - return redirectBackWithErrorMessage(request, "No connected GitHub repository found"); - } - - const [owner, repo] = existingConnection.repository.fullName.split("/"); - const installationId = Number(existingConnection.repository.installation.appInstallationId); - - const existingBranchTracking = BranchTrackingConfigSchema.safeParse( - existingConnection.branchTracking - ); - - const [error, branchValidationsOrFail] = await tryCatch( - Promise.all([ - productionBranch && existingBranchTracking.data?.prod?.branch !== productionBranch - ? checkGitHubBranchExists(installationId, owner, repo, productionBranch) - : Promise.resolve(true), - stagingBranch && existingBranchTracking.data?.staging?.branch !== stagingBranch - ? checkGitHubBranchExists(installationId, owner, repo, stagingBranch) - : Promise.resolve(true), - ]) - ); - - if (error) { - return redirectBackWithErrorMessage(request, "Failed to validate tracking branches"); - } - - const [productionBranchExists, stagingBranchExists] = branchValidationsOrFail; - - if (productionBranch && !productionBranchExists) { - return redirectBackWithErrorMessage( - request, - `Production tracking branch '${productionBranch}' does not exist in the repository` + try { + await projectSettingsService.updateGitSettings( + project.id, + productionBranch, + stagingBranch, + previewDeploymentsEnabled ); - } - if (stagingBranch && !stagingBranchExists) { - return redirectBackWithErrorMessage( - request, - `Staging tracking branch '${stagingBranch}' does not exist in the repository` - ); + return redirectBackWithSuccessMessage(request, "Git settings updated successfully"); + } catch (error: any) { + return redirectBackWithErrorMessage(request, error.message); } - - await prisma.connectedGithubRepository.update({ - where: { - projectId: project.id, - }, - data: { - branchTracking: { - prod: productionBranch ? { branch: productionBranch } : {}, - staging: stagingBranch ? { branch: stagingBranch } : {}, - } satisfies BranchTrackingConfig, - previewDeploymentsEnabled: previewDeploymentsEnabled, - }, - }); - - return redirectBackWithSuccessMessage(request, "Git settings updated successfully"); } case "connect-repo": { const { repositoryId, installationId } = submission.value; - const [repository, existingConnection] = await Promise.all([ - prisma.githubRepository.findFirst({ - where: { - id: repositoryId, - installationId, - installation: { - organizationId: project.organizationId, - }, - }, - select: { - id: true, - name: true, - defaultBranch: true, - }, - }), - prisma.connectedGithubRepository.findFirst({ - where: { - projectId: project.id, - }, - }), - ]); - - if (!repository) { - return redirectBackWithErrorMessage(request, "Repository not found"); - } - - if (existingConnection) { - return redirectBackWithErrorMessage( - request, - "Project is already connected to a repository" + try { + await projectSettingsService.connectGitHubRepo( + project.id, + project.organizationId, + repositoryId, + installationId ); - } - - await prisma.connectedGithubRepository.create({ - data: { - projectId: project.id, - repositoryId: repositoryId, - branchTracking: { - prod: { branch: repository.defaultBranch }, - staging: { branch: repository.defaultBranch }, - } satisfies BranchTrackingConfig, - previewDeploymentsEnabled: true, - }, - }); - return json({ - ...submission, - success: true, - }); + return json({ + ...submission, + success: true, + }); + } catch (error: any) { + return redirectBackWithErrorMessage(request, error.message); + } } default: { return submission.value satisfies never; diff --git a/apps/webapp/app/services/projectSettings.server.ts b/apps/webapp/app/services/projectSettings.server.ts new file mode 100644 index 0000000000..8204339bc5 --- /dev/null +++ b/apps/webapp/app/services/projectSettings.server.ts @@ -0,0 +1,192 @@ +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 { tryCatch } from "@trigger.dev/core/utils"; + +export class ProjectSettingsService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + async renameProject(projectId: string, newName: string) { + const updatedProject = await this.#prismaClient.project.update({ + where: { + id: projectId, + }, + data: { + name: newName, + }, + }); + + return updatedProject; + } + + async deleteProject(projectSlug: string, userId: string) { + const deleteProjectService = new DeleteProjectService(this.#prismaClient); + await deleteProjectService.call({ projectSlug, userId }); + } + + async connectGitHubRepo( + projectId: string, + organizationId: string, + repositoryId: string, + installationId: string + ) { + const [repository, existingConnection] = await Promise.all([ + this.#prismaClient.githubRepository.findFirst({ + where: { + id: repositoryId, + installationId, + installation: { + organizationId: organizationId, + }, + }, + select: { + id: true, + name: true, + defaultBranch: true, + }, + }), + this.#prismaClient.connectedGithubRepository.findFirst({ + where: { + projectId: projectId, + }, + }), + ]); + + if (!repository) { + throw new Error("Repository not found"); + } + + if (existingConnection) { + throw new Error("Project is already connected to a repository"); + } + + const connectedRepo = await this.#prismaClient.connectedGithubRepository.create({ + data: { + projectId: projectId, + repositoryId: repositoryId, + branchTracking: { + prod: { branch: repository.defaultBranch }, + staging: { branch: repository.defaultBranch }, + } satisfies BranchTrackingConfig, + previewDeploymentsEnabled: true, + }, + }); + + return connectedRepo; + } + + async disconnectGitHubRepo(projectId: string) { + await this.#prismaClient.connectedGithubRepository.delete({ + where: { + projectId: projectId, + }, + }); + } + + async updateGitSettings( + projectId: string, + productionBranch?: string, + stagingBranch?: string, + previewDeploymentsEnabled?: boolean + ) { + const existingConnection = await this.#prismaClient.connectedGithubRepository.findFirst({ + where: { + projectId: projectId, + }, + include: { + repository: { + include: { + installation: true, + }, + }, + }, + }); + + if (!existingConnection) { + throw new Error("No connected GitHub repository found"); + } + + const [owner, repo] = existingConnection.repository.fullName.split("/"); + const installationId = Number(existingConnection.repository.installation.appInstallationId); + + const existingBranchTracking = BranchTrackingConfigSchema.safeParse( + existingConnection.branchTracking + ); + + const [error, branchValidationsOrFail] = await tryCatch( + Promise.all([ + productionBranch && existingBranchTracking.data?.prod?.branch !== productionBranch + ? checkGitHubBranchExists(installationId, owner, repo, productionBranch) + : Promise.resolve(true), + stagingBranch && existingBranchTracking.data?.staging?.branch !== stagingBranch + ? checkGitHubBranchExists(installationId, owner, repo, stagingBranch) + : Promise.resolve(true), + ]) + ); + + if (error) { + throw new Error("Failed to validate tracking branches"); + } + + const [productionBranchExists, stagingBranchExists] = branchValidationsOrFail; + + if (productionBranch && !productionBranchExists) { + throw new Error( + `Production tracking branch '${productionBranch}' does not exist in the repository` + ); + } + + if (stagingBranch && !stagingBranchExists) { + throw new Error( + `Staging tracking branch '${stagingBranch}' does not exist in the repository` + ); + } + + const updatedConnection = await this.#prismaClient.connectedGithubRepository.update({ + where: { + projectId: projectId, + }, + data: { + branchTracking: { + prod: productionBranch ? { branch: productionBranch } : {}, + staging: stagingBranch ? { branch: stagingBranch } : {}, + } satisfies BranchTrackingConfig, + previewDeploymentsEnabled: previewDeploymentsEnabled, + }, + }); + + return updatedConnection; + } + + async verifyProjectMembership(projectSlug: string, organizationSlug: string, userId: string) { + const project = await this.#prismaClient.project.findFirst({ + where: { + slug: projectSlug, + organization: { + slug: organizationSlug, + members: { + some: { + userId, + }, + }, + }, + }, + select: { + id: true, + organizationId: true, + }, + }); + + if (!project) { + throw new Error("Project not found"); + } + + return project; + } +} From 0002bd6930252efb8cfbfe57b1f2e6308541037f Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 5 Sep 2025 11:48:40 +0200 Subject: [PATCH 29/32] Extract project settings loader into a presenter service --- .../route.tsx | 114 +++--------------- .../projectSettingsPresenter.server.ts | 114 ++++++++++++++++++ 2 files changed, 128 insertions(+), 100 deletions(-) create mode 100644 apps/webapp/app/services/projectSettingsPresenter.server.ts 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 1b8c5cf354..f92d7c3357 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 @@ -79,6 +79,7 @@ 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 [ @@ -89,7 +90,15 @@ export const meta: MetaFunction = () => { }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const githubAppEnabled = env.GITHUB_APP_ENABLED === "1"; + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); + + const projectSettingsPresenter = new ProjectSettingsPresenter(); + const { gitHubApp } = await projectSettingsPresenter.getProjectSettings( + organizationSlug, + projectParam, + userId + ); const session = await getSession(request.headers.get("Cookie")); const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true; @@ -97,106 +106,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { "Set-Cookie": await commitSession(session), }); - if (!githubAppEnabled) { - return typedjson( - { - githubAppEnabled, - githubAppInstallations: undefined, - connectedGithubRepository: undefined, - openGitHubRepoConnectionModal, - }, - { headers } - ); - } - - const userId = await requireUserId(request); - const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - throw new Response(undefined, { - status: 404, - statusText: "Project not found", - }); - } - - const connectedGithubRepository = await prisma.connectedGithubRepository.findFirst({ - where: { - projectId: project.id, - }, - select: { - branchTracking: true, - previewDeploymentsEnabled: true, - createdAt: true, - repository: { - select: { - id: true, - name: true, - fullName: true, - htmlUrl: true, - private: true, - }, - }, - }, - }); - - if (connectedGithubRepository) { - const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse( - connectedGithubRepository.branchTracking - ); - - return typedjson( - { - githubAppEnabled, - connectedGithubRepository: { - ...connectedGithubRepository, - branchTracking: branchTrackingOrFailure.success - ? branchTrackingOrFailure.data - : undefined, - }, - githubAppInstallations: undefined, - openGitHubRepoConnectionModal, - }, - { headers } - ); - } - - const githubAppInstallations = await prisma.githubAppInstallation.findMany({ - where: { - organizationId: project.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", - }, - }); - return typedjson( { - githubAppEnabled, - githubAppInstallations, - connectedGithubRepository: undefined, + githubAppEnabled: gitHubApp.enabled, + githubAppInstallations: gitHubApp.installations, + connectedGithubRepository: gitHubApp.connectedRepository, openGitHubRepoConnectionModal, }, { headers } @@ -507,7 +421,7 @@ export default function Page() { ) : ( Date: Fri, 5 Sep 2025 15:45:20 +0200 Subject: [PATCH 30/32] Introduce neverthrow for error handling --- .../route.tsx | 37 +++- .../projectSettingsPresenter.server.ts | 192 ++++++++++-------- apps/webapp/package.json | 1 + pnpm-lock.yaml | 11 +- 4 files changed, 152 insertions(+), 89 deletions(-) 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 f92d7c3357..6e58e2eca5 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 @@ -42,7 +42,6 @@ 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 { @@ -53,7 +52,6 @@ import { getSession, commitSession, } from "~/models/message.server"; -import { findProjectBySlug } from "~/models/project.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; @@ -64,17 +62,16 @@ import { EnvironmentParamSchema, v3ProjectSettingsPath, } from "~/utils/pathBuilder"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Select, SelectItem } from "~/components/primitives/Select"; import { Switch } from "~/components/primitives/Switch"; -import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github"; +import { type BranchTrackingConfig } from "~/v3/github"; import { EnvironmentIcon, environmentFullTitle, environmentTextClassName, } from "~/components/environments/EnvironmentLabel"; import { GitBranchIcon } from "lucide-react"; -import { env } from "~/env.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { DateTime } from "~/components/primitives/DateTime"; import { TextLink } from "~/components/primitives/TextLink"; @@ -94,12 +91,37 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); const projectSettingsPresenter = new ProjectSettingsPresenter(); - const { gitHubApp } = await projectSettingsPresenter.getProjectSettings( + 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({ @@ -278,7 +300,8 @@ export const action: ActionFunction = async ({ request, params }) => { } } default: { - return submission.value satisfies never; + submission.value satisfies never; + return redirectBackWithErrorMessage(request, "Failed to process request"); } } } catch (error: any) { diff --git a/apps/webapp/app/services/projectSettingsPresenter.server.ts b/apps/webapp/app/services/projectSettingsPresenter.server.ts index 792f02a1af..ffd0c5380a 100644 --- a/apps/webapp/app/services/projectSettingsPresenter.server.ts +++ b/apps/webapp/app/services/projectSettingsPresenter.server.ts @@ -1,11 +1,9 @@ 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 { tryCatch } from "@trigger.dev/core/utils"; +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; @@ -14,101 +12,133 @@ export class ProjectSettingsPresenter { this.#prismaClient = prismaClient; } - async getProjectSettings(organizationSlug: string, projectSlug: string, userId: string) { + getProjectSettings(organizationSlug: string, projectSlug: string, userId: string) { const githubAppEnabled = env.GITHUB_APP_ENABLED === "1"; if (!githubAppEnabled) { - return { + return okAsync({ gitHubApp: { enabled: false, connectedRepository: undefined, installations: undefined, }, - }; + }); } - const project = await findProjectBySlug(organizationSlug, projectSlug, userId); - if (!project) { - throw new Error("Project not found"); - } - const connectedGithubRepository = await prisma.connectedGithubRepository.findFirst({ - where: { - projectId: project.id, - }, - select: { - branchTracking: true, - previewDeploymentsEnabled: true, - createdAt: true, - repository: { + 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: { - id: true, - name: true, - fullName: true, - htmlUrl: true, - private: true, + 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; + } - if (connectedGithubRepository) { - const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse( - connectedGithubRepository.branchTracking - ); + const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse( + connectedGithubRepository.branchTracking + ); + return { + ...connectedGithubRepository, + branchTracking: branchTrackingOrFailure.success + ? branchTrackingOrFailure.data + : undefined, + }; + }); - return { - gitHubApp: { - enabled: true, - connectedRepository: { - ...connectedGithubRepository, - branchTracking: branchTrackingOrFailure.success - ? branchTrackingOrFailure.data - : undefined, + const listGithubAppInstallations = (organizationId: string) => + fromPromise( + this.#prismaClient.githubAppInstallation.findMany({ + where: { + organizationId, + deletedAt: null, + suspendedAt: null, }, - // skip loading installations if there is a connected repository - // a project can have only a single connected repository - installations: undefined, - }, - }; - } - - const githubAppInstallations = await prisma.githubAppInstallation.findMany({ - where: { - organizationId: project.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, + 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, + }, }, - // 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", - }, - }); + 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 { - gitHubApp: { - enabled: true, - connectedRepository: undefined, - installations: githubAppInstallations, - }, - }; + return listGithubAppInstallations(project.organizationId).map((githubAppInstallations) => { + return { + gitHubApp: { + enabled: true, + connectedRepository: undefined, + installations: githubAppInstallations, + }, + }; + }); + }) + ); } } 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/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'} From 190830b87e851258fbd14b1a6eec5c968192642c Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 5 Sep 2025 17:22:27 +0200 Subject: [PATCH 31/32] Try out neverthrow for error handling in the project setting flows --- .../route.tsx | 222 +++++++---- apps/webapp/app/services/gitHub.server.ts | 72 ++-- .../app/services/projectSettings.server.ts | 365 +++++++++++------- .../projectSettingsPresenter.server.ts | 8 +- 4 files changed, 421 insertions(+), 246 deletions(-) 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 6e58e2eca5..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 @@ -215,97 +215,171 @@ export const action: ActionFunction = async ({ request, params }) => { } const projectSettingsService = new ProjectSettingsService(); - const project = await projectSettingsService.verifyProjectMembership( - projectParam, + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( organizationSlug, + projectParam, userId ); - if (!project) { - return json({ errors: { body: "project not found" } }, { status: 404 }); + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); } - try { - switch (submission.value.action) { - case "rename": { - await projectSettingsService.renameProject(project.id, submission.value.projectName); + const { projectId, organizationId } = membershipResultOrFail.value; - return redirectWithSuccessMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project renamed to ${submission.value.projectName}` - ); - } - case "delete": { - try { - await projectSettingsService.deleteProject(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` - ); + 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 "disconnect-repo": { - await projectSettingsService.disconnectGitHubRepo(project.id); - return redirectBackWithSuccessMessage( - request, - "GitHub repository disconnected successfully" - ); + 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` + ); + } + } } - case "update-git-settings": { - const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value; - - try { - await projectSettingsService.updateGitSettings( - project.id, - productionBranch, - stagingBranch, - previewDeploymentsEnabled - ); - - return redirectBackWithSuccessMessage(request, "Git settings updated successfully"); - } catch (error: any) { - return redirectBackWithErrorMessage(request, error.message); + + 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"); + } } } - case "connect-repo": { - const { repositoryId, installationId } = submission.value; - - try { - await projectSettingsService.connectGitHubRepo( - project.id, - project.organizationId, - repositoryId, - installationId - ); - - return json({ - ...submission, - success: true, - }); - } catch (error: any) { - return redirectBackWithErrorMessage(request, error.message); + + 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"); + } } } - default: { - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); + + 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 }); } }; diff --git a/apps/webapp/app/services/gitHub.server.ts b/apps/webapp/app/services/gitHub.server.ts index 35d0e34c51..4363c68050 100644 --- a/apps/webapp/app/services/gitHub.server.ts +++ b/apps/webapp/app/services/gitHub.server.ts @@ -2,7 +2,7 @@ import { App, type Octokit } from "octokit"; import { env } from "../env.server"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; -import { tryCatch } from "@trigger.dev/core/utils"; +import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow"; export const githubApp = env.GITHUB_APP_ENABLED === "1" @@ -138,43 +138,53 @@ async function fetchInstallationRepositories(octokit: Octokit, installationId: n /** * Checks if a branch exists in a GitHub repository */ -export async function checkGitHubBranchExists( +export function checkGitHubBranchExists( installationId: number, - owner: string, - repo: string, + fullRepoName: string, branch: string -): Promise { +): ResultAsync { if (!githubApp) { - throw new Error("GitHub App is not enabled"); + return errAsync({ type: "github_app_not_enabled" as const }); } if (!branch || branch.trim() === "") { - return false; - } - - const octokit = await githubApp.getInstallationOctokit(installationId); - const [error] = await tryCatch( - octokit.rest.repos.getBranch({ - owner, - repo, - branch, - }) - ); - - if (error && "status" in error && error.status === 404) { - return false; + return okAsync(false); } - if (error) { - logger.error("Error checking GitHub branch", { - installationId, - owner, - repo, - branch, - error: error.message, + 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); }); - throw error; - } - - return true; } diff --git a/apps/webapp/app/services/projectSettings.server.ts b/apps/webapp/app/services/projectSettings.server.ts index 8204339bc5..3ff35d9435 100644 --- a/apps/webapp/app/services/projectSettings.server.ts +++ b/apps/webapp/app/services/projectSettings.server.ts @@ -3,7 +3,7 @@ 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 { tryCatch } from "@trigger.dev/core/utils"; +import { errAsync, fromPromise, okAsync, ResultAsync } from "neverthrow"; export class ProjectSettingsService { #prismaClient: PrismaClient; @@ -12,181 +12,270 @@ export class ProjectSettingsService { this.#prismaClient = prismaClient; } - async renameProject(projectId: string, newName: string) { - const updatedProject = await this.#prismaClient.project.update({ - where: { - id: projectId, - }, - data: { - name: newName, - }, - }); - - return updatedProject; + renameProject(projectId: string, newName: string) { + return fromPromise( + this.#prismaClient.project.update({ + where: { + id: projectId, + }, + data: { + name: newName, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ); } - async deleteProject(projectSlug: string, userId: string) { + deleteProject(projectSlug: string, userId: string) { const deleteProjectService = new DeleteProjectService(this.#prismaClient); - await deleteProjectService.call({ projectSlug, userId }); + + return fromPromise(deleteProjectService.call({ projectSlug, userId }), (error) => ({ + type: "other" as const, + cause: error, + })); } - async connectGitHubRepo( + connectGitHubRepo( projectId: string, organizationId: string, repositoryId: string, installationId: string ) { - const [repository, existingConnection] = await Promise.all([ - this.#prismaClient.githubRepository.findFirst({ - where: { - id: repositoryId, - installationId, - installation: { - organizationId: organizationId, + const getRepository = () => + fromPromise( + this.#prismaClient.githubRepository.findFirst({ + where: { + id: repositoryId, + installationId, + installation: { + organizationId: organizationId, + }, }, - }, - select: { - id: true, - name: true, - defaultBranch: true, - }, - }), - this.#prismaClient.connectedGithubRepository.findFirst({ + 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, }, }), - ]); - - if (!repository) { - throw new Error("Repository not found"); - } - - if (existingConnection) { - throw new Error("Project is already connected to a repository"); - } - - const connectedRepo = await this.#prismaClient.connectedGithubRepository.create({ - data: { - projectId: projectId, - repositoryId: repositoryId, - branchTracking: { - prod: { branch: repository.defaultBranch }, - staging: { branch: repository.defaultBranch }, - } satisfies BranchTrackingConfig, - previewDeploymentsEnabled: true, - }, - }); - - return connectedRepo; - } - - async disconnectGitHubRepo(projectId: string) { - await this.#prismaClient.connectedGithubRepository.delete({ - where: { - projectId: projectId, - }, - }); + (error) => ({ type: "other" as const, cause: error }) + ); } - async updateGitSettings( + updateGitSettings( projectId: string, productionBranch?: string, stagingBranch?: string, previewDeploymentsEnabled?: boolean ) { - const existingConnection = await this.#prismaClient.connectedGithubRepository.findFirst({ - where: { - projectId: projectId, - }, - include: { - repository: { + const getExistingConnectedRepo = () => + fromPromise( + this.#prismaClient.connectedGithubRepository.findFirst({ + where: { + projectId: projectId, + }, include: { - installation: true, + 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; - if (!existingConnection) { - throw new Error("No connected GitHub repository found"); - } + return { + ...connectedRepo, + branchTracking, + }; + }); - const [owner, repo] = existingConnection.repository.fullName.split("/"); - const installationId = Number(existingConnection.repository.installation.appInstallationId); + 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); + } + ); + } - const existingBranchTracking = BranchTrackingConfigSchema.safeParse( - existingConnection.branchTracking - ); + return okAsync(productionBranch); + }; - const [error, branchValidationsOrFail] = await tryCatch( - Promise.all([ - productionBranch && existingBranchTracking.data?.prod?.branch !== productionBranch - ? checkGitHubBranchExists(installationId, owner, repo, productionBranch) - : Promise.resolve(true), - stagingBranch && existingBranchTracking.data?.staging?.branch !== stagingBranch - ? checkGitHubBranchExists(installationId, owner, repo, stagingBranch) - : Promise.resolve(true), - ]) - ); - - if (error) { - throw new Error("Failed to validate tracking branches"); - } + 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); + } + ); + } - const [productionBranchExists, stagingBranchExists] = branchValidationsOrFail; + return okAsync(stagingBranch); + }; - if (productionBranch && !productionBranchExists) { - throw new Error( - `Production tracking branch '${productionBranch}' does not exist in the repository` + 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 }) ); - } - if (stagingBranch && !stagingBranchExists) { - throw new Error( - `Staging tracking branch '${stagingBranch}' does not exist in the repository` - ); - } - - const updatedConnection = await this.#prismaClient.connectedGithubRepository.update({ - where: { - projectId: projectId, - }, - data: { - branchTracking: { - prod: productionBranch ? { branch: productionBranch } : {}, - staging: stagingBranch ? { branch: stagingBranch } : {}, - } satisfies BranchTrackingConfig, - previewDeploymentsEnabled: previewDeploymentsEnabled, - }, - }); + return getExistingConnectedRepo() + .andThen((connectedRepo) => { + const installationId = Number(connectedRepo.repository.installation.appInstallationId); - return updatedConnection; + 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); } - async verifyProjectMembership(projectSlug: string, organizationSlug: string, userId: string) { - const project = await this.#prismaClient.project.findFirst({ - where: { - slug: projectSlug, - organization: { - slug: organizationSlug, - members: { - some: { - userId, + 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, - }, - }); + select: { + id: true, + organizationId: true, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ); - if (!project) { - throw new Error("Project not found"); - } + return findProject().andThen((project) => { + if (!project) { + return errAsync({ type: "user_not_in_project" as const }); + } - return project; + 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 index ffd0c5380a..b4095ff809 100644 --- a/apps/webapp/app/services/projectSettingsPresenter.server.ts +++ b/apps/webapp/app/services/projectSettingsPresenter.server.ts @@ -69,11 +69,13 @@ export class ProjectSettingsPresenter { const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse( connectedGithubRepository.branchTracking ); + const branchTracking = branchTrackingOrFailure.success + ? branchTrackingOrFailure.data + : undefined; + return { ...connectedGithubRepository, - branchTracking: branchTrackingOrFailure.success - ? branchTrackingOrFailure.data - : undefined, + branchTracking, }; }); From 269cf9cdfeab0b24af2372e565dd3e9f6d0df93d Mon Sep 17 00:00:00 2001 From: myftija Date: Fri, 5 Sep 2025 17:27:58 +0200 Subject: [PATCH 32/32] Move env gh branch resolution to the presenter service --- .../v3/DeploymentListPresenter.server.ts | 19 ++++++++++++++ .../route.tsx | 25 ++++++------------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index 108b03eb25..6ee44a665b 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -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; @@ -152,10 +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.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 18ff720691..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 @@ -60,7 +60,6 @@ import { v3ProjectSettingsPath, } from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; -import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; export const meta: MetaFunction = () => { @@ -131,22 +130,14 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { deployments, currentPage, totalPages, selectedDeployment, connectedGithubRepository } = - useTypedLoaderData(); - 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; + const { + deployments, + currentPage, + totalPages, + selectedDeployment, + connectedGithubRepository, + environmentGitHubBranch, + } = useTypedLoaderData(); const hasDeployments = totalPages > 0; const { deploymentParam } = useParams();