From d8eb28653f303cec47347a19a7171d3024b9d4b2 Mon Sep 17 00:00:00 2001 From: myftija Date: Mon, 15 Sep 2025 17:01:03 +0200 Subject: [PATCH 1/3] feat: expose project build settings --- .../route.tsx | 195 ++++++++++++++++-- .../app/services/projectSettings.server.ts | 18 ++ .../projectSettingsPresenter.server.ts | 40 +++- apps/webapp/app/v3/buildSettings.ts | 9 + .../migration.sql | 2 + .../database/prisma/schema.prisma | 2 + 6 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 apps/webapp/app/v3/buildSettings.ts create mode 100644 internal-packages/database/prisma/migrations/20250915141201_add_build_settings_to_project/migration.sql 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..8392652a59 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"; @@ -120,7 +120,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 +134,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { githubAppInstallations: gitHubApp.installations, connectedGithubRepository: gitHubApp.connectedRepository, openGitHubRepoConnectionModal, + buildSettings, }, { headers } ); @@ -155,6 +156,13 @@ const UpdateGitSettingsFormSchema = z.object({ .transform((val) => val === "on"), }); +const UpdateBuildSettingsFormSchema = z.object({ + action: z.literal("update-build-settings"), + rootDirectory: z.string().trim().optional(), + installCommand: z.string().trim().optional(), + triggerConfigFile: z.string().trim().optional(), +}); + export function createSchema( constraints: { getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; @@ -188,6 +196,7 @@ export function createSchema( }), ConnectGitHubRepoFormSchema, UpdateGitSettingsFormSchema, + UpdateBuildSettingsFormSchema, z.object({ action: z.literal("disconnect-repo"), }), @@ -376,6 +385,31 @@ export const action: ActionFunction = async ({ request, params }) => { success: true, }); } + case "update-build-settings": { + const { rootDirectory, installCommand, triggerConfigFile } = submission.value; + + const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { + rootDirectory: rootDirectory || undefined, + installCommand: installCommand || undefined, + triggerConfigFile: triggerConfigFile || 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 +423,7 @@ export default function Page() { connectedGithubRepository, githubAppEnabled, openGitHubRepoConnectionModal, + buildSettings, } = useTypedLoaderData(); const project = useProject(); const organization = useOrganization(); @@ -511,22 +546,31 @@ export default function Page() { {githubAppEnabled && ( -
- Git settings -
- {connectedGithubRepository ? ( - - ) : ( - - )} + +
+ Git settings +
+ {connectedGithubRepository ? ( + + ) : ( + + )} +
-
+ +
+ Build settings +
+ +
+
+ )}
@@ -1033,3 +1077,120 @@ function ConnectedGitHubRepoForm({ ); } + +type BuildSettings = { + rootDirectory?: string; + installCommand?: string; + triggerConfigFile?: string; +}; + +function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { + const lastSubmission = useActionData() as any; + const navigation = useNavigation(); + + const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); + const [buildSettingsValues, setBuildSettingsValues] = useState({ + rootDirectory: buildSettings?.rootDirectory || "", + installCommand: buildSettings?.installCommand || "", + triggerConfigFile: buildSettings?.triggerConfigFile || "", + }); + + useEffect(() => { + const hasChanges = + buildSettingsValues.rootDirectory !== (buildSettings?.rootDirectory || "") || + buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || + buildSettingsValues.triggerConfigFile !== (buildSettings?.triggerConfigFile || ""); + 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, + rootDirectory: e.target.value, + })); + }} + /> + The directory that contains your code. + {fields.rootDirectory.error} + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + installCommand: e.target.value, + })); + }} + /> + + Command to install your project dependencies. This is auto-detected by default. + + {fields.installCommand.error} + + + + { + setBuildSettingsValues((prev) => ({ + ...prev, + triggerConfigFile: e.target.value, + })); + }} + /> + + Path to your trigger configuration file, relative to the specified root directory. + + + {fields.triggerConfigFile.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..5ff6a22781 --- /dev/null +++ b/apps/webapp/app/v3/buildSettings.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const BuildSettingsSchema = z.object({ + rootDirectory: z.string().optional(), + installCommand: z.string().optional(), + triggerConfigFile: 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 { From fcb33223e7daf09130dd0fdd57230a705122629c Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 16 Sep 2025 14:21:31 +0200 Subject: [PATCH 2/3] Reorder fields --- .../route.tsx | 68 +++++++++---------- apps/webapp/app/v3/buildSettings.ts | 4 +- 2 files changed, 35 insertions(+), 37 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 8392652a59..e01fb1c095 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 @@ -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 { BuildSettings, BuildSettingsSchema } from "~/v3/buildSettings"; export const meta: MetaFunction = () => { return [ @@ -158,11 +159,13 @@ const UpdateGitSettingsFormSchema = z.object({ const UpdateBuildSettingsFormSchema = z.object({ action: z.literal("update-build-settings"), - rootDirectory: z.string().trim().optional(), + triggerConfigFilePath: z.string().trim().optional(), + installDirectory: z.string().trim().optional(), installCommand: z.string().trim().optional(), - triggerConfigFile: z.string().trim().optional(), }); +type UpdateBuildSettingsFormSchema = z.infer; + export function createSchema( constraints: { getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string }; @@ -386,12 +389,12 @@ export const action: ActionFunction = async ({ request, params }) => { }); } case "update-build-settings": { - const { rootDirectory, installCommand, triggerConfigFile } = submission.value; + const { installDirectory, installCommand, triggerConfigFilePath } = submission.value; const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, { - rootDirectory: rootDirectory || undefined, + installDirectory: installDirectory || undefined, installCommand: installCommand || undefined, - triggerConfigFile: triggerConfigFile || undefined, + triggerConfigFilePath: triggerConfigFilePath || undefined, }); if (resultOrFail.isErr()) { @@ -1078,28 +1081,22 @@ function ConnectedGitHubRepoForm({ ); } -type BuildSettings = { - rootDirectory?: string; - installCommand?: string; - triggerConfigFile?: string; -}; - function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) { const lastSubmission = useActionData() as any; const navigation = useNavigation(); const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false); const [buildSettingsValues, setBuildSettingsValues] = useState({ - rootDirectory: buildSettings?.rootDirectory || "", + installDirectory: buildSettings?.installDirectory || "", installCommand: buildSettings?.installCommand || "", - triggerConfigFile: buildSettings?.triggerConfigFile || "", + triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "", }); useEffect(() => { const hasChanges = - buildSettingsValues.rootDirectory !== (buildSettings?.rootDirectory || "") || + buildSettingsValues.installDirectory !== (buildSettings?.installDirectory || "") || buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") || - buildSettingsValues.triggerConfigFile !== (buildSettings?.triggerConfigFile || ""); + buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || ""); setHasBuildSettingsChanges(hasChanges); }, [buildSettingsValues, buildSettings]); @@ -1122,21 +1119,26 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
- + { setBuildSettingsValues((prev) => ({ ...prev, - rootDirectory: e.target.value, + triggerConfigFile: e.target.value, })); }} /> - The directory that contains your code. - {fields.rootDirectory.error} + + Path to your Trigger configuration file, relative to the root directory of your repo. + + + {fields.triggerConfigFilePath.error} + + - - Command to install your project dependencies. This is auto-detected by default. - + Command to install your project dependencies. Auto-detected by default. {fields.installCommand.error} - + { setBuildSettingsValues((prev) => ({ ...prev, - triggerConfigFile: e.target.value, + rootDirectory: e.target.value, })); }} /> - - Path to your trigger configuration file, relative to the specified root directory. - - - {fields.triggerConfigFile.error} + The directory where the install command is run in. Auto-detected by default. + + {fields.installDirectory.error} {buildSettingsForm.error} diff --git a/apps/webapp/app/v3/buildSettings.ts b/apps/webapp/app/v3/buildSettings.ts index 5ff6a22781..57bb2569b0 100644 --- a/apps/webapp/app/v3/buildSettings.ts +++ b/apps/webapp/app/v3/buildSettings.ts @@ -1,9 +1,9 @@ import { z } from "zod"; export const BuildSettingsSchema = z.object({ - rootDirectory: z.string().optional(), + triggerConfigFilePath: z.string().optional(), + installDirectory: z.string().optional(), installCommand: z.string().optional(), - triggerConfigFile: z.string().optional(), }); export type BuildSettings = z.infer; From 9d9ce244abaa3c890eaf032993b3f25e0f257d50 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 16 Sep 2025 14:47:06 +0200 Subject: [PATCH 3/3] Add some basic validations to the build settings schema --- .../route.tsx | 35 +++++++++++++++---- 1 file changed, 29 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 e01fb1c095..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 @@ -77,7 +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 { BuildSettings, BuildSettingsSchema } from "~/v3/buildSettings"; +import { type BuildSettings } from "~/v3/buildSettings"; export const meta: MetaFunction = () => { return [ @@ -159,9 +159,32 @@ const UpdateGitSettingsFormSchema = z.object({ const UpdateBuildSettingsFormSchema = z.object({ action: z.literal("update-build-settings"), - triggerConfigFilePath: z.string().trim().optional(), - installDirectory: z.string().trim().optional(), - installCommand: z.string().trim().optional(), + 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; @@ -1127,7 +1150,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) onChange={(e) => { setBuildSettingsValues((prev) => ({ ...prev, - triggerConfigFile: e.target.value, + triggerConfigFilePath: e.target.value, })); }} /> @@ -1164,7 +1187,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) onChange={(e) => { setBuildSettingsValues((prev) => ({ ...prev, - rootDirectory: e.target.value, + installDirectory: e.target.value, })); }} />