From f4b70b0e4db89c7a77fd2ab93378e7b87fc2da22 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 19 Feb 2026 16:45:51 +0100 Subject: [PATCH 1/5] feat(settings+vercel): route & UX fixes Redirect project settings to the general settings route - Replace v3ProjectSettingsPath with v3ProjectSettingsGeneralPath in org project settings loader so users land on the correct general settings page after selecting an environment. Add integrations settings route for Vercel onboarding flows - Swap v3ProjectSettingsPath for v3ProjectSettingsIntegrationsPath in VercelOnboardingModal imports to point onboarding actions at the integrations-specific settings route. Improve VercelOnboardingModal behavior and telemetry - Introduce origin variable and use it to derive fromMarketplaceContext for clearer intent. - Add optional vercelManageAccessUrl prop to allow rendering a manage access link when present. - Track a "vercel onboarding github step viewed" event with extra context (origin, step, org/project slugs, GH app installation state) and include capture and identifiers in the effect dependencies. - Prevent dialog from closing when interacting outside by handling onInteractOutside to stop accidental dismissals. - Start introducing rendering for a "Manage access" link next to the Connect Project button when vercelManageAccessUrl exists and no origin query param is present. Misc - Update effect dependency array to include newly referenced values (capture, organizationSlug, projectSlug, gitHubAppInstallations.length) to satisfy hooks correctness. --- .../components/integrations/VercelLink.tsx | 22 + .../integrations/VercelOnboardingModal.tsx | 52 +- .../app/components/navigation/SideMenu.tsx | 31 +- .../components/navigation/sideMenuTypes.ts | 2 +- .../app/models/vercelIntegration.server.ts | 36 + .../v3/DeploymentListPresenter.server.ts | 12 +- .../v3/DeploymentPresenter.server.ts | 47 ++ .../v3/VercelSettingsPresenter.server.ts | 33 +- .../route.tsx | 11 + .../route.tsx | 29 +- .../route.tsx | 293 +++++++ .../route.tsx | 500 ++++++++++++ .../route.tsx | 747 +----------------- ...ationSlug.settings.integrations.vercel.tsx | 4 +- ...ionSlug.projects.$projectParam.settings.ts | 4 +- ...cts.$projectParam.env.$envParam.github.tsx | 6 +- ...cts.$projectParam.env.$envParam.vercel.tsx | 4 +- apps/webapp/app/routes/vercel.connect.tsx | 4 +- apps/webapp/app/utils/pathBuilder.ts | 16 + .../vercel/vercelProjectIntegrationSchema.ts | 9 + 20 files changed, 1081 insertions(+), 781 deletions(-) create mode 100644 apps/webapp/app/components/integrations/VercelLink.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx diff --git a/apps/webapp/app/components/integrations/VercelLink.tsx b/apps/webapp/app/components/integrations/VercelLink.tsx new file mode 100644 index 00000000000..4a74e599f3d --- /dev/null +++ b/apps/webapp/app/components/integrations/VercelLink.tsx @@ -0,0 +1,22 @@ +import { VercelLogo } from "./VercelLogo"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; + +export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) { + return ( + } + iconSpacing="gap-x-1" + to={vercelDeploymentUrl} + className="pl-1" + > + Vercel + + } + content="View on Vercel" + /> + ); +} diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index dc6996dab14..f3635dbd08e 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -44,7 +44,7 @@ import { } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server"; import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server"; -import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; +import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; import { useEffect, useState, useCallback, useRef } from "react"; import { usePostHogTracking } from "~/hooks/usePostHog"; @@ -102,6 +102,7 @@ export function VercelOnboardingModal({ hasOrgIntegration, nextUrl, onDataReload, + vercelManageAccessUrl, }: { isOpen: boolean; onClose: () => void; @@ -114,6 +115,7 @@ export function VercelOnboardingModal({ hasOrgIntegration: boolean; nextUrl?: string; onDataReload?: (vercelStagingEnvironment?: string) => void; + vercelManageAccessUrl?: string; }) { const { capture, startSessionRecording } = usePostHogTracking(); const navigation = useNavigation(); @@ -122,7 +124,8 @@ export function VercelOnboardingModal({ const completeOnboardingFetcher = useFetcher(); const { Form: CompleteOnboardingForm } = completeOnboardingFetcher; const [searchParams] = useSearchParams(); - const fromMarketplaceContext = searchParams.get("origin") === "marketplace"; + const origin = searchParams.get("origin"); + const fromMarketplaceContext = origin === "marketplace"; const availableProjects = onboardingData?.availableProjects || []; const hasProjectSelected = onboardingData?.hasProjectSelected ?? false; @@ -543,8 +546,15 @@ export function VercelOnboardingModal({ if (!isGitHubConnectedForOnboarding) { setState("github-connection"); + capture("vercel onboarding github step viewed", { + origin: fromMarketplaceContext ? "marketplace" : "dashboard", + step: "github-connection", + organization_slug: organizationSlug, + project_slug: projectSlug, + github_app_installed: gitHubAppInstallations.length > 0, + }); } - }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]); + }, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding, capture, organizationSlug, projectSlug, gitHubAppInstallations.length]); const handleFinishOnboarding = useCallback((e: React.FormEvent) => { e.preventDefault(); @@ -639,7 +649,7 @@ export function VercelOnboardingModal({ onClose(); } }}> - + e.preventDefault()}>
@@ -727,14 +737,25 @@ export function VercelOnboardingModal({ - {fetcher.state !== "idle" ? "Connecting..." : "Connect Project"} - +
+ {vercelManageAccessUrl && !origin && ( + + Manage access + + )} + +
} cancelButton={
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 61e789e138b..a42b39c4573 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 @@ -19,7 +19,7 @@ import { useEffect } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PromoteIcon } from "~/assets/icons/PromoteIcon"; -import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { VercelLink } from "~/components/integrations/VercelLink"; import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels"; import { OctoKitty } from "~/components/GitHubLoginButton"; import { GitMetadata } from "~/components/GitMetadata"; @@ -56,7 +56,6 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { DeploymentStatus, deploymentStatusDescription, @@ -76,7 +75,7 @@ import { EnvironmentParamSchema, docsPath, v3DeploymentPath, - v3ProjectSettingsPath, + v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; @@ -314,20 +313,14 @@ export default function Page() { {hasVercelIntegration && ( {deployment.vercelDeploymentUrl ? ( - e.stopPropagation()} - > - - - } - content="View on Vercel" - /> +
e.stopPropagation()} + > + +
) : ( "–" )} @@ -377,7 +370,7 @@ export default function Page() { )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx new file mode 100644 index 00000000000..371ddd52fe8 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -0,0 +1,293 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { InlineCode } from "~/components/code/InlineCode"; +import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { useProject } from "~/hooks/useProject"; +import { + redirectWithErrorMessage, + redirectWithSuccessMessage, +} from "~/models/message.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; +import { useState } from "react"; + +function createSchema( + constraints: { + getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; + } = {} +) { + return z.discriminatedUnion("action", [ + z.object({ + action: z.literal("rename"), + projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), + }), + z.object({ + action: z.literal("delete"), + projectSlug: z.string().superRefine((slug, ctx) => { + if (constraints.getSlugMatch === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: conform.VALIDATION_UNDEFINED, + }); + } else { + const { isMatch, projectSlug } = constraints.getSlugMatch(slug); + if (isMatch) { + return; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The slug must match ${projectSlug}`, + }); + } + }), + }), + ]); +} + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = params; + if (!organizationSlug || !projectParam) { + return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); + } + + const formData = await request.formData(); + + const schema = createSchema({ + getSlugMatch: (slug) => { + return { isMatch: slug === projectParam, projectSlug: projectParam }; + }, + }); + const submission = parse(formData, { schema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); + + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } + + const { projectId } = membershipResultOrFail.value; + + switch (submission.value.action) { + case "rename": { + const resultOrFail = await projectSettingsService.renameProject( + projectId, + submission.value.projectName + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to rename project", { + error: resultOrFail.error, + }); + return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); + } + } + } + + return redirectWithSuccessMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project renamed to ${submission.value.projectName}` + ); + } + case "delete": { + const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to delete project", { + error: resultOrFail.error, + }); + return redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project ${projectParam} could not be deleted` + ); + } + } + } + + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + "Project deleted" + ); + } + } +}; + +export default function GeneralSettingsPage() { + const project = useProject(); + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); + + const [renameForm, { projectName }] = useForm({ + id: "rename-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema(), + }); + }, + }); + + const isRenameLoading = + navigation.formData?.get("action") === "rename" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const [deleteForm, { projectSlug }] = useForm({ + id: "delete-project", + // TODO: type this + lastSubmission: lastSubmission as any, + shouldValidate: "onInput", + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: createSchema({ + getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), + }), + }); + }, + }); + + const isDeleteLoading = + navigation.formData?.get("action") === "delete" && + (navigation.state === "submitting" || navigation.state === "loading"); + + const [deleteInputValue, setDeleteInputValue] = useState(""); + + return ( + +
+
+ General +
+
+ + + + + This goes in your{" "} + trigger.config file. + + +
+
+
+ + + { + setHasRenameFormChanges(e.target.value !== project.name); + }} + /> + {projectName.error} + + + Save + + } + /> +
+
+
+
+ +
+ Danger zone +
+
+
+ + + setDeleteInputValue(e.target.value)} + /> + {projectSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Project slug + {project.slug} and then press + Delete. + + + + Delete + + } + /> +
+
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx new file mode 100644 index 00000000000..edb6cf9c159 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.integrations/route.tsx @@ -0,0 +1,500 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData, useTypedFetcher } from "remix-typedjson"; +import { z } from "zod"; +import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { + redirectBackWithErrorMessage, + redirectBackWithSuccessMessage, +} from "~/models/message.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; +import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder"; +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { useSearchParams } from "@remix-run/react"; +import { type BuildSettings } from "~/v3/buildSettings"; +import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; +import { + VercelSettingsPanel, + VercelOnboardingModal, +} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); + + const projectSettingsPresenter = new ProjectSettingsPresenter(); + const resultOrFail = await projectSettingsPresenter.getProjectSettings( + organizationSlug, + projectParam, + userId + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "project_not_found": { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed loading project settings", { + error: resultOrFail.error, + }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, please try again!", + }); + } + } + } + + const { gitHubApp, buildSettings } = resultOrFail.value; + + return typedjson({ + githubAppEnabled: gitHubApp.enabled, + buildSettings, + vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, + }); +}; + +const UpdateBuildSettingsFormSchema = z.object({ + action: z.literal("update-build-settings"), + triggerConfigFilePath: z + .string() + .trim() + .optional() + .transform((val) => (val ? val.replace(/^\/+/, "") : val)) + .refine((val) => !val || val.length <= 255, { + message: "Config file path must not exceed 255 characters", + }), + installCommand: z + .string() + .trim() + .optional() + .refine((val) => !val || !val.includes("\n"), { + message: "Install command must be a single line", + }) + .refine((val) => !val || val.length <= 500, { + message: "Install command must not exceed 500 characters", + }), + preBuildCommand: z + .string() + .trim() + .optional() + .refine((val) => !val || !val.includes("\n"), { + message: "Pre-build command must be a single line", + }) + .refine((val) => !val || val.length <= 500, { + message: "Pre-build command must not exceed 500 characters", + }), + useNativeBuildServer: z + .string() + .optional() + .transform((val) => val === "on"), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = params; + if (!organizationSlug || !projectParam) { + return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: UpdateBuildSettingsFormSchema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); + + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } + + const { projectId } = membershipResultOrFail.value; + + const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } = + submission.value; + + const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { + installCommand: installCommand || undefined, + preBuildCommand: preBuildCommand || undefined, + triggerConfigFilePath: triggerConfigFilePath || undefined, + useNativeBuildServer: useNativeBuildServer, + }); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to update build settings", { + error: resultOrFail.error, + }); + return redirectBackWithErrorMessage(request, "Failed to update build settings"); + } + } + } + + return redirectBackWithSuccessMessage(request, "Build settings updated successfully"); +}; + +export default function IntegrationsSettingsPage() { + const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = + useTypedLoaderData(); + const project = useProject(); + const organization = useOrganization(); + const environment = useEnvironment(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Vercel onboarding modal state + const hasQueryParam = searchParams.get("vercelOnboarding") === "true"; + const nextUrl = searchParams.get("next"); + const [isModalOpen, setIsModalOpen] = useState(false); + const vercelFetcher = useTypedFetcher(); + + // Helper to open modal and ensure query param is present + const openVercelOnboarding = useCallback(() => { + setIsModalOpen(true); + // Ensure query param is present to maintain state during form submissions + if (!hasQueryParam) { + setSearchParams((prev) => { + prev.set("vercelOnboarding", "true"); + return prev; + }); + } + }, [hasQueryParam, setSearchParams]); + + const closeVercelOnboarding = useCallback(() => { + // Remove query param if present + if (hasQueryParam) { + setSearchParams((prev) => { + prev.delete("vercelOnboarding"); + return prev; + }); + } + // Close modal + setIsModalOpen(false); + }, [hasQueryParam, setSearchParams]); + + // When query param is present, handle modal opening + // Note: We don't close the modal based on data state during onboarding - only when explicitly closed + useEffect(() => { + if (hasQueryParam && vercelIntegrationEnabled) { + // Ensure query param is present and modal is open + if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data is loaded, ensure modal is open (query param takes precedence) + if (!isModalOpen) { + openVercelOnboarding(); + } + } else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) { + // Load onboarding data + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + ); + } + } else if (!hasQueryParam && isModalOpen) { + // Query param removed but modal is open, close modal + setIsModalOpen(false); + } + }, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); + + // Ensure modal stays open when query param is present (even after data reloads) + // This is a safeguard to prevent the modal from closing during form submissions + useEffect(() => { + if (hasQueryParam && !isModalOpen) { + // Query param is present but modal is closed, open it + // This ensures the modal stays open during the onboarding flow + openVercelOnboarding(); + } + }, [hasQueryParam, isModalOpen, openVercelOnboarding]); + + // When data finishes loading (from query param), ensure modal is open + useEffect(() => { + if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data loaded and query param is present, ensure modal is open + if (!isModalOpen) { + openVercelOnboarding(); + } + } + }, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); + + // Track if we're waiting for data from button click (not query param) + const waitingForButtonClickRef = useRef(false); + + // Handle opening modal from button click (without query param) + const handleOpenVercelModal = useCallback(() => { + // Add query param to maintain state during form submissions + if (!hasQueryParam) { + setSearchParams((prev) => { + prev.set("vercelOnboarding", "true"); + return prev; + }); + } + + if (vercelFetcher.data && vercelFetcher.data.onboardingData) { + // Data already loaded, open modal immediately + openVercelOnboarding(); + } else { + // Need to load data first, mark that we're waiting for button click + waitingForButtonClickRef.current = true; + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` + ); + } + }, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]); + + // When data loads from button click, open modal + useEffect(() => { + if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { + // Data loaded from button click, open modal and ensure query param is present + waitingForButtonClickRef.current = false; + openVercelOnboarding(); + } + }, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]); + + return ( + <> + +
+ {githubAppEnabled && ( + +
+ Git settings +
+ +
+
+ + {vercelIntegrationEnabled && ( +
+ Vercel integration +
+ +
+
+ )} + +
+ Build settings +
+ +
+
+
+ )} +
+
+ + {/* Vercel Onboarding Modal */} + {vercelIntegrationEnabled && ( + { + vercelFetcher.load( + `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${ + vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : "" + }` + ); + }} + /> + )} + + ); +} + +function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { + const lastSubmission = useActionData() as any; + const navigation = useNavigation(); + + const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); + const [buildSettingsValues, setBuildSettingsValues] = useState({ + preBuildCommand: buildSettings?.preBuildCommand || "", + installCommand: buildSettings?.installCommand || "", + triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "", + useNativeBuildServer: buildSettings?.useNativeBuildServer || false, + }); + + useEffect(() => { + const hasChanges = + buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") || + buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || + buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") || + buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false); + setHasBuildSettingsChanges(hasChanges); + }, [buildSettingsValues, buildSettings]); + + const [buildSettingsForm, fields] = useForm({ + id: "update-build-settings", + lastSubmission: lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { + schema: UpdateBuildSettingsFormSchema, + }); + }, + }); + + const isBuildSettingsLoading = + navigation.formData?.get("action") === "update-build-settings" && + (navigation.state === "submitting" || navigation.state === "loading"); + + return ( +
+
+ + + { + setBuildSettingsValues((prev) => ({ + ...prev, + triggerConfigFilePath: e.target.value, + })); + }} + /> + + Path to your Trigger configuration file, relative to the root directory of your repo. + + + {fields.triggerConfigFilePath.error} + + + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + installCommand: e.target.value, + })); + }} + /> + + Command to install your project dependencies. This will be run from the root directory + of your repo. Auto-detected by default. + + {fields.installCommand.error} + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + preBuildCommand: e.target.value, + })); + }} + /> + + Any command that needs to run before we build and deploy your project. This will be run + from the root directory of your repo. + + {fields.preBuildCommand.error} + +
+ + { + setBuildSettingsValues((prev) => ({ + ...prev, + useNativeBuildServer: isChecked, + })); + }} + /> + + Native build server builds do not rely on external build providers and will become the + default in the future. Version 4.2.0 or newer is required. + + + {fields.useNativeBuildServer.error} + + +
+ {buildSettingsForm.error} + + Save + + } + /> +
+
+ ); +} 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 a5a70c39af6..cc85dbb4acc 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,57 +1,13 @@ -import { conform, useForm } from "@conform-to/react"; -import { parse } from "@conform-to/zod"; -import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; -import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { z } from "zod"; -import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { InlineCode } from "~/components/code/InlineCode"; -import { - MainHorizontallyCenteredContainer, - PageBody, - PageContainer, -} from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; -import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; -import { ClipboardField } from "~/components/primitives/ClipboardField"; -import { Fieldset } from "~/components/primitives/Fieldset"; -import { FormButtons } from "~/components/primitives/FormButtons"; -import { FormError } from "~/components/primitives/FormError"; -import { Header2 } from "~/components/primitives/Headers"; -import { Hint } from "~/components/primitives/Hint"; -import { Input } from "~/components/primitives/Input"; -import { InputGroup } from "~/components/primitives/InputGroup"; -import { Label } from "~/components/primitives/Label"; +import { Outlet, type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { useOrganization } from "~/hooks/useOrganizations"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { useProject } from "~/hooks/useProject"; -import { - redirectBackWithErrorMessage, - redirectBackWithSuccessMessage, - redirectWithErrorMessage, - redirectWithSuccessMessage, -} from "~/models/message.server"; -import { ProjectSettingsService } from "~/services/projectSettings.server"; -import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder"; -import React, { useEffect, useState, useCallback, useRef } from "react"; -import { useSearchParams } from "@remix-run/react"; -import { useEnvironment } from "~/hooks/useEnvironment"; -import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; -import { type BuildSettings } from "~/v3/buildSettings"; -import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; -import { - VercelSettingsPanel, - VercelOnboardingModal, -} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; -import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel"; -import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; -import { useTypedFetcher } from "remix-typedjson"; +import { EnvironmentParamSchema, v3ProjectSettingsGeneralPath, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -62,397 +18,28 @@ export const meta: MetaFunction = () => { }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { projectParam, organizationSlug } = EnvironmentParamSchema.parse(params); - - const projectSettingsPresenter = new ProjectSettingsPresenter(); - const resultOrFail = await projectSettingsPresenter.getProjectSettings( - organizationSlug, - projectParam, - userId - ); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "project_not_found": { - throw new Response(undefined, { - status: 404, - statusText: "Project not found", - }); - } - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed loading project settings", { - error: resultOrFail.error, - }); - throw new Response(undefined, { - status: 400, - statusText: "Something went wrong, please try again!", - }); - } - } - } - - const { gitHubApp, buildSettings } = resultOrFail.value; - - return typedjson({ - githubAppEnabled: gitHubApp.enabled, - buildSettings, - vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported, - }); -}; - -const UpdateBuildSettingsFormSchema = z.object({ - action: z.literal("update-build-settings"), - triggerConfigFilePath: z - .string() - .trim() - .optional() - .transform((val) => (val ? val.replace(/^\/+/, "") : val)) - .refine((val) => !val || val.length <= 255, { - message: "Config file path must not exceed 255 characters", - }), - installCommand: z - .string() - .trim() - .optional() - .refine((val) => !val || !val.includes("\n"), { - message: "Install command must be a single line", - }) - .refine((val) => !val || val.length <= 500, { - message: "Install command must not exceed 500 characters", - }), - preBuildCommand: z - .string() - .trim() - .optional() - .refine((val) => !val || !val.includes("\n"), { - message: "Pre-build command must be a single line", - }) - .refine((val) => !val || val.length <= 500, { - message: "Pre-build command must not exceed 500 characters", - }), - useNativeBuildServer: z - .string() - .optional() - .transform((val) => val === "on"), -}); - -type UpdateBuildSettingsFormSchema = z.infer; - -export function createSchema( - constraints: { - getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; - } = {} -) { - return z.discriminatedUnion("action", [ - z.object({ - action: z.literal("rename"), - projectName: z.string().min(3, "Project name must have at least 3 characters").max(50), - }), - z.object({ - action: z.literal("delete"), - projectSlug: z.string().superRefine((slug, ctx) => { - if (constraints.getSlugMatch === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: conform.VALIDATION_UNDEFINED, - }); - } else { - const { isMatch, projectSlug } = constraints.getSlugMatch(slug); - if (isMatch) { - return; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `The slug must match ${projectSlug}`, - }); - } - }), - }), - UpdateBuildSettingsFormSchema, - ]); -} - -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam } = params; - if (!organizationSlug || !projectParam) { - return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); - } - - const formData = await request.formData(); + await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - const schema = createSchema({ - getSlugMatch: (slug) => { - return { isMatch: slug === projectParam, projectSlug: projectParam }; - }, - }); - const submission = parse(formData, { schema }); + // Redirect /settings to /settings/general (or /settings/integrations for Vercel onboarding) + const url = new URL(request.url); + if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) { + const org = { slug: organizationSlug }; + const project = { slug: projectParam }; + const env = { slug: envParam }; - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + const basePath = url.searchParams.has("vercelOnboarding") + ? v3ProjectSettingsIntegrationsPath(org, project, env) + : v3ProjectSettingsGeneralPath(org, project, env); - const projectSettingsService = new ProjectSettingsService(); - const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( - organizationSlug, - projectParam, - userId - ); - - if (membershipResultOrFail.isErr()) { - return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + return redirect(`${basePath}${url.search}`); } - const { projectId } = membershipResultOrFail.value; - - switch (submission.value.action) { - case "rename": { - const resultOrFail = await projectSettingsService.renameProject( - projectId, - submission.value.projectName - ); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to rename project", { - error: resultOrFail.error, - }); - return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); - } - } - } - - return redirectWithSuccessMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project renamed to ${submission.value.projectName}` - ); - } - case "delete": { - const resultOrFail = await projectSettingsService.deleteProject(projectParam, userId); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to delete project", { - error: resultOrFail.error, - }); - return redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project ${projectParam} could not be deleted` - ); - } - } - } - - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - "Project deleted" - ); - } - case "update-build-settings": { - const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } = - submission.value; - - const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { - installCommand: installCommand || undefined, - preBuildCommand: preBuildCommand || undefined, - triggerConfigFilePath: triggerConfigFilePath || undefined, - useNativeBuildServer: useNativeBuildServer, - }); - - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; - - logger.error("Failed to update build settings", { - error: resultOrFail.error, - }); - return redirectBackWithErrorMessage(request, "Failed to update build settings"); - } - } - } - - return redirectBackWithSuccessMessage(request, "Build settings updated successfully"); - } - default: { - submission.value satisfies never; - return redirectBackWithErrorMessage(request, "Failed to process request"); - } - } + return null; }; -export default function Page() { - const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } = - useTypedLoaderData(); +export default function SettingsLayout() { const project = useProject(); - const organization = useOrganization(); - const environment = useEnvironment(); - const lastSubmission = useActionData(); - const navigation = useNavigation(); - const [searchParams, setSearchParams] = useSearchParams(); - - // Vercel onboarding modal state - const hasQueryParam = searchParams.get("vercelOnboarding") === "true"; - const nextUrl = searchParams.get("next"); - const [isModalOpen, setIsModalOpen] = useState(false); - const vercelFetcher = useTypedFetcher(); - - // Helper to open modal and ensure query param is present - const openVercelOnboarding = useCallback(() => { - setIsModalOpen(true); - // Ensure query param is present to maintain state during form submissions - if (!hasQueryParam) { - setSearchParams((prev) => { - prev.set("vercelOnboarding", "true"); - return prev; - }); - } - }, [hasQueryParam, setSearchParams]); - - const closeVercelOnboarding = useCallback(() => { - // Remove query param if present - if (hasQueryParam) { - setSearchParams((prev) => { - prev.delete("vercelOnboarding"); - return prev; - }); - } - // Close modal - setIsModalOpen(false); - }, [hasQueryParam, setSearchParams]); - - // When query param is present, handle modal opening - // Note: We don't close the modal based on data state during onboarding - only when explicitly closed - useEffect(() => { - if (hasQueryParam && vercelIntegrationEnabled) { - // Ensure query param is present and modal is open - if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { - // Data is loaded, ensure modal is open (query param takes precedence) - if (!isModalOpen) { - openVercelOnboarding(); - } - } else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) { - // Load onboarding data - vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` - ); - } - } else if (!hasQueryParam && isModalOpen) { - // Query param removed but modal is open, close modal - setIsModalOpen(false); - } - }, [hasQueryParam, vercelIntegrationEnabled, organization.slug, project.slug, environment.slug, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); - - // Ensure modal stays open when query param is present (even after data reloads) - // This is a safeguard to prevent the modal from closing during form submissions - useEffect(() => { - if (hasQueryParam && !isModalOpen) { - // Query param is present but modal is closed, open it - // This ensures the modal stays open during the onboarding flow - openVercelOnboarding(); - } - }, [hasQueryParam, isModalOpen, openVercelOnboarding]); - - // When data finishes loading (from query param), ensure modal is open - useEffect(() => { - if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { - // Data loaded and query param is present, ensure modal is open - if (!isModalOpen) { - openVercelOnboarding(); - } - } - }, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]); - - - // Track if we're waiting for data from button click (not query param) - const waitingForButtonClickRef = useRef(false); - - // Handle opening modal from button click (without query param) - const handleOpenVercelModal = useCallback(() => { - // Add query param to maintain state during form submissions - if (!hasQueryParam) { - setSearchParams((prev) => { - prev.set("vercelOnboarding", "true"); - return prev; - }); - } - - if (vercelFetcher.data && vercelFetcher.data.onboardingData) { - // Data already loaded, open modal immediately - openVercelOnboarding(); - } else { - // Need to load data first, mark that we're waiting for button click - waitingForButtonClickRef.current = true; - vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true` - ); - } - }, [organization.slug, project.slug, environment.slug, vercelFetcher, setSearchParams, hasQueryParam, openVercelOnboarding]); - - // When data loads from button click, open modal - useEffect(() => { - if (waitingForButtonClickRef.current && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") { - // Data loaded from button click, open modal and ensure query param is present - waitingForButtonClickRef.current = false; - openVercelOnboarding(); - } - }, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]); - - const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false); - - const [renameForm, { projectName }] = useForm({ - id: "rename-project", - // TODO: type this - lastSubmission: lastSubmission as any, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: createSchema(), - }); - }, - }); - - const isRenameLoading = - navigation.formData?.get("action") === "rename" && - (navigation.state === "submitting" || navigation.state === "loading"); - - const [deleteForm, { projectSlug }] = useForm({ - id: "delete-project", - // TODO: type this - lastSubmission: lastSubmission as any, - shouldValidate: "onInput", - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: createSchema({ - getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }), - }), - }); - }, - }); - - const isDeleteLoading = - navigation.formData?.get("action") === "delete" && - (navigation.state === "submitting" || navigation.state === "loading"); - - const [deleteInputValue, setDeleteInputValue] = useState(""); return ( @@ -479,302 +66,8 @@ export default function Page() { - -
-
- General -
-
- - - - - This goes in your{" "} - trigger.config file. - - -
-
-
- - - { - setHasRenameFormChanges(e.target.value !== project.name); - }} - /> - {projectName.error} - - - Save - - } - /> -
-
-
-
- - {githubAppEnabled && ( - -
- Git settings -
- -
-
- - {vercelIntegrationEnabled && ( -
- Vercel integration -
- -
-
- )} - -
- Build settings -
- -
-
-
- )} - -
- Danger zone -
-
-
- - - setDeleteInputValue(e.target.value)} - /> - {projectSlug.error} - {deleteForm.error} - - This change is irreversible, so please be certain. Type in the Project slug - {project.slug} and then press - Delete. - - - - Delete - - } - /> -
-
-
-
-
-
+
- - {/* Vercel Onboarding Modal */} - {vercelIntegrationEnabled && ( - { - vercelFetcher.load( - `${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true${ - vercelEnvironmentId ? `&vercelEnvironmentId=${vercelEnvironmentId}` : "" - }` - ); - }} - /> - )}
); } - -function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { - const lastSubmission = useActionData() as any; - const navigation = useNavigation(); - - const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); - const [buildSettingsValues, setBuildSettingsValues] = useState({ - preBuildCommand: buildSettings?.preBuildCommand || "", - installCommand: buildSettings?.installCommand || "", - triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "", - useNativeBuildServer: buildSettings?.useNativeBuildServer || false, - }); - - useEffect(() => { - const hasChanges = - buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") || - buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || - buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") || - buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false); - setHasBuildSettingsChanges(hasChanges); - }, [buildSettingsValues, buildSettings]); - - const [buildSettingsForm, fields] = useForm({ - id: "update-build-settings", - lastSubmission: lastSubmission, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: UpdateBuildSettingsFormSchema, - }); - }, - }); - - const isBuildSettingsLoading = - navigation.formData?.get("action") === "update-build-settings" && - (navigation.state === "submitting" || navigation.state === "loading"); - - return ( -
-
- - - { - setBuildSettingsValues((prev) => ({ - ...prev, - triggerConfigFilePath: e.target.value, - })); - }} - /> - - Path to your Trigger configuration file, relative to the root directory of your repo. - - - {fields.triggerConfigFilePath.error} - - - - - - { - setBuildSettingsValues((prev) => ({ - ...prev, - installCommand: e.target.value, - })); - }} - /> - - Command to install your project dependencies. This will be run from the root directory - of your repo. Auto-detected by default. - - {fields.installCommand.error} - - - - { - setBuildSettingsValues((prev) => ({ - ...prev, - preBuildCommand: e.target.value, - })); - }} - /> - - Any command that needs to run before we build and deploy your project. This will be run - from the root directory of your repo. - - {fields.preBuildCommand.error} - -
- - { - setBuildSettingsValues((prev) => ({ - ...prev, - useNativeBuildServer: isChecked, - })); - }} - /> - - Native build server builds do not rely on external build providers and will become the - default in the future. Version 4.2.0 or newer is required. - - - {fields.useNativeBuildServer.error} - - -
- {buildSettingsForm.error} - - Save - - } - /> -
-
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx index 10b3f2283ce..df6f5b9859a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx @@ -28,7 +28,7 @@ import { requireOrganization } from "~/services/org.server"; import { OrganizationParamsSchema } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; import { TrashIcon } from "@heroicons/react/20/solid"; -import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; import { LinkButton } from "~/components/primitives/Buttons"; function formatDate(date: Date): string { @@ -354,7 +354,7 @@ export default function VercelIntegrationPage() { { const user = await requireUser(request); @@ -39,5 +39,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const selector = new SelectBestEnvironmentPresenter(); const environment = await selector.selectBestEnvironment(project.id, user, project.environments); - return redirect(v3ProjectSettingsPath({ slug: organizationSlug }, project, environment)); + return redirect(v3ProjectSettingsGeneralPath({ slug: organizationSlug }, project, environment)); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx index afd89f33577..38ef50126cd 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx @@ -43,7 +43,7 @@ import { requireUserId } from "~/services/session.server"; import { githubAppInstallPath, EnvironmentParamSchema, - v3ProjectSettingsPath, + v3ProjectSettingsIntegrationsPath, } from "~/utils/pathBuilder"; import { cn } from "~/utils/cn"; import { type BranchTrackingConfig } from "~/v3/github"; @@ -459,7 +459,7 @@ export function ConnectGitHubRepoModal({ navigate( githubAppInstallPath( organizationSlug, - `${v3ProjectSettingsPath( + `${v3ProjectSettingsIntegrationsPath( { slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug } @@ -567,7 +567,7 @@ export function GitHubConnectionPrompt({ redirectUrl?: string; }) { - const githubInstallationRedirect = redirectUrl || v3ProjectSettingsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug }); + const githubInstallationRedirect = redirectUrl || v3ProjectSettingsIntegrationsPath({ slug: organizationSlug }, { slug: projectSlug }, { slug: environmentSlug }); return (
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx index bb0fca6d745..26e9ad5b3be 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel.tsx @@ -44,7 +44,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { sanitizeVercelNextUrl } from "~/v3/vercel/vercelUrls.server"; -import { EnvironmentParamSchema, v3ProjectSettingsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3ProjectSettingsIntegrationsPath, vercelAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder"; import { VercelSettingsPresenter, type VercelOnboardingData, @@ -224,7 +224,7 @@ export async function action({ request, params }: ActionFunctionArgs) { return json(submission); } - const settingsPath = v3ProjectSettingsPath( + const settingsPath = v3ProjectSettingsIntegrationsPath( { slug: organizationSlug }, { slug: projectParam }, { slug: envParam } diff --git a/apps/webapp/app/routes/vercel.connect.tsx b/apps/webapp/app/routes/vercel.connect.tsx index 7c0701edfe3..f1be58fe977 100644 --- a/apps/webapp/app/routes/vercel.connect.tsx +++ b/apps/webapp/app/routes/vercel.connect.tsx @@ -7,7 +7,7 @@ import { VercelIntegrationRepository, type TokenResponse } from "~/models/vercel import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { requestUrl } from "~/utils/requestUrl.server"; -import { v3ProjectSettingsPath } from "~/utils/pathBuilder"; +import { v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; import { validateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; const VercelConnectSchema = z.object({ @@ -139,7 +139,7 @@ export async function loader({ request }: LoaderFunctionArgs) { throw new Response("Environment not found", { status: 404 }); } - const settingsPath = v3ProjectSettingsPath( + const settingsPath = v3ProjectSettingsIntegrationsPath( { slug: stateData.organizationSlug }, { slug: stateData.projectSlug }, { slug: environment.slug } diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 4f1c03d8d66..4230474804c 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -499,6 +499,22 @@ export function v3ProjectSettingsPath( return `${v3EnvironmentPath(organization, project, environment)}/settings`; } +export function v3ProjectSettingsGeneralPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3ProjectSettingsPath(organization, project, environment)}/general`; +} + +export function v3ProjectSettingsIntegrationsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3ProjectSettingsPath(organization, project, environment)}/integrations`; +} + export function v3LogsPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts index 213e730c643..64ed5d55a3c 100644 --- a/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts +++ b/apps/webapp/app/v3/vercel/vercelProjectIntegrationSchema.ts @@ -202,6 +202,15 @@ export function shouldSyncEnvVarForAnyEnvironment( return false; } +export function buildVercelDeploymentUrl( + vercelTeamSlug: string | undefined, + vercelProjectName: string, + integrationDeploymentId: string +): string { + const vercelId = integrationDeploymentId.replace(/^dpl_/, ""); + return `https://vercel.com/${vercelTeamSlug}/${vercelProjectName}/${vercelId}`; +} + export function isPullEnvVarsEnabledForEnvironment( pullEnvVarsBeforeBuild: EnvSlug[] | null | undefined, environmentType: TriggerEnvironmentType From 6cfa07464217fb360249e544eb2512b02052fcb9 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Fri, 20 Feb 2026 10:22:14 +0100 Subject: [PATCH 2/5] feat(alerts): include deployment integration metadata in alerts Add resolution and propagation of deployment integration metadata (git links and Vercel deployment URL) to alert delivery flow so emails, Slack messages and webhooks can include richer context for deployment alerts. - Resolve Git metadata and Vercel integration info via BranchesPresenter and Vercel integration schema in a new #resolveDeploymentMetadata flow; represent result as DeploymentIntegrationMetadata. - Thread DeploymentIntegrationMetadata through DeliverAlertService and pass it into #sendEmail, #sendSlack and #sendWebhook calls. - Populate email payload with git details (branch, short SHA, commit message and PR info) and vercelDeploymentUrl when present. - Use neverthrow.fromPromise to safely handle metadata resolution and fall back to empty metadata on errors. Motivation: provide recipients immediate links and SCM context for deployment success/failure alerts to speed debugging and navigation. --- .../v3/services/alerts/deliverAlert.server.ts | 287 +++++++++++++++--- .../emails/emails/deployment-failure.tsx | 84 ++++- .../emails/emails/deployment-success.tsx | 93 +++++- packages/core/src/v3/schemas/webhooks.ts | 20 ++ 4 files changed, 432 insertions(+), 52 deletions(-) diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index debb176da57..8b922f91e9f 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -33,14 +33,20 @@ import { ProjectAlertWebhookProperties, } from "~/models/projectAlert.server"; import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; +import { + processGitMetadata, + type GitMetaLinks, +} from "~/presenters/v3/BranchesPresenter.server"; import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server"; import { sendAlertEmail } from "~/services/email.server"; +import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { logger } from "~/services/logger.server"; import { decryptSecret } from "~/services/secrets/secretStore.server"; import { v3RunPath } from "~/utils/pathBuilder"; import { alertsRateLimiter } from "~/v3/alertsRateLimiter.server"; import { alertsWorker } from "~/v3/alertsWorker.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; +import { fromPromise } from "neverthrow"; import { BaseService } from "../baseService.server"; import { CURRENT_API_VERSION } from "~/api/versions"; @@ -89,6 +95,11 @@ type FoundAlert = Prisma.Result< class SkipRetryError extends Error {} +type DeploymentIntegrationMetadata = { + git: GitMetaLinks | null; + vercelDeploymentUrl: string | undefined; +}; + export class DeliverAlertService extends BaseService { public async call(alertId: string) { const alert: FoundAlert | null = await this._prisma.projectAlert.findFirst({ @@ -139,18 +150,27 @@ export class DeliverAlertService extends BaseService { return; } + const emptyMeta: DeploymentIntegrationMetadata = { git: null, vercelDeploymentUrl: undefined }; + + const deploymentMeta = + alert.type === "DEPLOYMENT_SUCCESS" || alert.type === "DEPLOYMENT_FAILURE" + ? ( + await fromPromise(this.#resolveDeploymentMetadata(alert), (e) => e) + ).unwrapOr(emptyMeta) + : emptyMeta; + try { switch (alert.channel.type) { case "EMAIL": { - await this.#sendEmail(alert); + await this.#sendEmail(alert, deploymentMeta); break; } case "SLACK": { - await this.#sendSlack(alert); + await this.#sendSlack(alert, deploymentMeta); break; } case "WEBHOOK": { - await this.#sendWebhook(alert); + await this.#sendWebhook(alert, deploymentMeta); break; } default: { @@ -177,7 +197,7 @@ export class DeliverAlertService extends BaseService { }); } - async #sendEmail(alert: FoundAlert) { + async #sendEmail(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) { const emailProperties = ProjectAlertEmailProperties.safeParse(alert.channel.properties); if (!emailProperties.success) { @@ -243,6 +263,19 @@ export class DeliverAlertService extends BaseService { error: preparedError, deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, organization: alert.project.organization.title, + git: deploymentMeta.git + ? { + branchName: deploymentMeta.git.branchName, + shortSha: deploymentMeta.git.shortSha, + commitMessage: deploymentMeta.git.commitMessage, + commitUrl: deploymentMeta.git.commitUrl, + branchUrl: deploymentMeta.git.branchUrl, + pullRequestNumber: deploymentMeta.git.pullRequestNumber, + pullRequestTitle: deploymentMeta.git.pullRequestTitle, + pullRequestUrl: deploymentMeta.git.pullRequestUrl, + } + : undefined, + vercelDeploymentUrl: deploymentMeta.vercelDeploymentUrl, }); } else { logger.error("[DeliverAlert] Worker deployment not found", { @@ -264,6 +297,19 @@ export class DeliverAlertService extends BaseService { deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`, taskCount: alert.workerDeployment.worker?.tasks.length ?? 0, organization: alert.project.organization.title, + git: deploymentMeta.git + ? { + branchName: deploymentMeta.git.branchName, + shortSha: deploymentMeta.git.shortSha, + commitMessage: deploymentMeta.git.commitMessage, + commitUrl: deploymentMeta.git.commitUrl, + branchUrl: deploymentMeta.git.branchUrl, + pullRequestNumber: deploymentMeta.git.pullRequestNumber, + pullRequestTitle: deploymentMeta.git.pullRequestTitle, + pullRequestUrl: deploymentMeta.git.pullRequestUrl, + } + : undefined, + vercelDeploymentUrl: deploymentMeta.vercelDeploymentUrl, }); } else { logger.error("[DeliverAlert] Worker deployment not found", { @@ -279,7 +325,7 @@ export class DeliverAlertService extends BaseService { } } - async #sendWebhook(alert: FoundAlert) { + async #sendWebhook(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) { const webhookProperties = ProjectAlertWebhookProperties.safeParse(alert.channel.properties); if (!webhookProperties.success) { @@ -452,6 +498,8 @@ export class DeliverAlertService extends BaseService { name: alert.project.name, }, error: preparedError, + git: this.#buildWebhookGitObject(deploymentMeta.git), + vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl), }; await this.#deliverWebhook(payload, webhookProperties.data); @@ -488,6 +536,8 @@ export class DeliverAlertService extends BaseService { name: alert.project.name, }, error: preparedError, + git: this.#buildWebhookGitObject(deploymentMeta.git), + vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl), }, }; @@ -542,6 +592,8 @@ export class DeliverAlertService extends BaseService { slug: alert.project.slug, name: alert.project.name, }, + git: this.#buildWebhookGitObject(deploymentMeta.git), + vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl), }; await this.#deliverWebhook(payload, webhookProperties.data); @@ -584,6 +636,8 @@ export class DeliverAlertService extends BaseService { slug: alert.project.slug, name: alert.project.name, }, + git: this.#buildWebhookGitObject(deploymentMeta.git), + vercel: this.#buildWebhookVercelObject(deploymentMeta.vercelDeploymentUrl), }, }; @@ -609,7 +663,7 @@ export class DeliverAlertService extends BaseService { } } - async #sendSlack(alert: FoundAlert) { + async #sendSlack(alert: FoundAlert, deploymentMeta: DeploymentIntegrationMetadata) { const slackProperties = ProjectAlertSlackProperties.safeParse(alert.channel.properties); if (!slackProperties.success) { @@ -694,9 +748,7 @@ export class DeliverAlertService extends BaseService { type: "section", text: { type: "mrkdwn", - text: `:rotating_light: Error in *${taskIdentifier}* __`, + text: `:rotating_light: Error in *${taskIdentifier}*`, }, }, { @@ -706,18 +758,7 @@ export class DeliverAlertService extends BaseService { text: this.#wrapInCodeBlock(error.stackTrace ?? error.message), }, }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${runId} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`, - }, - ], - }, - { - type: "divider", - }, + this.#buildRunQuoteBlock(taskIdentifier, version, environment, runId, alert.project.name, timestamp), { type: "actions", elements: [ @@ -789,14 +830,13 @@ export class DeliverAlertService extends BaseService { await this.#postSlackMessage(integration, { channel: slackProperties.data.channelId, + text: `:rotating_light: Deployment failed *${version}.${environment}*`, blocks: [ { type: "section", text: { type: "mrkdwn", - text: `:rotating_light: Deployment failed *${version}.${environment}* __`, + text: `:rotating_light: Deployment failed *${version}.${environment}*`, }, }, { @@ -806,15 +846,7 @@ export class DeliverAlertService extends BaseService { text: this.#wrapInCodeBlock(preparedError.stack ?? preparedError.message), }, }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`, - }, - ], - }, + this.#buildDeploymentQuoteBlock(alert, deploymentMeta, version, environment, timestamp), { type: "actions", elements: [ @@ -842,7 +874,6 @@ export class DeliverAlertService extends BaseService { if (alert.workerDeployment) { const version = alert.workerDeployment.version; const environment = alert.environment.slug; - const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0; const timestamp = alert.workerDeployment.deployedAt ?? new Date(); await this.#postSlackMessage(integration, { @@ -853,20 +884,10 @@ export class DeliverAlertService extends BaseService { type: "section", text: { type: "mrkdwn", - text: `:rocket: Deployed *${version}.${environment}* successfully __`, + text: `:rocket: Deployed *${version}.${environment}* successfully`, }, }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: `${numberOfTasks} tasks | ${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`, - }, - ], - }, + this.#buildDeploymentQuoteBlock(alert, deploymentMeta, version, environment, timestamp), { type: "actions", elements: [ @@ -949,7 +970,11 @@ export class DeliverAlertService extends BaseService { ); try { - return await client.chat.postMessage(message); + return await client.chat.postMessage({ + ...message, + unfurl_links: false, + unfurl_media: false, + }); } catch (error) { if (isWebAPIRateLimitedError(error)) { logger.warn("[DeliverAlert] Slack rate limited", { @@ -1013,6 +1038,174 @@ export class DeliverAlertService extends BaseService { } } + async #resolveDeploymentMetadata( + alert: FoundAlert + ): Promise { + const deployment = alert.workerDeployment; + if (!deployment) { + return { git: null, vercelDeploymentUrl: undefined }; + } + + const git = processGitMetadata(deployment.git); + const vercelDeploymentUrl = await this.#resolveVercelDeploymentUrl( + deployment.projectId, + deployment.id + ); + + return { git, vercelDeploymentUrl }; + } + + async #resolveVercelDeploymentUrl( + projectId: string, + deploymentId: string + ): Promise { + const vercelProjectIntegration = + await this._prisma.organizationProjectIntegration.findFirst({ + where: { + projectId, + deletedAt: null, + organizationIntegration: { + service: "VERCEL", + deletedAt: null, + }, + }, + select: { + integrationData: true, + }, + }); + + if (!vercelProjectIntegration) { + return undefined; + } + + const parsed = VercelProjectIntegrationDataSchema.safeParse( + vercelProjectIntegration.integrationData + ); + + if (!parsed.success || !parsed.data.vercelTeamSlug) { + return undefined; + } + + const integrationDeployment = + await this._prisma.integrationDeployment.findFirst({ + where: { + deploymentId, + integrationName: "vercel", + }, + select: { + integrationDeploymentId: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!integrationDeployment) { + return undefined; + } + + const vercelId = integrationDeployment.integrationDeploymentId.replace(/^dpl_/, ""); + return `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`; + } + + #buildDeploymentQuoteBlock( + alert: FoundAlert, + deploymentMeta: DeploymentIntegrationMetadata, + version: string, + environment: string, + timestamp: Date + ) { + const git = deploymentMeta.git; + const shortCode = alert.workerDeployment!.shortCode; + const lines: string[] = []; + + // Line 1: git author + branch (if available) + if (git) { + lines.push(`> By *${git.commitAuthor}* on <${git.branchUrl}|\`${git.branchName}\`>`); + } + + // Line 2: deployment info + lines.push(`> ${shortCode} | ${version}.${environment} | ${alert.project.name} `); + + // Line 3: provider + commit link + vercel link (conditional parts) + const integrationParts: string[] = []; + if (git?.provider === "github") { + integrationParts.push(`via GitHub | <${git.commitUrl}|${git.shortSha}>`); + } + if (deploymentMeta.vercelDeploymentUrl) { + integrationParts.push(`with <${deploymentMeta.vercelDeploymentUrl}|Vercel>`); + } + if (integrationParts.length > 0) { + lines.push(`> ${integrationParts.join(" | ")} `); + } + + // Line 4: timestamp + lines.push(`> ${this.#formatTimestamp(timestamp)}`); + + return { + type: "context" as const, + elements: [ + { + type: "mrkdwn" as const, + text: lines.join("\n"), + }, + ], + }; + } + + #buildRunQuoteBlock( + taskIdentifier: string, + version: string, + environment: string, + runId: string, + projectName: string, + timestamp: Date + ) { + return { + type: "context" as const, + elements: [ + { + type: "mrkdwn" as const, + text: `> *${taskIdentifier}* | ${version}.${environment}\n> ${runId} | ${projectName}\n> ${this.#formatTimestamp(timestamp)}`, + }, + ], + }; + } + + #formatTimestamp(date: Date): string { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + }).format(date); + } + + #buildWebhookGitObject(git: GitMetaLinks | null) { + if (!git) return undefined; + + return { + branch: git.branchName, + commitSha: git.shortSha, + commitMessage: git.commitMessage, + commitUrl: git.commitUrl, + branchUrl: git.branchUrl, + pullRequestNumber: git.pullRequestNumber, + pullRequestTitle: git.pullRequestTitle, + pullRequestUrl: git.pullRequestUrl, + provider: git.provider, + }; + } + + #buildWebhookVercelObject(url: string | undefined) { + if (!url) return undefined; + + return { deploymentUrl: url }; + } + #getRunError(alert: FoundAlert): TaskRunError { if (alert.taskRun) { const res = TaskRunError.safeParse(alert.taskRun.error); diff --git a/internal-packages/emails/emails/deployment-failure.tsx b/internal-packages/emails/emails/deployment-failure.tsx index 476208360b2..c4cf363c2e7 100644 --- a/internal-packages/emails/emails/deployment-failure.tsx +++ b/internal-packages/emails/emails/deployment-failure.tsx @@ -1,18 +1,20 @@ import { Body, CodeBlock, + Column, Container, Head, Html, Link, Preview, + Row, Text, dracula, } from "@react-email/components"; import { z } from "zod"; import { Footer } from "./components/Footer"; import { Image } from "./components/Image"; -import { anchor, container, h1, main, paragraphLight } from "./components/styles"; +import { anchor, bullets, container, grey, h1, main, paragraphLight } from "./components/styles"; export const AlertDeploymentFailureEmailSchema = z.object({ email: z.literal("alert-deployment-failure"), @@ -27,6 +29,19 @@ export const AlertDeploymentFailureEmailSchema = z.object({ stack: z.string().optional(), }), deploymentLink: z.string().url(), + git: z + .object({ + branchName: z.string(), + shortSha: z.string(), + commitMessage: z.string(), + commitUrl: z.string(), + branchUrl: z.string(), + pullRequestNumber: z.number().optional(), + pullRequestTitle: z.string().optional(), + pullRequestUrl: z.string().optional(), + }) + .optional(), + vercelDeploymentUrl: z.string().url().optional(), }); const previewDefaults = { @@ -40,10 +55,31 @@ const previewDefaults = { stack: "Error: Something went wrong\n at main.ts:12:34", }, deploymentLink: "https://trigger.dev", + git: { + branchName: "feat/new-feature", + shortSha: "abc1234", + commitMessage: "Add new background task for processing uploads", + commitUrl: "https://github.com/acme/app/commit/abc1234", + branchUrl: "https://github.com/acme/app/tree/feat/new-feature", + pullRequestNumber: 42, + pullRequestTitle: "Add upload processing", + pullRequestUrl: "https://github.com/acme/app/pull/42", + }, + vercelDeploymentUrl: "https://vercel.com/acme/app/abc1234", }; export default function Email(props: z.infer) { - const { version, environment, organization, shortCode, failedAt, error, deploymentLink } = { + const { + version, + environment, + organization, + shortCode, + failedAt, + error, + deploymentLink, + git, + vercelDeploymentUrl, + } = { ...previewDefaults, ...props, }; @@ -63,6 +99,7 @@ export default function Email(props: z.infer )} + + {git && ( + <> + + Branch + + + {git.branchName} + + + + + Commit + + + {git.shortSha} + {" "} + {git.commitMessage} + + + {git.pullRequestNumber && git.pullRequestUrl && ( + + Pull Request + + + #{git.pullRequestNumber} + + {git.pullRequestTitle ? ` ${git.pullRequestTitle}` : ""} + + + )} + + )} + {vercelDeploymentUrl && ( + + Vercel + + + View Vercel Deployment + + + + )} + Trigger.dev