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 7884fcc035..d826c986d9 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 @@ -62,7 +62,7 @@ import { EnvironmentParamSchema, v3ProjectSettingsPath, } 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 { type BranchTrackingConfig } from "~/v3/github"; @@ -77,6 +77,7 @@ import { DateTime } from "~/components/primitives/DateTime"; import { TextLink } from "~/components/primitives/TextLink"; import { cn } from "~/utils/cn"; import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server"; +import { type BuildSettings } from "~/v3/buildSettings"; export const meta: MetaFunction = () => { return [ @@ -120,7 +121,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } } - const { gitHubApp } = resultOrFail.value; + const { gitHubApp, buildSettings } = resultOrFail.value; const session = await getSession(request.headers.get("Cookie")); const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true; @@ -134,6 +135,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { githubAppInstallations: gitHubApp.installations, connectedGithubRepository: gitHubApp.connectedRepository, openGitHubRepoConnectionModal, + buildSettings, }, { headers } ); @@ -155,6 +157,38 @@ const UpdateGitSettingsFormSchema = z.object({ .transform((val) => val === "on"), }); +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", + }), + installDirectory: z + .string() + .trim() + .optional() + .transform((val) => (val ? val.replace(/^\/+/, "") : val)) + .refine((val) => !val || val.length <= 255, { + message: "Install directory 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", + }), +}); + +type UpdateBuildSettingsFormSchema = z.infer; + export function createSchema( constraints: { getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; @@ -188,6 +222,7 @@ export function createSchema( }), ConnectGitHubRepoFormSchema, UpdateGitSettingsFormSchema, + UpdateBuildSettingsFormSchema, z.object({ action: z.literal("disconnect-repo"), }), @@ -376,6 +411,31 @@ export const action: ActionFunction = async ({ request, params }) => { success: true, }); } + case "update-build-settings": { + const { installDirectory, installCommand, triggerConfigFilePath } = submission.value; + + const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { + installDirectory: installDirectory || undefined, + installCommand: installCommand || undefined, + triggerConfigFilePath: triggerConfigFilePath || undefined, + }); + + 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"); @@ -389,6 +449,7 @@ export default function Page() { connectedGithubRepository, githubAppEnabled, openGitHubRepoConnectionModal, + buildSettings, } = useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); @@ -511,22 +572,31 @@ export default function Page() { {githubAppEnabled && ( -
- Git settings -
- {connectedGithubRepository ? ( - - ) : ( - - )} + +
+ Git settings +
+ {connectedGithubRepository ? ( + + ) : ( + + )} +
-
+ +
+ Build settings +
+ +
+
+ )}
@@ -1033,3 +1103,115 @@ function ConnectedGitHubRepoForm({ ); } + +function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { + const lastSubmission = useActionData() as any; + const navigation = useNavigation(); + + const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); + const [buildSettingsValues, setBuildSettingsValues] = useState({ + installDirectory: buildSettings?.installDirectory || "", + installCommand: buildSettings?.installCommand || "", + triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "", + }); + + useEffect(() => { + const hasChanges = + buildSettingsValues.installDirectory !== (buildSettings?.installDirectory || "") || + buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || + buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || ""); + 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. Auto-detected by default. + {fields.installCommand.error} + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + installDirectory: e.target.value, + })); + }} + /> + The directory where the install command is run in. Auto-detected by default. + + {fields.installDirectory.error} + + + {buildSettingsForm.error} + + Save + + } + /> +
+
+ ); +} diff --git a/apps/webapp/app/services/projectSettings.server.ts b/apps/webapp/app/services/projectSettings.server.ts index 3ff35d9435..8f5195e985 100644 --- a/apps/webapp/app/services/projectSettings.server.ts +++ b/apps/webapp/app/services/projectSettings.server.ts @@ -4,6 +4,7 @@ import { DeleteProjectService } from "~/services/deleteProject.server"; import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github"; import { checkGitHubBranchExists } from "~/services/gitHub.server"; import { errAsync, fromPromise, okAsync, ResultAsync } from "neverthrow"; +import { BuildSettings } from "~/v3/buildSettings"; export class ProjectSettingsService { #prismaClient: PrismaClient; @@ -244,6 +245,23 @@ export class ProjectSettingsService { .andThen(updateConnectedRepo); } + updateBuildSettings(projectId: string, buildSettings: BuildSettings) { + return fromPromise( + this.#prismaClient.project.update({ + where: { + id: projectId, + }, + data: { + buildSettings: buildSettings, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ); + } + verifyProjectMembership(organizationSlug: string, projectSlug: string, userId: string) { const findProject = () => fromPromise( diff --git a/apps/webapp/app/services/projectSettingsPresenter.server.ts b/apps/webapp/app/services/projectSettingsPresenter.server.ts index b4095ff809..7429648c6f 100644 --- a/apps/webapp/app/services/projectSettingsPresenter.server.ts +++ b/apps/webapp/app/services/projectSettingsPresenter.server.ts @@ -4,6 +4,7 @@ import { BranchTrackingConfigSchema } from "~/v3/github"; import { env } from "~/env.server"; import { findProjectBySlug } from "~/models/project.server"; import { err, fromPromise, ok, okAsync } from "neverthrow"; +import { BuildSettingsSchema } from "~/v3/buildSettings"; export class ProjectSettingsPresenter { #prismaClient: PrismaClient; @@ -15,16 +16,6 @@ export class ProjectSettingsPresenter { getProjectSettings(organizationSlug: string, projectSlug: string, userId: string) { const githubAppEnabled = env.GITHUB_APP_ENABLED === "1"; - if (!githubAppEnabled) { - return okAsync({ - gitHubApp: { - enabled: false, - connectedRepository: undefined, - installations: undefined, - }, - }); - } - const getProject = () => fromPromise(findProjectBySlug(organizationSlug, projectSlug, userId), (error) => ({ type: "other" as const, @@ -36,6 +27,28 @@ export class ProjectSettingsPresenter { return ok(project); }); + if (!githubAppEnabled) { + return getProject().andThen((project) => { + if (!project) { + return err({ type: "project_not_found" as const }); + } + + const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings); + const buildSettings = buildSettingsOrFailure.success + ? buildSettingsOrFailure.data + : undefined; + + return ok({ + gitHubApp: { + enabled: false, + connectedRepository: undefined, + installations: undefined, + }, + buildSettings, + }); + }); + } + const findConnectedGithubRepository = (projectId: string) => fromPromise( this.#prismaClient.connectedGithubRepository.findFirst({ @@ -119,6 +132,11 @@ export class ProjectSettingsPresenter { return getProject().andThen((project) => findConnectedGithubRepository(project.id).andThen((connectedGithubRepository) => { + const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings); + const buildSettings = buildSettingsOrFailure.success + ? buildSettingsOrFailure.data + : undefined; + if (connectedGithubRepository) { return okAsync({ gitHubApp: { @@ -128,6 +146,7 @@ export class ProjectSettingsPresenter { // a project can have only a single connected repository installations: undefined, }, + buildSettings, }); } @@ -138,6 +157,7 @@ export class ProjectSettingsPresenter { connectedRepository: undefined, installations: githubAppInstallations, }, + buildSettings, }; }); }) diff --git a/apps/webapp/app/v3/buildSettings.ts b/apps/webapp/app/v3/buildSettings.ts new file mode 100644 index 0000000000..57bb2569b0 --- /dev/null +++ b/apps/webapp/app/v3/buildSettings.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const BuildSettingsSchema = z.object({ + triggerConfigFilePath: z.string().optional(), + installDirectory: z.string().optional(), + installCommand: z.string().optional(), +}); + +export type BuildSettings = z.infer; diff --git a/internal-packages/database/prisma/migrations/20250915141201_add_build_settings_to_project/migration.sql b/internal-packages/database/prisma/migrations/20250915141201_add_build_settings_to_project/migration.sql new file mode 100644 index 0000000000..30ca3a4c9b --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250915141201_add_build_settings_to_project/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "public"."Project" ADD COLUMN "buildSettings" JSONB; + diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3a0ad80507..d15b2692d0 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -394,6 +394,8 @@ model Project { executionSnapshots TaskRunExecutionSnapshot[] waitpointTags WaitpointTag[] connectedGithubRepository ConnectedGithubRepository? + + buildSettings Json? } enum ProjectVersion {