From 7ae56e6c672a0c58f961ef8c6113ca9858c7f891 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 15:51:29 +0100 Subject: [PATCH 001/121] Initial preview migrations --- .../migration.sql | 9 +++ .../migration.sql | 2 + .../database/prisma/schema.prisma | 58 ++++++++++++------- 3 files changed, 48 insertions(+), 21 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql new file mode 100644 index 0000000000..729c3c1afd --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "RuntimeEnvironment" +ADD COLUMN "archivedAt" TIMESTAMP(3), +ADD COLUMN "branchName" TEXT, +ADD COLUMN "git" JSONB, +ADD COLUMN "parentEnvironmentId" TEXT; + +-- AddForeignKey +ALTER TABLE "RuntimeEnvironment" ADD CONSTRAINT "RuntimeEnvironment_parentEnvironmentId_fkey" FOREIGN KEY ("parentEnvironmentId") REFERENCES "RuntimeEnvironment" ("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql b/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql new file mode 100644 index 0000000000..a3beef3c1e --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_parentEnvironmentId_idx" ON "RuntimeEnvironment" ("parentEnvironmentId"); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index c67edbd173..c73761de42 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -373,13 +373,26 @@ model OrgMemberInvite { } model RuntimeEnvironment { - id String @id @default(cuid()) - slug String - apiKey String @unique + id String @id @default(cuid()) + slug String + apiKey String @unique + + /// Deprecated, was for v2 pkApiKey String @unique type RuntimeEnvironmentType @default(DEVELOPMENT) + /// Preview branches + branchName String? + parentEnvironment RuntimeEnvironment? @relation("parentEnvironment", fields: [parentEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + parentEnvironmentId String? + childEnvironments RuntimeEnvironment[] @relation("parentEnvironment") + + git Json? + + /// When set API calls will fail + archivedAt DateTime? + ///A memorable code for the environment shortcode String @@ -401,24 +414,26 @@ model RuntimeEnvironment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tunnelId String? - - endpoints Endpoint[] - jobVersions JobVersion[] - events EventRecord[] - jobRuns JobRun[] - requestDeliveries HttpSourceRequestDelivery[] - jobAliases JobAlias[] - JobQueue JobQueue[] - sources TriggerSource[] - eventDispatchers EventDispatcher[] - scheduleSources ScheduleSource[] - ExternalAccount ExternalAccount[] - httpEndpointEnvironments TriggerHttpEndpointEnvironment[] - concurrencyLimitGroups ConcurrencyLimitGroup[] - keyValueItems KeyValueItem[] - webhookEnvironments WebhookEnvironment[] - webhookRequestDeliveries WebhookRequestDelivery[] + /// Deprecated (v2) + tunnelId String? + endpoints Endpoint[] + jobVersions JobVersion[] + events EventRecord[] + jobRuns JobRun[] + requestDeliveries HttpSourceRequestDelivery[] + jobAliases JobAlias[] + JobQueue JobQueue[] + sources TriggerSource[] + eventDispatchers EventDispatcher[] + scheduleSources ScheduleSource[] + ExternalAccount ExternalAccount[] + httpEndpointEnvironments TriggerHttpEndpointEnvironment[] + concurrencyLimitGroups ConcurrencyLimitGroup[] + keyValueItems KeyValueItem[] + webhookEnvironments WebhookEnvironment[] + webhookRequestDeliveries WebhookRequestDelivery[] + + /// v3 backgroundWorkers BackgroundWorker[] backgroundWorkerTasks BackgroundWorkerTask[] taskRuns TaskRun[] @@ -445,6 +460,7 @@ model RuntimeEnvironment { @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) + @@index([parentEnvironmentId]) } enum RuntimeEnvironmentType { From b65e88ba6eed1c0cd5df20d91e58784a729300e7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 15:52:42 +0100 Subject: [PATCH 002/121] Modified the staging endpoint to create preview environments --- ...gs.$organizationId.environments.staging.ts | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts index 8483058f32..9f488355f8 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts @@ -1,9 +1,14 @@ -import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { + type RuntimeEnvironment, + type Organization, + type Project, + type RuntimeEnvironmentType, +} from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { createEnvironment } from "~/models/organization.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { marqs } from "~/v3/marqs/index.server"; import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; const ParamsSchema = z.object({ @@ -55,16 +60,33 @@ export async function action({ request, params }: ActionFunctionArgs) { let created = 0; for (const project of organization.projects) { - const stagingEnvironment = project.environments.find((env) => env.type === "STAGING"); + const stagingResult = await upsertEnvironment(organization, project, "STAGING"); + if (stagingResult.status === "created") { + created++; + } - if (!stagingEnvironment) { - const staging = await createEnvironment(organization, project, "STAGING"); - await updateEnvConcurrencyLimits({ ...staging, organization, project }); + const previewResult = await upsertEnvironment(organization, project, "PREVIEW"); + if (previewResult.status === "created") { created++; - } else { - await updateEnvConcurrencyLimits({ ...stagingEnvironment, organization, project }); } } return json({ success: true, created, total: organization.projects.length }); } + +async function upsertEnvironment( + organization: Organization, + project: Project & { environments: RuntimeEnvironment[] }, + type: RuntimeEnvironmentType +) { + const existingEnvironment = project.environments.find((env) => env.type === type); + + if (!existingEnvironment) { + const newEnvironment = await createEnvironment(organization, project, type); + await updateEnvConcurrencyLimits({ ...newEnvironment, organization, project }); + return { status: "created", environment: newEnvironment }; + } else { + await updateEnvConcurrencyLimits({ ...existingEnvironment, organization, project }); + return { status: "updated", environment: existingEnvironment }; + } +} From 99e8130b015afac85799d0fabc4571320369b264 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 16:44:56 +0100 Subject: [PATCH 003/121] Added isBranchableEnvironment to RuntimeEnvironment --- .../migration.sql | 3 +++ internal-packages/database/prisma/schema.prisma | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql b/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql new file mode 100644 index 0000000000..d241e349b3 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "RuntimeEnvironment" +ADD COLUMN "isBranchableEnvironment" BOOLEAN NOT NULL DEFAULT false; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index c73761de42..2370f7840f 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -382,11 +382,13 @@ model RuntimeEnvironment { type RuntimeEnvironmentType @default(DEVELOPMENT) - /// Preview branches - branchName String? - parentEnvironment RuntimeEnvironment? @relation("parentEnvironment", fields: [parentEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) - parentEnvironmentId String? - childEnvironments RuntimeEnvironment[] @relation("parentEnvironment") + // Preview branches + /// If true, this environment has branches and is treated differently in the dashboard/API + isBranchableEnvironment Boolean @default(false) + branchName String? + parentEnvironment RuntimeEnvironment? @relation("parentEnvironment", fields: [parentEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + parentEnvironmentId String? + childEnvironments RuntimeEnvironment[] @relation("parentEnvironment") git Json? From 9a57df2e208996dead9e276c10ee34cc27aa4e53 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 16:45:09 +0100 Subject: [PATCH 004/121] Staging = yellow Preview = orange --- apps/webapp/tailwind.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 5b41bcafd8..e3fc2c36f5 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -149,8 +149,8 @@ const pending = colors.blue[500]; const warning = colors.amber[500]; const error = colors.rose[600]; const devEnv = colors.pink[500]; -const stagingEnv = colors.amber[400]; -const previewEnv = colors.amber[400]; +const stagingEnv = colors.yellow[400]; +const previewEnv = colors.orange[400]; const prodEnv = mint[500]; /** Icon colors */ From 406ce5dcd2ff2463a8c866e0b2008acbbccb8bed Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 16:45:24 +0100 Subject: [PATCH 005/121] Changed the env sort order --- apps/webapp/app/utils/environmentSort.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/utils/environmentSort.ts b/apps/webapp/app/utils/environmentSort.ts index 4ed749bf64..00c7a33580 100644 --- a/apps/webapp/app/utils/environmentSort.ts +++ b/apps/webapp/app/utils/environmentSort.ts @@ -2,8 +2,8 @@ import { type RuntimeEnvironmentType } from "@trigger.dev/database"; const environmentSortOrder: RuntimeEnvironmentType[] = [ "DEVELOPMENT", - "PREVIEW", "STAGING", + "PREVIEW", "PRODUCTION", ]; From e1b1735eed59c76c0e6645545b5a7791b4ff1b0f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 16:45:52 +0100 Subject: [PATCH 006/121] Set isBranchableEnvironment correctly. Create preview for new projects --- apps/webapp/app/models/member.server.ts | 2 +- apps/webapp/app/models/organization.server.ts | 2 ++ apps/webapp/app/models/project.server.ts | 4 ++-- ...v1.orgs.$organizationId.environments.staging.ts | 14 ++++++++++---- apps/webapp/app/services/platform.v3.server.ts | 4 +++- apps/webapp/prisma/seed.ts | 4 +--- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index c2e05248be..766de46c99 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -174,7 +174,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit // 3. Create an environment for each project for (const project of invite.organization.projects) { - await createEnvironment(invite.organization, project, "DEVELOPMENT", member, tx); + await createEnvironment(invite.organization, project, "DEVELOPMENT", false, member, tx); } // 4. Check for other invites diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index b9cd102d79..dee41a1702 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -80,6 +80,7 @@ export async function createEnvironment( organization: Pick, project: Pick, type: RuntimeEnvironment["type"], + isBranchableEnvironment = false, member?: OrgMember, prismaClient: PrismaClientOrTransaction = prisma ) { @@ -108,6 +109,7 @@ export async function createEnvironment( }, orgMember: member ? { connect: { id: member.id } } : undefined, type, + isBranchableEnvironment, }, }); } diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 581c08ed7f..4b59dcc567 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -88,10 +88,10 @@ export async function createProject( }); // Create the dev and prod environments - await createEnvironment(organization, project, "PRODUCTION"); + await createEnvironment(organization, project, "PRODUCTION", false); for (const member of project.organization.members) { - await createEnvironment(organization, project, "DEVELOPMENT", member); + await createEnvironment(organization, project, "DEVELOPMENT", false, member); } await projectCreated(organization, project); diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts index 9f488355f8..d69edf03d3 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts @@ -60,12 +60,12 @@ export async function action({ request, params }: ActionFunctionArgs) { let created = 0; for (const project of organization.projects) { - const stagingResult = await upsertEnvironment(organization, project, "STAGING"); + const stagingResult = await upsertEnvironment(organization, project, "STAGING", false); if (stagingResult.status === "created") { created++; } - const previewResult = await upsertEnvironment(organization, project, "PREVIEW"); + const previewResult = await upsertEnvironment(organization, project, "PREVIEW", true); if (previewResult.status === "created") { created++; } @@ -77,12 +77,18 @@ export async function action({ request, params }: ActionFunctionArgs) { async function upsertEnvironment( organization: Organization, project: Project & { environments: RuntimeEnvironment[] }, - type: RuntimeEnvironmentType + type: RuntimeEnvironmentType, + isBranchableEnvironment: boolean ) { const existingEnvironment = project.environments.find((env) => env.type === type); if (!existingEnvironment) { - const newEnvironment = await createEnvironment(organization, project, type); + const newEnvironment = await createEnvironment( + organization, + project, + type, + isBranchableEnvironment + ); await updateEnvConcurrencyLimits({ ...newEnvironment, organization, project }); return { status: "created", environment: newEnvironment }; } else { diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index c4638fbff1..e74fbe3b58 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -346,13 +346,15 @@ export async function getEntitlement(organizationId: string) { } export async function projectCreated(organization: Organization, project: Project) { - if (project.version === "V2" || !isCloud()) { + if (!isCloud()) { await createEnvironment(organization, project, "STAGING"); + await createEnvironment(organization, project, "PREVIEW", true); } else { //staging is only available on certain plans const plan = await getCurrentPlan(organization.id); if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) { await createEnvironment(organization, project, "STAGING"); + await createEnvironment(organization, project, "PREVIEW", true); } } } diff --git a/apps/webapp/prisma/seed.ts b/apps/webapp/prisma/seed.ts index f9e418f25e..2e42c6772b 100644 --- a/apps/webapp/prisma/seed.ts +++ b/apps/webapp/prisma/seed.ts @@ -1,5 +1,3 @@ -/* eslint-disable turbo/no-undeclared-env-vars */ - import { seedCloud } from "./seedCloud"; import { prisma } from "../app/db.server"; import { createEnvironment } from "~/models/organization.server"; @@ -48,7 +46,7 @@ async function runStagingEnvironmentMigration() { `Creating staging environment for project ${project.slug} on org ${project.organization.slug}` ); - await createEnvironment(project.organization, project, "STAGING", undefined, tx); + await createEnvironment(project.organization, project, "STAGING", false, undefined, tx); } catch (error) { console.error(error); } From 7a04b97474b011f386ef47b67fcef7e483a01b66 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 17:11:40 +0100 Subject: [PATCH 007/121] Very basic branch menu --- .../navigation/EnvironmentSelector.tsx | 154 ++++++++++++++++-- .../app/hooks/useEnvironmentSwitcher.ts | 6 +- .../OrganizationsPresenter.server.ts | 20 ++- .../_app.orgs.$organizationSlug/route.tsx | 1 - 4 files changed, 164 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index a56d0ec8ab..744c3585b2 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -1,10 +1,10 @@ import { useNavigation } from "@remix-run/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; -import { v3BillingPath } from "~/utils/pathBuilder"; +import { newOrganizationPath, v3BillingPath, v3EnvironmentPath } from "~/utils/pathBuilder"; import { EnvironmentCombo } from "../environments/EnvironmentLabel"; import { Popover, @@ -12,8 +12,13 @@ import { PopoverContent, PopoverMenuItem, PopoverSectionHeader, + PopoverTrigger, } from "../primitives/Popover"; import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; +import { ButtonContent } from "../primitives/Buttons"; +import { ChevronRightIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { GitBranchIcon } from "lucide-react"; +import { Paragraph } from "../primitives/Paragraph"; export function EnvironmentSelector({ organization, @@ -53,14 +58,31 @@ export function EnvironmentSelector({ style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} >
- {project.environments.map((env) => ( - } - isSelected={env.id === environment.id} - /> - ))} + {project.environments.map((env) => { + switch (env.isBranchableEnvironment) { + case true: { + const branchEnvironments = project.environments.filter( + (e) => e.parentEnvironmentId === env.id + ); + return ( + + ); + } + case false: + return ( + } + isSelected={env.id === environment.id} + /> + ); + } + })}
{!hasStaging && isManagedCloud && ( <> @@ -80,6 +102,20 @@ export function EnvironmentSelector({ } isSelected={false} /> + + + Upgrade + + } + isSelected={false} + /> )} @@ -87,3 +123,101 @@ export function EnvironmentSelector({ ); } + +function Branches({ + parentEnvironment, + branchEnvironments, + currentEnvironment, +}: { + parentEnvironment: SideMenuEnvironment; + branchEnvironments: SideMenuEnvironment[]; + currentEnvironment: SideMenuEnvironment; +}) { + const { urlForEnvironment } = useEnvironmentSwitcher(); + const navigation = useNavigation(); + const [isMenuOpen, setMenuOpen] = useState(false); + const timeoutRef = useRef(null); + + // Clear timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + useEffect(() => { + setMenuOpen(false); + }, [navigation.location?.pathname]); + + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + setMenuOpen(true); + }; + + const handleMouseLeave = () => { + // Small delay before closing to allow moving to the content + timeoutRef.current = setTimeout(() => { + setMenuOpen(false); + }, 150); + }; + + return ( + setMenuOpen(open)} open={isMenuOpen}> +
+ + + + + + + {branchEnvironments.length > 0 ? ( +
+ {branchEnvironments.map((env) => ( + } + leadingIconClassName="text-text-dimmed" + isSelected={env.id === currentEnvironment.id} + /> + ))} +
+ ) : ( +
+ No branches found +
+ )} +
+ +
+
+
+
+ ); +} diff --git a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts index 9c0d02a7b6..64c7cf9ebe 100644 --- a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts +++ b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts @@ -1,7 +1,5 @@ import { type Path, useMatches } from "@remix-run/react"; -import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server"; -import { useEnvironment } from "./useEnvironment"; -import { useEnvironments } from "./useEnvironments"; +import { type RuntimeEnvironment } from "@trigger.dev/database"; import { useOptimisticLocation } from "./useOptimisticLocation"; /** @@ -12,7 +10,7 @@ export function useEnvironmentSwitcher() { const matches = useMatches(); const location = useOptimisticLocation(); - const urlForEnvironment = (newEnvironment: MinimumEnvironment) => { + const urlForEnvironment = (newEnvironment: Pick) => { return routeForEnvironmentSwitch({ location, matchId: matches[matches.length - 1].id, diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 415a371e84..fca69abe6c 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -1,4 +1,4 @@ -import { type PrismaClient } from "@trigger.dev/database"; +import { RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database"; import { redirect } from "remix-typedjson"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; @@ -76,6 +76,9 @@ export class OrganizationsPresenter { type: true, slug: true, paused: true, + isBranchableEnvironment: true, + branchName: true, + parentEnvironmentId: true, orgMember: { select: { userId: true, @@ -172,7 +175,20 @@ export class OrganizationsPresenter { user: UserFromSession; projectId: string; environmentSlug: string | undefined; - environments: MinimumEnvironment[]; + environments: (Pick< + RuntimeEnvironment, + | "id" + | "slug" + | "type" + | "branchName" + | "paused" + | "parentEnvironmentId" + | "isBranchableEnvironment" + > & { + orgMember: null | { + userId: string | undefined; + }; + })[]; }) { if (environmentSlug) { const env = environments.find((e) => e.slug === environmentSlug); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 5a32667a26..8a22bdefc9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -12,7 +12,6 @@ import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; import { organizationPath } from "~/utils/pathBuilder"; import { isEnvironmentPauseResumeFormSubmission } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route"; -import { logger } from "~/services/logger.server"; const ParamsSchema = z.object({ organizationSlug: z.string(), From 7de28ce8174fec4c16f6287593b1e3327349e628 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 18:36:11 +0100 Subject: [PATCH 008/121] Creating branches from the dashboard --- .../environments/EnvironmentLabel.tsx | 13 +- .../navigation/EnvironmentSelector.tsx | 151 +++++++++++++----- apps/webapp/app/models/member.server.ts | 9 +- apps/webapp/app/models/organization.server.ts | 67 +++++++- apps/webapp/app/models/project.server.ts | 15 +- ...gs.$organizationId.environments.staging.ts | 6 +- .../app/routes/resources.branches.new.ts | 74 +++++++++ .../webapp/app/services/platform.v3.server.ts | 18 ++- apps/webapp/package.json | 9 +- apps/webapp/prisma/seed.ts | 9 +- pnpm-lock.yaml | 8 + 11 files changed, 317 insertions(+), 62 deletions(-) create mode 100644 apps/webapp/app/routes/resources.branches.new.ts diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 9d3d3bb8b0..3ba973482d 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -1,3 +1,4 @@ +import { GitBranchIcon } from "lucide-react"; import { DeployedEnvironmentIconSmall, DevEnvironmentIconSmall, @@ -6,7 +7,7 @@ import { import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server"; import { cn } from "~/utils/cn"; -type Environment = Pick; +type Environment = Pick & { branchName?: string | null }; export function EnvironmentIcon({ environment, @@ -15,6 +16,10 @@ export function EnvironmentIcon({ environment: Environment; className?: string; }) { + if (environment.branchName) { + return ; + } + switch (environment.type) { case "DEVELOPMENT": return ( @@ -60,12 +65,16 @@ export function EnvironmentLabel({ }) { return ( - {environmentFullTitle(environment)} + {environment.branchName ? environment.branchName : environmentFullTitle(environment)} ); } export function environmentTitle(environment: Environment, username?: string) { + if (environment.branchName) { + return environment.branchName; + } + switch (environment.type) { case "PRODUCTION": return "Prod"; diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 744c3585b2..eac1e66639 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -1,11 +1,26 @@ -import { useNavigation } from "@remix-run/react"; +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { ChevronRightIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useActionData, useNavigation } from "@remix-run/react"; +import { GitBranchIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { z } from "zod"; import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; -import { newOrganizationPath, v3BillingPath, v3EnvironmentPath } from "~/utils/pathBuilder"; +import { v3BillingPath } from "~/utils/pathBuilder"; import { EnvironmentCombo } from "../environments/EnvironmentLabel"; +import { Button, ButtonContent } from "../primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog"; +import { Fieldset } from "../primitives/Fieldset"; +import { FormButtons } from "../primitives/FormButtons"; +import { FormError } from "../primitives/FormError"; +import { Input } from "../primitives/Input"; +import { InputGroup } from "../primitives/InputGroup"; +import { Label } from "../primitives/Label"; +import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverArrowTrigger, @@ -15,10 +30,7 @@ import { PopoverTrigger, } from "../primitives/Popover"; import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; -import { ButtonContent } from "../primitives/Buttons"; -import { ChevronRightIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { GitBranchIcon } from "lucide-react"; -import { Paragraph } from "../primitives/Paragraph"; +import { schema } from "~/routes/resources.branches.new"; export function EnvironmentSelector({ organization, @@ -58,31 +70,36 @@ export function EnvironmentSelector({ style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} >
- {project.environments.map((env) => { - switch (env.isBranchableEnvironment) { - case true: { - const branchEnvironments = project.environments.filter( - (e) => e.parentEnvironmentId === env.id - ); - return ( - - ); + {project.environments + .filter((env) => env.branchName === null) + .map((env) => { + switch (env.isBranchableEnvironment) { + case true: { + const branchEnvironments = project.environments.filter( + (e) => e.parentEnvironmentId === env.id + ); + return ( + + ); + } + case false: + return ( + + } + isSelected={env.id === environment.id} + /> + ); } - case false: - return ( - } - isSelected={env.id === environment.id} - /> - ); - } - })} + })}
{!hasStaging && isManagedCloud && ( <> @@ -205,19 +222,79 @@ function Branches({ ) : (
- No branches found + + No branches found +
)}
- + + + + + + + +
); } + +function NewBranchPanel({ parentEnvironment }: { parentEnvironment: SideMenuEnvironment }) { + const lastSubmission = useActionData(); + + const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({ + id: "accept-invite", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onInput", + }); + + return ( + <> + New branch +
+
+
+ + + + + + {branchName.error} + + {form.error} + + Create branch + + } + cancelButton={ + + + + } + /> +
+
+
+ + ); +} diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 766de46c99..86ae5d371d 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -174,7 +174,14 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit // 3. Create an environment for each project for (const project of invite.organization.projects) { - await createEnvironment(invite.organization, project, "DEVELOPMENT", false, member, tx); + await createEnvironment({ + organization: invite.organization, + project, + type: "DEVELOPMENT", + isBranchableEnvironment: false, + member, + prismaClient: tx, + }); } // 4. Check for other invites diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index dee41a1702..8a7a31441a 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -1,3 +1,4 @@ +import slugify from "slugify"; import type { Organization, OrgMember, @@ -7,7 +8,7 @@ import type { } from "@trigger.dev/database"; import { customAlphabet } from "nanoid"; import slug from "slug"; -import { prisma, PrismaClientOrTransaction } from "~/db.server"; +import { prisma, type PrismaClientOrTransaction } from "~/db.server"; import { generate } from "random-words"; import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; import { env } from "~/env.server"; @@ -76,14 +77,21 @@ export async function createOrganization( return { ...organization }; } -export async function createEnvironment( - organization: Pick, - project: Pick, - type: RuntimeEnvironment["type"], +export async function createEnvironment({ + organization, + project, + type, isBranchableEnvironment = false, - member?: OrgMember, - prismaClient: PrismaClientOrTransaction = prisma -) { + member, + prismaClient = prisma, +}: { + organization: Pick; + project: Pick; + type: RuntimeEnvironment["type"]; + isBranchableEnvironment?: boolean; + member?: OrgMember; + prismaClient?: PrismaClientOrTransaction; +}) { const slug = envSlug(type); const apiKey = createApiKeyForEnv(type); const pkApiKey = createPkApiKeyForEnv(type); @@ -114,6 +122,49 @@ export async function createEnvironment( }); } +export function createBranchEnvironment({ + organization, + project, + parentEnvironment, + branchName, +}: { + organization: Pick; + project: Pick; + parentEnvironment: RuntimeEnvironment; + branchName: string; +}) { + const slug = slugify(`${parentEnvironment.slug}-${branchName}`, { + lower: true, + strict: true, + }); + const apiKey = createApiKeyForEnv(parentEnvironment.type); + const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); + const shortcode = createShortcode().join("-"); + + return prisma.runtimeEnvironment.create({ + data: { + slug, + apiKey, + pkApiKey, + shortcode, + maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit, + organization: { + connect: { + id: organization.id, + }, + }, + project: { + connect: { id: project.id }, + }, + branchName, + type: parentEnvironment.type, + parentEnvironment: { + connect: { id: parentEnvironment.id }, + }, + }, + }); +} + function createShortcode() { return generate({ exactly: 2 }); } diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 4b59dcc567..3e4dc2cf8d 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -88,10 +88,21 @@ export async function createProject( }); // Create the dev and prod environments - await createEnvironment(organization, project, "PRODUCTION", false); + await createEnvironment({ + organization, + project, + type: "PRODUCTION", + isBranchableEnvironment: false, + }); for (const member of project.organization.members) { - await createEnvironment(organization, project, "DEVELOPMENT", false, member); + await createEnvironment({ + organization, + project, + type: "DEVELOPMENT", + isBranchableEnvironment: false, + member, + }); } await projectCreated(organization, project); diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts index d69edf03d3..6a8628f752 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts @@ -83,12 +83,12 @@ async function upsertEnvironment( const existingEnvironment = project.environments.find((env) => env.type === type); if (!existingEnvironment) { - const newEnvironment = await createEnvironment( + const newEnvironment = await createEnvironment({ organization, project, type, - isBranchableEnvironment - ); + isBranchableEnvironment, + }); await updateEnvConcurrencyLimits({ ...newEnvironment, organization, project }); return { status: "created", environment: newEnvironment }; } else { diff --git a/apps/webapp/app/routes/resources.branches.new.ts b/apps/webapp/app/routes/resources.branches.new.ts new file mode 100644 index 0000000000..f2d3f78ab9 --- /dev/null +++ b/apps/webapp/app/routes/resources.branches.new.ts @@ -0,0 +1,74 @@ +import { parse } from "@conform-to/zod"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { type PlainClient, uiComponent } from "@team-plain/typescript-sdk"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { createBranchEnvironment } from "~/models/organization.server"; +import { requireUser } from "~/services/session.server"; +import { v3EnvironmentPath } from "~/utils/pathBuilder"; +import { sendToPlain } from "~/utils/plain.server"; + +export const schema = z.object({ + parentEnvironmentId: z.string(), + branchName: z.string().min(1), + failurePath: z.string(), +}); + +export async function action({ request }: ActionFunctionArgs) { + const user = await requireUser(request); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return redirectWithErrorMessage("/", request, "Invalid form data"); + } + + try { + const parentEnvironment = await prisma.runtimeEnvironment.findFirstOrThrow({ + where: { + id: submission.value.parentEnvironmentId, + organization: { + members: { + some: { + userId: user.id, + }, + }, + }, + }, + include: { + organization: true, + project: true, + }, + }); + + if (!parentEnvironment.isBranchableEnvironment) { + return redirectWithErrorMessage( + submission.value.failurePath, + request, + "Parent environment is not branchable" + ); + } + + const branch = await createBranchEnvironment({ + organization: parentEnvironment.organization, + project: parentEnvironment.project, + parentEnvironment, + branchName: submission.value.branchName, + }); + + return redirectWithSuccessMessage( + v3EnvironmentPath(parentEnvironment.organization, parentEnvironment.project, branch), + request, + "Thanks for your feedback! We'll get back to you soon." + ); + } catch (e) { + submission.error.message = e instanceof Error ? e.message : "Unknown error"; + return redirectWithErrorMessage( + submission.value.failurePath, + request, + "Failed to create branch" + ); + } +} diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index e74fbe3b58..f12270c1e6 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -347,14 +347,24 @@ export async function getEntitlement(organizationId: string) { export async function projectCreated(organization: Organization, project: Project) { if (!isCloud()) { - await createEnvironment(organization, project, "STAGING"); - await createEnvironment(organization, project, "PREVIEW", true); + await createEnvironment({ organization, project, type: "STAGING" }); + await createEnvironment({ + organization, + project, + type: "PREVIEW", + isBranchableEnvironment: true, + }); } else { //staging is only available on certain plans const plan = await getCurrentPlan(organization.id); if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) { - await createEnvironment(organization, project, "STAGING"); - await createEnvironment(organization, project, "PREVIEW", true); + await createEnvironment({ organization, project, type: "STAGING" }); + await createEnvironment({ + organization, + project, + type: "PREVIEW", + isBranchableEnvironment: true, + }); } } } diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 97d743f879..a6c7c830eb 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -45,18 +45,18 @@ "@codemirror/view": "^6.5.0", "@conform-to/react": "^0.6.1", "@conform-to/zod": "^0.6.1", - "@depot/sdk-node": "^1.0.0", "@depot/cli": "0.0.1-cli.2.80.0", + "@depot/sdk-node": "^1.0.0", "@electric-sql/react": "^0.3.5", "@headlessui/react": "^1.7.8", "@heroicons/react": "^2.0.12", + "@internal/redis": "workspace:*", "@internal/run-engine": "workspace:*", "@internal/zod-worker": "workspace:*", - "@internal/redis": "workspace:*", - "@trigger.dev/redis-worker": "workspace:*", "@internationalized/date": "^3.5.1", "@lezer/highlight": "^1.1.6", "@opentelemetry/api": "1.9.0", + "@opentelemetry/api-logs": "0.52.1", "@opentelemetry/core": "1.25.1", "@opentelemetry/exporter-logs-otlp-http": "0.52.1", "@opentelemetry/exporter-trace-otlp-http": "0.52.1", @@ -69,7 +69,6 @@ "@opentelemetry/sdk-trace-base": "1.25.1", "@opentelemetry/sdk-trace-node": "1.25.1", "@opentelemetry/semantic-conventions": "1.25.1", - "@opentelemetry/api-logs": "0.52.1", "@popperjs/core": "^2.11.8", "@prisma/instrumentation": "^5.11.0", "@radix-ui/react-alert-dialog": "^1.0.4", @@ -104,6 +103,7 @@ "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", "@trigger.dev/platform": "1.0.14", + "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", "@uiw/react-codemirror": "^4.19.5", @@ -174,6 +174,7 @@ "simple-oauth2": "^5.0.0", "simplur": "^3.0.1", "slug": "^6.0.0", + "slugify": "^1.6.6", "socket.io": "4.7.4", "socket.io-adapter": "^2.5.4", "sonner": "^1.0.3", diff --git a/apps/webapp/prisma/seed.ts b/apps/webapp/prisma/seed.ts index 2e42c6772b..009f9278b5 100644 --- a/apps/webapp/prisma/seed.ts +++ b/apps/webapp/prisma/seed.ts @@ -46,7 +46,14 @@ async function runStagingEnvironmentMigration() { `Creating staging environment for project ${project.slug} on org ${project.organization.slug}` ); - await createEnvironment(project.organization, project, "STAGING", false, undefined, tx); + await createEnvironment({ + organization: project.organization, + project, + type: "STAGING", + isBranchableEnvironment: false, + member: undefined, + prismaClient: tx, + }); } catch (error) { console.error(error); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b91607b7e..bf0ca2d9f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,9 @@ importers: slug: specifier: ^6.0.0 version: 6.1.0 + slugify: + specifier: ^1.6.6 + version: 1.6.6 socket.io: specifier: 4.7.4 version: 4.7.4 @@ -32300,6 +32303,11 @@ packages: resolution: {integrity: sha512-x6vLHCMasg4DR2LPiyFGI0gJJhywY6DTiGhCrOMzb3SOk/0JVLIaL4UhyFSHu04SD3uAavrKY/K3zZ3i6iRcgA==} dev: false + /slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + dev: false + /smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} From 18941bf5afb7dbe825e526e3b0a7bae09294f27f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 11 May 2025 18:43:39 +0100 Subject: [PATCH 009/121] Fix for string icons on project delete page --- .../route.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 24d9cbe670..db6f641f5d 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,6 +1,6 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { FolderIcon } from "@heroicons/react/20/solid"; +import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; import { type ActionFunction, json } from "@remix-run/server-runtime"; import { z } from "zod"; @@ -272,7 +272,7 @@ export default function Page() { {projectSlug.error} {deleteForm.error} @@ -287,7 +287,7 @@ export default function Page() { } + defaultValue="help" + /> + ) + } + > + + You've reached the limit ({limits.used}/{limits.limit}) of branches for your plan. Upgrade + to get more branches. + + + ); + } + + return ( + + + + + + + + + } + > + + Branches are a way to test new features in isolation before merging them into the main + environment. + + + ); +} + function SwitcherPanel() { const organization = useOrganization(); const project = useProject(); diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 3ba973482d..0cf4ae3807 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -1,5 +1,6 @@ import { GitBranchIcon } from "lucide-react"; import { + BranchEnvironmentIconSmall, DeployedEnvironmentIconSmall, DevEnvironmentIconSmall, ProdEnvironmentIconSmall, @@ -17,7 +18,11 @@ export function EnvironmentIcon({ className?: string; }) { if (environment.branchName) { - return ; + return ( + + ); } switch (environment.type) { diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index eac1e66639..c5fc57185c 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -1,6 +1,6 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { ChevronRightIcon, PlusIcon } from "@heroicons/react/20/solid"; +import { ChevronRightIcon, Cog8ToothIcon, PlusIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useActionData, useNavigation } from "@remix-run/react"; import { GitBranchIcon } from "lucide-react"; @@ -8,9 +8,9 @@ import { useEffect, useRef, useState } from "react"; import { z } from "zod"; import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; import { useFeatures } from "~/hooks/useFeatures"; -import { type MatchedOrganization } from "~/hooks/useOrganizations"; +import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; -import { v3BillingPath } from "~/utils/pathBuilder"; +import { branchesPath, v3BillingPath } from "~/utils/pathBuilder"; import { EnvironmentCombo } from "../environments/EnvironmentLabel"; import { Button, ButtonContent } from "../primitives/Buttons"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog"; @@ -31,6 +31,9 @@ import { } from "../primitives/Popover"; import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; import { schema } from "~/routes/resources.branches.new"; +import { useProject } from "~/hooks/useProject"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; export function EnvironmentSelector({ organization, @@ -150,6 +153,9 @@ function Branches({ branchEnvironments: SideMenuEnvironment[]; currentEnvironment: SideMenuEnvironment; }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); const { urlForEnvironment } = useEnvironmentSwitcher(); const navigation = useNavigation(); const [isMenuOpen, setMenuOpen] = useState(false); @@ -213,9 +219,8 @@ function Branches({ } - leadingIconClassName="text-text-dimmed" + title={{env.branchName}} + icon={} isSelected={env.id === currentEnvironment.id} /> ))} @@ -228,73 +233,15 @@ function Branches({ )}
- - - - - - - - + } + leadingIconClassName="text-text-dimmed" + />
); } - -function NewBranchPanel({ parentEnvironment }: { parentEnvironment: SideMenuEnvironment }) { - const lastSubmission = useActionData(); - - const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({ - id: "accept-invite", - lastSubmission: lastSubmission as any, - onValidate({ formData }) { - return parse(formData, { schema }); - }, - shouldRevalidate: "onInput", - }); - - return ( - <> - New branch -
-
-
- - - - - - {branchName.error} - - {form.error} - - Create branch - - } - cancelButton={ - - - - } - /> -
-
-
- - ); -} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 4dab36078e..44ead52393 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -38,6 +38,7 @@ import { cn } from "~/utils/cn"; import { accountPath, adminPath, + branchesPath, logoutPath, newOrganizationPath, newProjectPath, @@ -84,6 +85,7 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -288,6 +290,13 @@ export function SideMenu({ to={v3ProjectAlertsPath(organization, project, environment)} data-action="alerts" /> + {title} diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 8a7a31441a..e0bd6cd998 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -1,4 +1,3 @@ -import slugify from "slugify"; import type { Organization, OrgMember, @@ -13,6 +12,7 @@ import { generate } from "random-words"; import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; import { env } from "~/env.server"; import { featuresForUrl } from "~/features.server"; +import { BranchGit } from "~/presenters/v3/BranchesPresenter.server"; export type { Organization }; @@ -127,23 +127,22 @@ export function createBranchEnvironment({ project, parentEnvironment, branchName, + git, }: { organization: Pick; project: Pick; parentEnvironment: RuntimeEnvironment; branchName: string; + git?: BranchGit; }) { - const slug = slugify(`${parentEnvironment.slug}-${branchName}`, { - lower: true, - strict: true, - }); + const branchSlug = `${slug(`${parentEnvironment.slug}-${branchName}`)}-${nanoid(4)}`; const apiKey = createApiKeyForEnv(parentEnvironment.type); const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); const shortcode = createShortcode().join("-"); return prisma.runtimeEnvironment.create({ data: { - slug, + slug: branchSlug, apiKey, pkApiKey, shortcode, @@ -161,6 +160,7 @@ export function createBranchEnvironment({ parentEnvironment: { connect: { id: parentEnvironment.id }, }, + git: git ?? undefined, }, }); } diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts new file mode 100644 index 0000000000..eb51894757 --- /dev/null +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; +import { type PrismaClient, prisma } from "~/db.server"; +import { type Project } from "~/models/project.server"; +import { type User } from "~/models/user.server"; +import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; +import { getLimit } from "~/services/platform.v3.server"; + +type Result = Awaited>; +export type Branch = Result["branches"][number]; + +const BRANCHES_PER_PAGE = 10; + +type Options = z.infer; + +//TODO filter by branch name + +export const BranchGit = z + .object({ + repo: z.string(), + pr: z.string().optional(), + branch: z.string().optional(), + commit: z.string().optional(), + }) + .nullable(); + +export type BranchGit = z.infer; + +export class BranchesPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + showArchived = false, + search, + page = 1, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + } & Options) { + const project = await this.#prismaClient.project.findFirst({ + select: { + id: true, + organizationId: true, + }, + where: { + slug: projectSlug, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }); + + if (!project) { + throw new Error("Project not found"); + } + + const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({ + select: { + id: true, + }, + where: { + projectId: project.id, + isBranchableEnvironment: true, + }, + }); + + const hasFilters = !!showArchived || (search !== undefined && search !== ""); + + if (!branchableEnvironment) { + return { + branchableEnvironment: null, + currentPage: page, + totalPages: 0, + totalCount: 0, + branches: [], + hasFilters: false, + limits: { + used: 0, + limit: 0, + }, + }; + } + + const visibleCount = await this.#prismaClient.runtimeEnvironment.count({ + where: { + projectId: project.id, + branchName: { + not: null, + }, + ...(showArchived ? {} : { archivedAt: null }), + }, + }); + + // Limits + // We limit the number of active branches + const used = await this.#prismaClient.runtimeEnvironment.count({ + where: { + projectId: project.id, + branchName: { + not: null, + }, + archivedAt: null, + }, + }); + const limit = await getLimit(project.organizationId, "branches", 50); + + const branches = await this.#prismaClient.runtimeEnvironment.findMany({ + select: { + id: true, + slug: true, + branchName: true, + type: true, + archivedAt: true, + createdAt: true, + git: true, + }, + where: { + projectId: project.id, + branchName: { + not: null, + }, + ...(showArchived ? {} : { archivedAt: null }), + }, + skip: (page - 1) * BRANCHES_PER_PAGE, + take: BRANCHES_PER_PAGE, + }); + + const sortedBranches = branches.sort((a, b) => a.branchName!.localeCompare(b.branchName!)); + + return { + branchableEnvironment, + currentPage: page, + totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE), + totalCount: visibleCount, + branches: sortedBranches.map((branch) => ({ + ...branch, + git: BranchGit.parse(branch.git), + })), + hasFilters, + limits: { + used, + limit, + }, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index f03121ae83..2053fab532 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -71,6 +71,8 @@ export class EnvironmentVariablesPresenter { select: { id: true, type: true, + isBranchableEnvironment: true, + branchName: true, orgMember: { select: { userId: true, @@ -120,6 +122,8 @@ export class EnvironmentVariablesPresenter { environments: sortedEnvironments.map((environment) => ({ id: environment.id, type: environment.type, + isBranchableEnvironment: environment.isBranchableEnvironment, + branchName: environment.branchName, })), hasStaging: environments.some((environment) => environment.type === "STAGING"), }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx new file mode 100644 index 0000000000..d13a249fe6 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -0,0 +1,495 @@ +import { + ArchiveBoxIcon, + ArrowRightIcon, + ArrowUpCircleIcon, + CheckIcon, + MagnifyingGlassIcon, + PlusIcon, +} from "@heroicons/react/20/solid"; +import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { useLocation, useNavigate, useSearchParams } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { useCallback } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels"; +import { Feedback } from "~/components/Feedback"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { DateTime } from "~/components/primitives/DateTime"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "~/components/primitives/Dialog"; +import { Header3 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +import * as Property from "~/components/primitives/PropertyTable"; +import { Switch } from "~/components/primitives/Switch"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { usePathName } from "~/hooks/usePathName"; +import { useProject } from "~/hooks/useProject"; +import { useThrottle } from "~/hooks/useThrottle"; +import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { + docsPath, + ProjectParamSchema, + v3BillingPath, + v3EnvironmentPath, +} from "~/utils/pathBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { NewBranchPanel } from "../resources.branches.new"; +import { ArchiveIcon, UnarchiveIcon } from "~/assets/icons/ArchiveIcon"; + +export const BranchesOptions = z.object({ + search: z.string().optional(), + showArchived: z.preprocess((val) => val === "true" || val === true, z.boolean()).optional(), + page: z.number().optional(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam } = ProjectParamSchema.parse(params); + + const searchParams = new URL(request.url).searchParams; + const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams)); + const options = parsedSearchParams.success ? parsedSearchParams.data : {}; + + try { + const presenter = new BranchesPresenter(); + const result = await presenter.call({ + userId, + projectSlug: projectParam, + ...options, + }); + + return typedjson(result); + } catch (error) { + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export default function Page() { + const { + branchableEnvironment, + branches, + hasFilters, + limits, + currentPage, + totalPages, + totalCount, + } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const location = useLocation(); + const pathName = usePathName(); + + const plan = useCurrentPlan(); + const requiresUpgrade = + plan?.v3Subscription?.plan && + limits.used >= plan.v3Subscription.plan.limits.branches.number && + !plan.v3Subscription.plan.limits.branches.canExceed; + const canUpgrade = + plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.branches.canExceed; + + const isAtLimit = limits.used >= limits.limit; + + if (!branchableEnvironment) { + return ( + + + + + + + + + + + ); + } + + return ( + + + + + + + {branches.map((branch) => ( + + {branch.branchName} + {branch.id} + + ))} + + + + + Branches docs + + + {isAtLimit ? ( + + ) : ( + + + + + + + + + )} + + + +
+ {totalCount === 0 && !hasFilters ? ( + + + + ) : ( + <> +
+ +
+ +
+
+ +
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" + )} + > + + + + Branch + Created + Git branch + Git PR + Archived + + Actions + + + + + {branches.length === 0 ? ( + + There are no matches for your filters + + ) : ( + branches.map((branch) => { + const path = v3EnvironmentPath(organization, project, branch); + const cellClass = branch.archivedAt ? "opacity-50" : ""; + + return ( + + +
+ + +
+
+ + + + + {branch.git?.branch ? ( + + ) : ( + "–" + )} + + + {branch.git?.pr ? : "–"} + + + {branch.archivedAt ? ( + + ) : ( + "–" + )} + + } + popoverContent={ + <> + + {branch.archivedAt ? ( + <> + {isAtLimit ? ( + + ) : ( + + )} + + ) : ( + + + + + + )} + + } + /> +
+ ); + }) + )} +
+
+
1 && "justify-end border-t border-grid-dimmed px-2 py-3" + )} + > + +
+
+ +
+
+ + + + + +
+ } + content={`${Math.round((limits.used / limits.limit) * 100)}%`} + /> +
+ {requiresUpgrade ? ( + + You've used all {limits.limit} of your branches. Archive one or upgrade your + plan to enable more. + + ) : ( +
+ + You've used {limits.used}/{limits.limit} of your branches + + +
+ )} + + {canUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} +
+
+
+ + )} + +
+
+ ); +} + +export function BranchFilters() { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const { search, showArchived, page } = BranchesOptions.parse( + Object.fromEntries(searchParams.entries()) + ); + + const handleFilterChange = useCallback((filterType: string, value: string | undefined) => { + setSearchParams((s) => { + if (value) { + searchParams.set(filterType, value); + } else { + searchParams.delete(filterType); + } + searchParams.delete("page"); + return searchParams; + }); + }, []); + + const handleArchivedChange = useCallback((checked: boolean) => { + handleFilterChange("showArchived", checked ? "true" : undefined); + }, []); + + const handleSearchChange = useThrottle((value: string) => { + handleFilterChange("search", value.length === 0 ? undefined : value); + }, 300); + + return ( +
+ handleSearchChange(e.target.value)} + /> + + +
+ ); +} + +function UpgradePanel({ + limits, + canUpgrade, +}: { + limits: { + used: number; + limit: number; + }; + canUpgrade: boolean; +}) { + const organization = useOrganization(); + + return ( + + + + + + You've exceeded your limit + + You've used {limits.used}/{limits.limit} of your branches. + + + {canUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + + + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index cec4c570cc..7f04d9ba5d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -180,6 +180,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { export default function Page() { const [isOpen, setIsOpen] = useState(false); const { environments, hasStaging } = useTypedLoaderData(); + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState([]); const lastSubmission = useActionData(); const navigation = useNavigation(); const navigate = useNavigate(); @@ -221,17 +222,19 @@ export default function Page() {
- {environments.map((environment) => ( - } - variant="button" - /> - ))} + {environments + .filter((env) => !env.branchName) + .map((environment) => ( + } + variant="button" + /> + ))} {!hasStaging && ( diff --git a/apps/webapp/app/routes/resources.branches.new.ts b/apps/webapp/app/routes/resources.branches.new.ts deleted file mode 100644 index f2d3f78ab9..0000000000 --- a/apps/webapp/app/routes/resources.branches.new.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { parse } from "@conform-to/zod"; -import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { type PlainClient, uiComponent } from "@team-plain/typescript-sdk"; -import { z } from "zod"; -import { prisma } from "~/db.server"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { createBranchEnvironment } from "~/models/organization.server"; -import { requireUser } from "~/services/session.server"; -import { v3EnvironmentPath } from "~/utils/pathBuilder"; -import { sendToPlain } from "~/utils/plain.server"; - -export const schema = z.object({ - parentEnvironmentId: z.string(), - branchName: z.string().min(1), - failurePath: z.string(), -}); - -export async function action({ request }: ActionFunctionArgs) { - const user = await requireUser(request); - - const formData = await request.formData(); - const submission = parse(formData, { schema }); - - if (!submission.value) { - return redirectWithErrorMessage("/", request, "Invalid form data"); - } - - try { - const parentEnvironment = await prisma.runtimeEnvironment.findFirstOrThrow({ - where: { - id: submission.value.parentEnvironmentId, - organization: { - members: { - some: { - userId: user.id, - }, - }, - }, - }, - include: { - organization: true, - project: true, - }, - }); - - if (!parentEnvironment.isBranchableEnvironment) { - return redirectWithErrorMessage( - submission.value.failurePath, - request, - "Parent environment is not branchable" - ); - } - - const branch = await createBranchEnvironment({ - organization: parentEnvironment.organization, - project: parentEnvironment.project, - parentEnvironment, - branchName: submission.value.branchName, - }); - - return redirectWithSuccessMessage( - v3EnvironmentPath(parentEnvironment.organization, parentEnvironment.project, branch), - request, - "Thanks for your feedback! We'll get back to you soon." - ); - } catch (e) { - submission.error.message = e instanceof Error ? e.message : "Unknown error"; - return redirectWithErrorMessage( - submission.value.failurePath, - request, - "Failed to create branch" - ); - } -} diff --git a/apps/webapp/app/routes/resources.branches.new.tsx b/apps/webapp/app/routes/resources.branches.new.tsx new file mode 100644 index 0000000000..39035f0801 --- /dev/null +++ b/apps/webapp/app/routes/resources.branches.new.tsx @@ -0,0 +1,132 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useActionData } from "@remix-run/react"; +import { type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { type SideMenuEnvironment } from "~/components/navigation/SideMenu"; +import { Button } from "~/components/primitives/Buttons"; +import { DialogHeader } from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { prisma } from "~/db.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { createBranchEnvironment } from "~/models/organization.server"; +import { requireUser } from "~/services/session.server"; +import { v3EnvironmentPath } from "~/utils/pathBuilder"; + +export const schema = z.object({ + parentEnvironmentId: z.string(), + branchName: z.string().min(1), + failurePath: z.string(), +}); + +export async function action({ request }: ActionFunctionArgs) { + const user = await requireUser(request); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return redirectWithErrorMessage("/", request, "Invalid form data"); + } + + try { + const parentEnvironment = await prisma.runtimeEnvironment.findFirstOrThrow({ + where: { + id: submission.value.parentEnvironmentId, + organization: { + members: { + some: { + userId: user.id, + }, + }, + }, + }, + include: { + organization: true, + project: true, + }, + }); + + if (!parentEnvironment.isBranchableEnvironment) { + return redirectWithErrorMessage( + submission.value.failurePath, + request, + "Parent environment is not branchable" + ); + } + + const branch = await createBranchEnvironment({ + organization: parentEnvironment.organization, + project: parentEnvironment.project, + parentEnvironment, + branchName: submission.value.branchName, + }); + + return redirectWithSuccessMessage( + v3EnvironmentPath(parentEnvironment.organization, parentEnvironment.project, branch), + request, + "Thanks for your feedback! We'll get back to you soon." + ); + } catch (e) { + submission.error.message = e instanceof Error ? e.message : "Unknown error"; + return redirectWithErrorMessage( + submission.value.failurePath, + request, + "Failed to create branch" + ); + } +} + +export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: string } }) { + const lastSubmission = useActionData(); + + const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({ + id: "accept-invite", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onInput", + }); + + return ( + <> + New branch +
+
+
+ + + + + + {branchName.error} + + {form.error} + + Create branch + + } + cancelButton={ + + + + } + /> +
+
+
+ + ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 1d96293d0d..4871a2d0b7 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -195,6 +195,10 @@ const pricingDefinitions = { content: "Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.", }, + branches: { + title: "Branches", + content: "The number of preview branches that can be active (you can archive old ones).", + }, }; type PricingPlansProps = { @@ -495,6 +499,7 @@ export function TierFree({ + @@ -609,7 +614,9 @@ export function TierHobby({ tasks - + + + @@ -726,6 +733,7 @@ export function TierPro({ + @@ -938,7 +946,7 @@ function TeamMembers({ limits }: { limits: Limits }) { function Environments({ limits }: { limits: Limits }) { return ( - {limits.hasStagingEnvironment ? "Dev, Staging and Prod" : "Dev and Prod"}{" "} + {limits.hasStagingEnvironment ? "Dev, Preview and Prod" : "Dev and Prod"}{" "} ); } + +function Branches({ limits }: { limits: Limits }) { + return ( + 0}> + {limits.branches.number} + {limits.branches.canExceed ? "+ " : " "} + + preview branches + + + ); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index c4d48c3438..60f1ececee 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -404,6 +404,14 @@ export function v3DeploymentVersionPath( return `${v3DeploymentsPath(organization, project, environment)}?version=${version}`; } +export function branchesPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/branches`; +} + export function v3BillingPath(organization: OrgForPath, message?: string) { return `${organizationPath(organization)}/settings/billing${ message ? `?message=${encodeURIComponent(message)}` : "" diff --git a/apps/webapp/package.json b/apps/webapp/package.json index a6c7c830eb..bf24917e08 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -102,7 +102,7 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.0.14", + "@trigger.dev/platform": "1.0.15", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", @@ -174,7 +174,6 @@ "simple-oauth2": "^5.0.0", "simplur": "^3.0.1", "slug": "^6.0.0", - "slugify": "^1.6.6", "socket.io": "4.7.4", "socket.io-adapter": "^2.5.4", "sonner": "^1.0.3", diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index e3fc2c36f5..ac3f8bf065 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -149,8 +149,8 @@ const pending = colors.blue[500]; const warning = colors.amber[500]; const error = colors.rose[600]; const devEnv = colors.pink[500]; -const stagingEnv = colors.yellow[400]; -const previewEnv = colors.orange[400]; +const stagingEnv = colors.orange[400]; +const previewEnv = colors.yellow[400]; const prodEnv = mint[500]; /** Icon colors */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf0ca2d9f5..9d71564af8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,8 +406,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.14 - version: 1.0.14 + specifier: 1.0.15 + version: 1.0.15 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -621,9 +621,6 @@ importers: slug: specifier: ^6.0.0 version: 6.1.0 - slugify: - specifier: ^1.6.6 - version: 1.6.6 socket.io: specifier: 4.7.4 version: 4.7.4 @@ -17893,8 +17890,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@trigger.dev/platform@1.0.14: - resolution: {integrity: sha512-sYWzsH5oNnSTe4zhm1s0JFtvuRyAjBadScE9REN4f0AkntRG572mJHOolr0HyER4k1gS1mSK0nQScXSG5LCVIA==} + /@trigger.dev/platform@1.0.15: + resolution: {integrity: sha512-rorRJJl7ecyiO8iQZcHGlXR00bTzm7e1xZt0ddCYJFhaQjxq2bo2oen5DVxUbLZsE2cp60ipQWFrmAipFwK79Q==} dependencies: zod: 3.23.8 dev: false @@ -32303,11 +32300,6 @@ packages: resolution: {integrity: sha512-x6vLHCMasg4DR2LPiyFGI0gJJhywY6DTiGhCrOMzb3SOk/0JVLIaL4UhyFSHu04SD3uAavrKY/K3zZ3i6iRcgA==} dev: false - /slugify@1.6.6: - resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} - engines: {node: '>=8.0.0'} - dev: false - /smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} From 05cff355467ff56d3febea6844f15eecf7f488f3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 May 2025 15:10:40 +0100 Subject: [PATCH 012/121] RuntimeEnvironment added projectId index --- .../migration.sql | 1 + internal-packages/database/prisma/schema.prisma | 1 + 2 files changed, 2 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql b/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql new file mode 100644 index 0000000000..1787fc45db --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_projectId_idx" ON "RuntimeEnvironment" ("projectId"); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 2370f7840f..4b059c4204 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -463,6 +463,7 @@ model RuntimeEnvironment { @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@index([parentEnvironmentId]) + @@index([projectId]) } enum RuntimeEnvironmentType { From e7419976e18b42977f396dfadee5cbee9e4bca72 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 May 2025 15:11:39 +0100 Subject: [PATCH 013/121] =?UTF-8?q?Only=20create=20the=20parentEnvironment?= =?UTF-8?q?Id=20column=20if=20it=20doesn=E2=80=99t=20exist=20already?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql index 729c3c1afd..d93feeee2f 100644 --- a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql +++ b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql @@ -2,8 +2,21 @@ ALTER TABLE "RuntimeEnvironment" ADD COLUMN "archivedAt" TIMESTAMP(3), ADD COLUMN "branchName" TEXT, -ADD COLUMN "git" JSONB, -ADD COLUMN "parentEnvironmentId" TEXT; +ADD COLUMN "git" JSONB; + +-- Add the parentEnvironmentId column +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'RuntimeEnvironment' + AND column_name = 'parentEnvironmentId' + ) THEN + ALTER TABLE "RuntimeEnvironment" + ADD COLUMN "parentEnvironmentId" TEXT; + END IF; +END $$; -- AddForeignKey ALTER TABLE "RuntimeEnvironment" ADD CONSTRAINT "RuntimeEnvironment_parentEnvironmentId_fkey" FOREIGN KEY ("parentEnvironmentId") REFERENCES "RuntimeEnvironment" ("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file From 79ce9a010b6799f17504a325249946cc4dceec94 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 May 2025 15:11:45 +0100 Subject: [PATCH 014/121] Improved the limit wording --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index d13a249fe6..03d2464b6a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -369,7 +369,7 @@ export default function Page() { You've used {limits.used}/{limits.limit} of your branches - +
)} From 2f79398ee76797cd6b7ed844075bba3f96930870 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 May 2025 15:12:12 +0100 Subject: [PATCH 015/121] Add search to the branch list --- .../presenters/v3/BranchesPresenter.server.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index eb51894757..81ec20cfc7 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -93,9 +93,14 @@ export class BranchesPresenter { const visibleCount = await this.#prismaClient.runtimeEnvironment.count({ where: { projectId: project.id, - branchName: { - not: null, - }, + branchName: search + ? { + startsWith: search, + mode: "insensitive", + } + : { + not: null, + }, ...(showArchived ? {} : { archivedAt: null }), }, }); @@ -125,23 +130,29 @@ export class BranchesPresenter { }, where: { projectId: project.id, - branchName: { - not: null, - }, + branchName: search + ? { + contains: search, + mode: "insensitive", + } + : { + not: null, + }, ...(showArchived ? {} : { archivedAt: null }), }, + orderBy: { + branchName: "asc", + }, skip: (page - 1) * BRANCHES_PER_PAGE, take: BRANCHES_PER_PAGE, }); - const sortedBranches = branches.sort((a, b) => a.branchName!.localeCompare(b.branchName!)); - return { branchableEnvironment, currentPage: page, totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE), totalCount: visibleCount, - branches: sortedBranches.map((branch) => ({ + branches: branches.map((branch) => ({ ...branch, git: BranchGit.parse(branch.git), })), From 8074fa3809444b8f43b89c9a4feb9732c7876591 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 May 2025 15:12:24 +0100 Subject: [PATCH 016/121] contains in both places --- apps/webapp/app/presenters/v3/BranchesPresenter.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index 81ec20cfc7..b75b1dd159 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -95,7 +95,7 @@ export class BranchesPresenter { projectId: project.id, branchName: search ? { - startsWith: search, + contains: search, mode: "insensitive", } : { From 9209607b6d1be3935fab15acc60bd19221189597 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 May 2025 17:44:29 +0100 Subject: [PATCH 017/121] Many style improvements --- .../app/components/BlankStatePanels.tsx | 8 +- apps/webapp/app/components/V4Badge.tsx | 14 +++ .../navigation/EnvironmentSelector.tsx | 19 ++- .../navigation/HelpAndFeedbackPopover.tsx | 7 +- .../app/components/navigation/SideMenu.tsx | 2 + .../components/navigation/SideMenuItem.tsx | 11 +- .../presenters/v3/BranchesPresenter.server.ts | 19 +-- .../route.tsx | 17 +-- .../route.tsx | 3 +- .../app/routes/resources.branches.new.tsx | 66 +++-------- .../app/services/createBranch.server.ts | 109 ++++++++++++++++++ 11 files changed, 183 insertions(+), 92 deletions(-) create mode 100644 apps/webapp/app/components/V4Badge.tsx create mode 100644 apps/webapp/app/services/createBranch.server.ts diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index aca800da3c..6ab1ad1c2e 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -39,9 +39,7 @@ import { StepContentContainer } from "./StepContentContainer"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { useFeatures } from "~/hooks/useFeatures"; -import { DialogContent } from "./primitives/Dialog"; -import { DialogTrigger } from "./primitives/Dialog"; -import { Dialog } from "./primitives/Dialog"; +import { DialogContent, DialogTrigger, Dialog } from "./primitives/Dialog"; import { NewBranchPanel } from "~/routes/resources.branches.new"; export function HasNoTasksDev() { @@ -544,6 +542,10 @@ export function BranchesNoBranches({ Branches are a way to test new features in isolation before merging them into the main environment. + + Branches are only available when using v4 or above. Read our{" "} + v4 upgrade guide to learn more. + ); } diff --git a/apps/webapp/app/components/V4Badge.tsx b/apps/webapp/app/components/V4Badge.tsx new file mode 100644 index 0000000000..b6c3e7ad67 --- /dev/null +++ b/apps/webapp/app/components/V4Badge.tsx @@ -0,0 +1,14 @@ +import { Badge } from "./primitives/Badge"; + +export function V4Badge() { + return V4; +} + +export function V4Title({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index c5fc57185c..366fbf69af 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -10,7 +10,7 @@ import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; import { useFeatures } from "~/hooks/useFeatures"; import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; -import { branchesPath, v3BillingPath } from "~/utils/pathBuilder"; +import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder"; import { EnvironmentCombo } from "../environments/EnvironmentLabel"; import { Button, ButtonContent } from "../primitives/Buttons"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog"; @@ -34,6 +34,8 @@ import { schema } from "~/routes/resources.branches.new"; import { useProject } from "~/hooks/useProject"; import { useEnvironment } from "~/hooks/useEnvironment"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { Header2 } from "../primitives/Headers"; +import { TextLink } from "../primitives/TextLink"; export function EnvironmentSelector({ organization, @@ -226,9 +228,18 @@ function Branches({ ))} ) : ( -
- - No branches found +
+
+ + Create your first branch +
+ + Branches are a way to test new features in isolation before merging them into the + main environment. + + + Branches are only available when using v4 or above. Read our{" "} + v4 upgrade guide to learn more.
)} diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index a788e74233..93f2843de5 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -21,7 +21,8 @@ import { Icon } from "../primitives/Icon"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover"; import { StepNumber } from "../primitives/StepNumber"; -import { MenuCount, SideMenuItem } from "./SideMenuItem"; +import { SideMenuItem } from "./SideMenuItem"; +import { Badge } from "../primitives/Badge"; export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) { const [isHelpMenuOpen, setHelpMenuOpen] = useState(false); @@ -109,7 +110,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: >
Join our Slack… - + + Pro +
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 44ead52393..80516ab4bb 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -265,6 +265,7 @@ export function SideMenu({ icon={WaitpointTokenIcon} activeIconColor="text-sky-500" to={v3WaitpointTokensPath(organization, project, environment)} + badge="V4" /> @@ -296,6 +297,7 @@ export function SideMenu({ activeIconColor="text-branches" to={branchesPath(organization, project, environment)} data-action="preview-branches" + badge="V4" /> {name}
- {badge !== undefined && } + {badge !== undefined && {badge}}
); } - -export function MenuCount({ count }: { count: number | string }) { - return ( -
- {count} -
- ); -} diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index b75b1dd159..c791dc8c59 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -3,6 +3,7 @@ import { type PrismaClient, prisma } from "~/db.server"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; +import { checkBranchLimit } from "~/services/createBranch.server"; import { getLimit } from "~/services/platform.v3.server"; type Result = Awaited>; @@ -86,6 +87,7 @@ export class BranchesPresenter { limits: { used: 0, limit: 0, + isAtLimit: true, }, }; } @@ -106,17 +108,7 @@ export class BranchesPresenter { }); // Limits - // We limit the number of active branches - const used = await this.#prismaClient.runtimeEnvironment.count({ - where: { - projectId: project.id, - branchName: { - not: null, - }, - archivedAt: null, - }, - }); - const limit = await getLimit(project.organizationId, "branches", 50); + const limits = await checkBranchLimit(this.#prismaClient, project.organizationId, project.id); const branches = await this.#prismaClient.runtimeEnvironment.findMany({ select: { @@ -157,10 +149,7 @@ export class BranchesPresenter { git: BranchGit.parse(branch.git), })), hasFilters, - limits: { - used, - limit, - }, + limits, }; } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 03d2464b6a..0caa6518a0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -63,6 +63,7 @@ import { import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { NewBranchPanel } from "../resources.branches.new"; import { ArchiveIcon, UnarchiveIcon } from "~/assets/icons/ArchiveIcon"; +import { V4Badge, V4Title } from "~/components/V4Badge"; export const BranchesOptions = z.object({ search: z.string().optional(), @@ -107,9 +108,6 @@ export default function Page() { } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); - const environment = useEnvironment(); - const location = useLocation(); - const pathName = usePathName(); const plan = useCurrentPlan(); const requiresUpgrade = @@ -119,13 +117,11 @@ export default function Page() { const canUpgrade = plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.branches.canExceed; - const isAtLimit = limits.used >= limits.limit; - if (!branchableEnvironment) { return ( - + Preview branches} /> @@ -139,7 +135,7 @@ export default function Page() { return ( - + Preview branches} /> @@ -156,7 +152,7 @@ export default function Page() { Branches docs - {isAtLimit ? ( + {limits.isAtLimit ? ( ) : ( @@ -272,7 +268,7 @@ export default function Page() { /> {branch.archivedAt ? ( <> - {isAtLimit ? ( + {limits.isAtLimit ? ( +
- + Waitpoint Tokens} /> diff --git a/apps/webapp/app/routes/resources.branches.new.tsx b/apps/webapp/app/routes/resources.branches.new.tsx index 39035f0801..07b20844a4 100644 --- a/apps/webapp/app/routes/resources.branches.new.tsx +++ b/apps/webapp/app/routes/resources.branches.new.tsx @@ -4,7 +4,6 @@ import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useActionData } from "@remix-run/react"; import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; -import { type SideMenuEnvironment } from "~/components/navigation/SideMenu"; import { Button } from "~/components/primitives/Buttons"; import { DialogHeader } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -13,20 +12,26 @@ import { FormError } from "~/components/primitives/FormError"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; -import { prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { createBranchEnvironment } from "~/models/organization.server"; -import { requireUser } from "~/services/session.server"; +import { CreateBranchService } from "~/services/createBranch.server"; +import { requireUserId } from "~/services/session.server"; import { v3EnvironmentPath } from "~/utils/pathBuilder"; -export const schema = z.object({ +export const CreateBranchOptions = z.object({ parentEnvironmentId: z.string(), branchName: z.string().min(1), - failurePath: z.string(), }); +export type CreateBranchOptions = z.infer; + +export const schema = CreateBranchOptions.and( + z.object({ + failurePath: z.string(), + }) +); + export async function action({ request }: ActionFunctionArgs) { - const user = await requireUser(request); + const userId = await requireUserId(request); const formData = await request.formData(); const submission = parse(formData, { schema }); @@ -35,52 +40,19 @@ export async function action({ request }: ActionFunctionArgs) { return redirectWithErrorMessage("/", request, "Invalid form data"); } - try { - const parentEnvironment = await prisma.runtimeEnvironment.findFirstOrThrow({ - where: { - id: submission.value.parentEnvironmentId, - organization: { - members: { - some: { - userId: user.id, - }, - }, - }, - }, - include: { - organization: true, - project: true, - }, - }); - - if (!parentEnvironment.isBranchableEnvironment) { - return redirectWithErrorMessage( - submission.value.failurePath, - request, - "Parent environment is not branchable" - ); - } + const createBranchService = new CreateBranchService(); - const branch = await createBranchEnvironment({ - organization: parentEnvironment.organization, - project: parentEnvironment.project, - parentEnvironment, - branchName: submission.value.branchName, - }); + const result = await createBranchService.call(userId, submission.value); + if (result.success) { return redirectWithSuccessMessage( - v3EnvironmentPath(parentEnvironment.organization, parentEnvironment.project, branch), + v3EnvironmentPath(result.organization, result.project, result.branch), request, - "Thanks for your feedback! We'll get back to you soon." - ); - } catch (e) { - submission.error.message = e instanceof Error ? e.message : "Unknown error"; - return redirectWithErrorMessage( - submission.value.failurePath, - request, - "Failed to create branch" + `Branch "${result.branch.branchName}" created` ); } + + return redirectWithErrorMessage(submission.value.failurePath, request, result.error); } export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: string } }) { diff --git a/apps/webapp/app/services/createBranch.server.ts b/apps/webapp/app/services/createBranch.server.ts new file mode 100644 index 0000000000..7291cc44cc --- /dev/null +++ b/apps/webapp/app/services/createBranch.server.ts @@ -0,0 +1,109 @@ +import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { createBranchEnvironment } from "~/models/organization.server"; +import { type CreateBranchOptions } from "~/routes/resources.branches.new"; +import { logger } from "./logger.server"; +import { getLimit } from "./platform.v3.server"; + +export class CreateBranchService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call(userId: string, { parentEnvironmentId, branchName }: CreateBranchOptions) { + try { + const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({ + where: { + id: parentEnvironmentId, + organization: { + members: { + some: { + userId: userId, + }, + }, + }, + }, + include: { + organization: { + select: { + id: true, + slug: true, + maximumConcurrencyLimit: true, + }, + }, + project: { + select: { + id: true, + slug: true, + }, + }, + }, + }); + + if (!parentEnvironment.isBranchableEnvironment) { + return { + success: false as const, + error: "Parent environment is not branchable", + }; + } + + const limits = await checkBranchLimit( + this.#prismaClient, + parentEnvironment.organization.id, + parentEnvironment.project.id + ); + + if (limits.isAtLimit) { + return { + success: false as const, + error: `You've used all ${limits.used} of ${limits.limit} branches for your plan. Upgrade to get more branches or archive some.`, + }; + } + + const branch = await createBranchEnvironment({ + organization: parentEnvironment.organization, + project: parentEnvironment.project, + parentEnvironment, + branchName, + }); + + return { + success: true as const, + branch, + organization: parentEnvironment.organization, + project: parentEnvironment.project, + }; + } catch (e) { + logger.error("CreateBranchService error", { error: e }); + return { + success: false as const, + error: "Failed to create branch", + }; + } + } +} + +export async function checkBranchLimit( + prisma: PrismaClientOrTransaction, + organizationId: string, + projectId: string +) { + const used = await prisma.runtimeEnvironment.count({ + where: { + projectId, + branchName: { + not: null, + }, + archivedAt: null, + }, + }); + const limit = await getLimit(organizationId, "branches", 50); + + return { + used, + limit, + isAtLimit: used >= limit, + }; +} From 00ac0f72e55bbd2238b37064ea81b0228f5799f4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 May 2025 17:51:22 +0100 Subject: [PATCH 018/121] Branch dropdown and v4 badge --- apps/webapp/app/components/BlankStatePanels.tsx | 3 ++- apps/webapp/app/components/V4Badge.tsx | 16 ++++++++++++++-- .../navigation/EnvironmentSelector.tsx | 3 ++- .../navigation/OrganizationSettingsSideMenu.tsx | 7 ++++--- .../app/components/navigation/SideMenu.tsx | 5 +++-- .../app/components/navigation/SideMenuItem.tsx | 9 +++------ 6 files changed, 28 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 6ab1ad1c2e..4f5a8601d8 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -41,6 +41,7 @@ import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { useFeatures } from "~/hooks/useFeatures"; import { DialogContent, DialogTrigger, Dialog } from "./primitives/Dialog"; import { NewBranchPanel } from "~/routes/resources.branches.new"; +import { V4Badge } from "./V4Badge"; export function HasNoTasksDev() { return ( @@ -543,7 +544,7 @@ export function BranchesNoBranches({ environment. - Branches are only available when using v4 or above. Read our{" "} + Branches are only available when using or above. Read our{" "} v4 upgrade guide to learn more. diff --git a/apps/webapp/app/components/V4Badge.tsx b/apps/webapp/app/components/V4Badge.tsx index b6c3e7ad67..c92baabac8 100644 --- a/apps/webapp/app/components/V4Badge.tsx +++ b/apps/webapp/app/components/V4Badge.tsx @@ -1,7 +1,19 @@ +import { cn } from "~/utils/cn"; import { Badge } from "./primitives/Badge"; +import { SimpleTooltip } from "./primitives/Tooltip"; -export function V4Badge() { - return V4; +export function V4Badge({ inline = false, className }: { inline?: boolean; className?: string }) { + return ( + + V4 + + } + content="This feature is only available in V4 and above." + disableHoverableContent + /> + ); } export function V4Title({ children }: { children: React.ReactNode }) { diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 366fbf69af..884d819b18 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -36,6 +36,7 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { Header2 } from "../primitives/Headers"; import { TextLink } from "../primitives/TextLink"; +import { V4Badge } from "../V4Badge"; export function EnvironmentSelector({ organization, @@ -238,7 +239,7 @@ function Branches({ main environment. - Branches are only available when using v4 or above. Read our{" "} + Branches are only available when using or above. Read our{" "} v4 upgrade guide to learn more.
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index f4d67bfac4..694ac87560 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -21,6 +21,7 @@ import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { Paragraph } from "../primitives/Paragraph"; +import { Badge } from "../primitives/Badge"; export function OrganizationSettingsSideMenu({ organization, @@ -69,9 +70,9 @@ export function OrganizationSettingsSideMenu({ to={v3BillingPath(organization)} data-action="billing" badge={ - currentPlan?.v3Subscription?.isPaying - ? currentPlan?.v3Subscription?.plan?.title - : undefined + currentPlan?.v3Subscription?.isPaying ? ( + {currentPlan?.v3Subscription?.plan?.title} + ) : undefined } /> )} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 80516ab4bb..321adec0bd 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -86,6 +86,7 @@ import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { V4Badge } from "../V4Badge"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -265,7 +266,7 @@ export function SideMenu({ icon={WaitpointTokenIcon} activeIconColor="text-sky-500" to={v3WaitpointTokensPath(organization, project, environment)} - badge="V4" + badge={} /> @@ -297,7 +298,7 @@ export function SideMenu({ activeIconColor="text-branches" to={branchesPath(organization, project, environment)} data-action="preview-branches" - badge="V4" + badge={} /> ["target"]; }) { const pathName = usePathName(); @@ -47,9 +46,7 @@ export function SideMenuItem({ >
{name} -
- {badge !== undefined && {badge}} -
+
{badge !== undefined && badge}
); From 65fc70a119ed04d212131a9d8d0aa91b0a5b2c19 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 13 May 2025 18:58:24 +0100 Subject: [PATCH 019/121] Arching/unarchive branches working in the dashboard --- .../presenters/v3/BranchesPresenter.server.ts | 19 +- .../route.tsx | 53 ++--- .../app/routes/resources.branches.archive.tsx | 192 ++++++++++++++++++ .../app/routes/resources.branches.new.tsx | 2 +- .../app/services/archiveBranch.server.ts | 90 ++++++++ 5 files changed, 310 insertions(+), 46 deletions(-) create mode 100644 apps/webapp/app/routes/resources.branches.archive.tsx create mode 100644 apps/webapp/app/services/archiveBranch.server.ts diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index c791dc8c59..0211b61028 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -13,8 +13,6 @@ const BRANCHES_PER_PAGE = 10; type Options = z.infer; -//TODO filter by branch name - export const BranchGit = z .object({ repo: z.string(), @@ -144,10 +142,19 @@ export class BranchesPresenter { currentPage: page, totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE), totalCount: visibleCount, - branches: branches.map((branch) => ({ - ...branch, - git: BranchGit.parse(branch.git), - })), + branches: branches.flatMap((branch) => { + if (branch.branchName === null) { + return []; + } + + return [ + { + ...branch, + branchName: branch.branchName, + git: BranchGit.parse(branch.git), + } as const, + ]; + }), hasFilters, limits, }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 0caa6518a0..efa1c5dec3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -64,6 +64,8 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { NewBranchPanel } from "../resources.branches.new"; import { ArchiveIcon, UnarchiveIcon } from "~/assets/icons/ArchiveIcon"; import { V4Badge, V4Title } from "~/components/V4Badge"; +import { ArchiveButton, UnarchiveButton } from "../resources.branches.archive"; +import { Paragraph } from "~/components/primitives/Paragraph"; export const BranchesOptions = z.object({ search: z.string().optional(), @@ -267,43 +269,13 @@ export default function Page() { title="View branch" /> {branch.archivedAt ? ( - <> - {limits.isAtLimit ? ( - - ) : ( - - )} - + ) : ( - - - - - + )} } @@ -469,9 +441,12 @@ function UpgradePanel({ You've exceeded your limit - - You've used {limits.used}/{limits.limit} of your branches. - +
+ + You've used {limits.used}/{limits.limit} of your branches. + + You can archive one or upgrade your plan for more. +
{canUpgrade ? ( diff --git a/apps/webapp/app/routes/resources.branches.archive.tsx b/apps/webapp/app/routes/resources.branches.archive.tsx new file mode 100644 index 0000000000..7d2c1c2546 --- /dev/null +++ b/apps/webapp/app/routes/resources.branches.archive.tsx @@ -0,0 +1,192 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { PlusIcon } from "@heroicons/react/24/outline"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useActionData, useLocation } from "@remix-run/react"; +import { type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { ArchiveIcon, UnarchiveIcon } from "~/assets/icons/ArchiveIcon"; +import { Feedback } from "~/components/Feedback"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { ArchiveBranchService } from "~/services/archiveBranch.server"; +import { requireUserId } from "~/services/session.server"; +import { v3BillingPath, v3EnvironmentPath } from "~/utils/pathBuilder"; + +const ArchiveBranchOptions = z.object({ + environmentId: z.string(), + action: z.enum(["archive", "unarchive"]), +}); + +const schema = ArchiveBranchOptions.and( + z.object({ + redirectPath: z.string(), + }) +); + +export async function action({ request }: ActionFunctionArgs) { + const userId = await requireUserId(request); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return redirectWithErrorMessage("/", request, "Invalid form data"); + } + + const archiveBranchService = new ArchiveBranchService(); + + const result = await archiveBranchService.call(userId, submission.value); + + if (result.success) { + return redirectWithSuccessMessage( + submission.value.redirectPath, + request, + `Branch "${result.branch.branchName}" ${ + submission.value.action === "archive" ? "archived" : "unarchived" + }` + ); + } + + return redirectWithErrorMessage(submission.value.redirectPath, request, result.error); +} + +export function ArchiveButton({ + environment, +}: { + environment: { id: string; branchName: string }; +}) { + const location = useLocation(); + const lastSubmission = useActionData(); + + const [form, { environmentId, action, redirectPath }] = useForm({ + id: "archive-branch", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onInput", + }); + + return ( + <> +
+ + + + {form.error} + +
+ + ); +} + +export function UnarchiveButton({ + environment, + limits, + canUpgrade, +}: { + environment: { id: string; branchName: string }; + limits: { used: number; limit: number; isAtLimit: boolean }; + canUpgrade: boolean; +}) { + const location = useLocation(); + const organization = useOrganization(); + const lastSubmission = useActionData(); + + const [form, { environmentId, action, redirectPath }] = useForm({ + id: "archive-branch", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onInput", + }); + + if (limits.isAtLimit) { + return ( + + + + + + You've exceeded your branch limit +
+ + You've used {limits.used}/{limits.limit} of your branches. + + You can archive one or upgrade your plan for more. +
+ + {canUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + +
+
+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/apps/webapp/app/routes/resources.branches.new.tsx b/apps/webapp/app/routes/resources.branches.new.tsx index 07b20844a4..fd9a4ae4a9 100644 --- a/apps/webapp/app/routes/resources.branches.new.tsx +++ b/apps/webapp/app/routes/resources.branches.new.tsx @@ -59,7 +59,7 @@ export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: const lastSubmission = useActionData(); const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({ - id: "accept-invite", + id: "create-branch", lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema }); diff --git a/apps/webapp/app/services/archiveBranch.server.ts b/apps/webapp/app/services/archiveBranch.server.ts new file mode 100644 index 0000000000..1adf698248 --- /dev/null +++ b/apps/webapp/app/services/archiveBranch.server.ts @@ -0,0 +1,90 @@ +import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { createBranchEnvironment } from "~/models/organization.server"; +import { type CreateBranchOptions } from "~/routes/resources.branches.new"; +import { logger } from "./logger.server"; +import { getLimit } from "./platform.v3.server"; +import { checkBranchLimit } from "./createBranch.server"; + +export class ArchiveBranchService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call( + userId: string, + { action, environmentId }: { action: "archive" | "unarchive"; environmentId: string } + ) { + try { + const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({ + where: { + id: environmentId, + organization: { + members: { + some: { + userId: userId, + }, + }, + }, + }, + include: { + organization: { + select: { + id: true, + slug: true, + maximumConcurrencyLimit: true, + }, + }, + project: { + select: { + id: true, + slug: true, + }, + }, + }, + }); + + if (!environment.parentEnvironmentId) { + return { + success: false as const, + error: "This isn't a branch, and cannot be archived.", + }; + } + + if (action === "unarchive") { + const limits = await checkBranchLimit( + this.#prismaClient, + environment.organization.id, + environment.project.id + ); + + if (limits.isAtLimit) { + return { + success: false as const, + error: `You've used all ${limits.used} of ${limits.limit} branches for your plan. Upgrade to get more branches or archive some.`, + }; + } + } + + const updatedBranch = await this.#prismaClient.runtimeEnvironment.update({ + where: { id: environmentId }, + data: { archivedAt: action === "archive" ? new Date() : null }, + }); + + return { + success: true as const, + branch: updatedBranch, + organization: environment.organization, + project: environment.project, + }; + } catch (e) { + logger.error("ArchiveBranchService error", { environmentId, action, error: e }); + return { + success: false as const, + error: "Failed to archive branch", + }; + } + } +} From b637ec13646bad70b3081955c04a12ad67aec5bd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 12:01:43 +0100 Subject: [PATCH 020/121] Tidied imports --- apps/webapp/app/services/archiveBranch.server.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/services/archiveBranch.server.ts b/apps/webapp/app/services/archiveBranch.server.ts index 1adf698248..f95b8b00cc 100644 --- a/apps/webapp/app/services/archiveBranch.server.ts +++ b/apps/webapp/app/services/archiveBranch.server.ts @@ -1,10 +1,7 @@ -import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { type PrismaClient } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -import { createBranchEnvironment } from "~/models/organization.server"; -import { type CreateBranchOptions } from "~/routes/resources.branches.new"; -import { logger } from "./logger.server"; -import { getLimit } from "./platform.v3.server"; import { checkBranchLimit } from "./createBranch.server"; +import { logger } from "./logger.server"; export class ArchiveBranchService { #prismaClient: PrismaClient; From 6efa232f11e11beb1f7a934b8c17fc2a7239b14b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 12:01:58 +0100 Subject: [PATCH 021/121] Change preview slug from `prev` to `preview` --- apps/webapp/app/models/api-key.server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index c70fd7510f..86609cc01d 100644 --- a/apps/webapp/app/models/api-key.server.ts +++ b/apps/webapp/app/models/api-key.server.ts @@ -84,7 +84,7 @@ export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) { return `pk_${envSlug(envType)}_${apiKeyId(20)}`; } -export type EnvSlug = "dev" | "stg" | "prod" | "prev"; +export type EnvSlug = "dev" | "stg" | "prod" | "preview"; export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug { switch (environmentType) { @@ -98,11 +98,11 @@ export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug { return "stg"; } case "PREVIEW": { - return "prev"; + return "preview"; } } } export function isEnvSlug(maybeSlug: string): maybeSlug is EnvSlug { - return ["dev", "stg", "prod", "prev"].includes(maybeSlug); + return ["dev", "stg", "prod", "preview"].includes(maybeSlug); } From 4e97a8c396d756ad4782cb0236e7c57d6b808f19 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 12:02:09 +0100 Subject: [PATCH 022/121] Use correct color for side menu preview branch icon --- apps/webapp/app/components/navigation/SideMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 321adec0bd..ca79b07d64 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -295,7 +295,7 @@ export function SideMenu({ } From 0579f0e59fb6bb072640ccb6bf255c2e62eb6794 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 12:04:54 +0100 Subject: [PATCH 023/121] Upsert the branch and use the shortcode as a unique constraint --- apps/webapp/app/models/organization.server.ts | 21 +++++++--- .../app/services/createBranch.server.ts | 39 ++++++++++++++++++- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index e0bd6cd998..76b5e08195 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -7,7 +7,7 @@ import type { } from "@trigger.dev/database"; import { customAlphabet } from "nanoid"; import slug from "slug"; -import { prisma, type PrismaClientOrTransaction } from "~/db.server"; +import { Prisma, prisma, type PrismaClientOrTransaction } from "~/db.server"; import { generate } from "random-words"; import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; import { env } from "~/env.server"; @@ -122,7 +122,7 @@ export async function createEnvironment({ }); } -export function createBranchEnvironment({ +export async function upsertBranchEnvironment({ organization, project, parentEnvironment, @@ -135,13 +135,19 @@ export function createBranchEnvironment({ branchName: string; git?: BranchGit; }) { - const branchSlug = `${slug(`${parentEnvironment.slug}-${branchName}`)}-${nanoid(4)}`; + const branchSlug = `${slug(`${parentEnvironment.slug}-${branchName}`)}`; const apiKey = createApiKeyForEnv(parentEnvironment.type); const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); - const shortcode = createShortcode().join("-"); + const shortcode = branchSlug; - return prisma.runtimeEnvironment.create({ - data: { + return prisma.runtimeEnvironment.upsert({ + where: { + projectId_shortcode: { + projectId: project.id, + shortcode: shortcode, + }, + }, + create: { slug: branchSlug, apiKey, pkApiKey, @@ -162,6 +168,9 @@ export function createBranchEnvironment({ }, git: git ?? undefined, }, + update: { + git: git ?? undefined, + }, }); } diff --git a/apps/webapp/app/services/createBranch.server.ts b/apps/webapp/app/services/createBranch.server.ts index 7291cc44cc..a1bcc9f35a 100644 --- a/apps/webapp/app/services/createBranch.server.ts +++ b/apps/webapp/app/services/createBranch.server.ts @@ -1,9 +1,44 @@ import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -import { createBranchEnvironment } from "~/models/organization.server"; +import { upsertBranchEnvironment } from "~/models/organization.server"; import { type CreateBranchOptions } from "~/routes/resources.branches.new"; import { logger } from "./logger.server"; import { getLimit } from "./platform.v3.server"; +import { z } from "zod"; + +/* +Regex that only allows +- alpha (upper, lower) +- dashes +- underscores +- period +- slashes +- At least one character +*/ +const branchRegEx = /[a-zA-Z\-_.]+/; + +// name schema, use on the frontend too to give errors in the browser +const BranchName = z.preprocess((val) => { + return val; +}, z.string()); + +//TODO CreateBranchService +//- Should "upsert" branch + +//TODO At the database layer prevent duplicate projectId, slug +//look at /// The second one implemented in SQL only prevents a TaskRun + Waitpoint with a null batchIndex +// @@unique([taskRunId, waitpointId, batchIndex]) + +//TODO Archive +// - Save the slug in another column +// - Scramble the slug column (archivedSlug) + +//TODO unarchiving +// - Only unarchive if there isn't an active branch with the same name +// - Restore the slug from the other column + +//TODO +// When finding an environment for the URL ($envParam) only find non-archived ones export class CreateBranchService { #prismaClient: PrismaClient; @@ -62,7 +97,7 @@ export class CreateBranchService { }; } - const branch = await createBranchEnvironment({ + const branch = await upsertBranchEnvironment({ organization: parentEnvironment.organization, project: parentEnvironment.project, parentEnvironment, From 17b5a40e33365686e21077748529ac3d7e857462 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 12:18:38 +0100 Subject: [PATCH 024/121] Upserting working with nice messages in the dashboard --- apps/webapp/app/models/organization.server.ts | 9 ++++++++- .../presenters/v3/BranchesPresenter.server.ts | 2 +- .../app/routes/resources.branches.new.tsx | 19 +++++++++++++------ .../app/services/archiveBranch.server.ts | 2 +- ...ranch.server.ts => upsertBranch.server.ts} | 5 +++-- 5 files changed, 26 insertions(+), 11 deletions(-) rename apps/webapp/app/services/{createBranch.server.ts => upsertBranch.server.ts} (97%) diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 76b5e08195..9586e65f01 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -140,7 +140,9 @@ export async function upsertBranchEnvironment({ const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); const shortcode = branchSlug; - return prisma.runtimeEnvironment.upsert({ + const now = new Date(); + + const branch = await prisma.runtimeEnvironment.upsert({ where: { projectId_shortcode: { projectId: project.id, @@ -172,6 +174,11 @@ export async function upsertBranchEnvironment({ git: git ?? undefined, }, }); + + return { + alreadyExisted: branch.createdAt < now, + branch, + }; } function createShortcode() { diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index 0211b61028..d9573b6c86 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -3,7 +3,7 @@ import { type PrismaClient, prisma } from "~/db.server"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; -import { checkBranchLimit } from "~/services/createBranch.server"; +import { checkBranchLimit } from "~/services/upsertBranch.server"; import { getLimit } from "~/services/platform.v3.server"; type Result = Awaited>; diff --git a/apps/webapp/app/routes/resources.branches.new.tsx b/apps/webapp/app/routes/resources.branches.new.tsx index fd9a4ae4a9..ea02efe718 100644 --- a/apps/webapp/app/routes/resources.branches.new.tsx +++ b/apps/webapp/app/routes/resources.branches.new.tsx @@ -13,9 +13,9 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { CreateBranchService } from "~/services/createBranch.server"; import { requireUserId } from "~/services/session.server"; -import { v3EnvironmentPath } from "~/utils/pathBuilder"; +import { UpsertBranchService } from "~/services/upsertBranch.server"; +import { branchesPath, v3EnvironmentPath } from "~/utils/pathBuilder"; export const CreateBranchOptions = z.object({ parentEnvironmentId: z.string(), @@ -40,13 +40,20 @@ export async function action({ request }: ActionFunctionArgs) { return redirectWithErrorMessage("/", request, "Invalid form data"); } - const createBranchService = new CreateBranchService(); - - const result = await createBranchService.call(userId, submission.value); + const upsertBranchService = new UpsertBranchService(); + const result = await upsertBranchService.call(userId, submission.value); if (result.success) { + if (result.alreadyExisted) { + return redirectWithErrorMessage( + submission.value.failurePath, + request, + `Branch "${result.branch.branchName}" already exists` + ); + } + return redirectWithSuccessMessage( - v3EnvironmentPath(result.organization, result.project, result.branch), + branchesPath(result.organization, result.project, result.branch), request, `Branch "${result.branch.branchName}" created` ); diff --git a/apps/webapp/app/services/archiveBranch.server.ts b/apps/webapp/app/services/archiveBranch.server.ts index f95b8b00cc..92b0c0dcdc 100644 --- a/apps/webapp/app/services/archiveBranch.server.ts +++ b/apps/webapp/app/services/archiveBranch.server.ts @@ -1,6 +1,6 @@ import { type PrismaClient } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -import { checkBranchLimit } from "./createBranch.server"; +import { checkBranchLimit } from "./upsertBranch.server"; import { logger } from "./logger.server"; export class ArchiveBranchService { diff --git a/apps/webapp/app/services/createBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts similarity index 97% rename from apps/webapp/app/services/createBranch.server.ts rename to apps/webapp/app/services/upsertBranch.server.ts index a1bcc9f35a..5ca9b0e6ea 100644 --- a/apps/webapp/app/services/createBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -40,7 +40,7 @@ const BranchName = z.preprocess((val) => { //TODO // When finding an environment for the URL ($envParam) only find non-archived ones -export class CreateBranchService { +export class UpsertBranchService { #prismaClient: PrismaClient; constructor(prismaClient: PrismaClient = prisma) { @@ -106,7 +106,8 @@ export class CreateBranchService { return { success: true as const, - branch, + alreadyExisted: branch.alreadyExisted, + branch: branch.branch, organization: parentEnvironment.organization, project: parentEnvironment.project, }; From 8b80cf2cf620d722cdd8e53f3423406feeeef253 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 14:56:23 +0100 Subject: [PATCH 025/121] Better errors when upserting branches --- apps/webapp/app/models/organization.server.ts | 67 +------- .../app/routes/resources.branches.new.tsx | 53 +++++-- .../app/services/upsertBranch.server.ts | 149 +++++++++++++----- .../webapp/test/validateGitBranchName.test.ts | 108 +++++++++++++ 4 files changed, 264 insertions(+), 113 deletions(-) create mode 100644 apps/webapp/test/validateGitBranchName.test.ts diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 9586e65f01..73bf483c26 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -6,13 +6,13 @@ import type { User, } from "@trigger.dev/database"; import { customAlphabet } from "nanoid"; -import slug from "slug"; -import { Prisma, prisma, type PrismaClientOrTransaction } from "~/db.server"; import { generate } from "random-words"; -import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; +import slug from "slug"; +import { prisma, type PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { featuresForUrl } from "~/features.server"; -import { BranchGit } from "~/presenters/v3/BranchesPresenter.server"; +import { type BranchGit } from "~/presenters/v3/BranchesPresenter.server"; +import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; export type { Organization }; @@ -122,65 +122,6 @@ export async function createEnvironment({ }); } -export async function upsertBranchEnvironment({ - organization, - project, - parentEnvironment, - branchName, - git, -}: { - organization: Pick; - project: Pick; - parentEnvironment: RuntimeEnvironment; - branchName: string; - git?: BranchGit; -}) { - const branchSlug = `${slug(`${parentEnvironment.slug}-${branchName}`)}`; - const apiKey = createApiKeyForEnv(parentEnvironment.type); - const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); - const shortcode = branchSlug; - - const now = new Date(); - - const branch = await prisma.runtimeEnvironment.upsert({ - where: { - projectId_shortcode: { - projectId: project.id, - shortcode: shortcode, - }, - }, - create: { - slug: branchSlug, - apiKey, - pkApiKey, - shortcode, - maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit, - organization: { - connect: { - id: organization.id, - }, - }, - project: { - connect: { id: project.id }, - }, - branchName, - type: parentEnvironment.type, - parentEnvironment: { - connect: { id: parentEnvironment.id }, - }, - git: git ?? undefined, - }, - update: { - git: git ?? undefined, - }, - }); - - return { - alreadyExisted: branch.createdAt < now, - branch, - }; -} - function createShortcode() { return generate({ exactly: 2 }); } diff --git a/apps/webapp/app/routes/resources.branches.new.tsx b/apps/webapp/app/routes/resources.branches.new.tsx index ea02efe718..ad3130b8ae 100644 --- a/apps/webapp/app/routes/resources.branches.new.tsx +++ b/apps/webapp/app/routes/resources.branches.new.tsx @@ -1,14 +1,16 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useActionData } from "@remix-run/react"; -import { type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { Form, useActionData, useFetcher } from "@remix-run/react"; +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; +import { InlineCode } from "~/components/code/InlineCode"; import { Button } from "~/components/primitives/Buttons"; import { DialogHeader } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; +import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; @@ -20,6 +22,17 @@ import { branchesPath, v3EnvironmentPath } from "~/utils/pathBuilder"; export const CreateBranchOptions = z.object({ parentEnvironmentId: z.string(), branchName: z.string().min(1), + git: z + .object({ + repoOwner: z.string(), + repoName: z.string(), + refFull: z.string(), + refType: z.enum(["branch", "tag", "commit", "pull_request"]), + commitSha: z.string(), + createdBy: z.string().optional(), + pullRequestNumber: z.number().optional(), + }) + .optional(), }); export type CreateBranchOptions = z.infer; @@ -45,11 +58,8 @@ export async function action({ request }: ActionFunctionArgs) { if (result.success) { if (result.alreadyExisted) { - return redirectWithErrorMessage( - submission.value.failurePath, - request, - `Branch "${result.branch.branchName}" already exists` - ); + submission.error = { branchName: `Branch "${result.branch.branchName}" already exists` }; + return json(submission); } return redirectWithSuccessMessage( @@ -59,15 +69,16 @@ export async function action({ request }: ActionFunctionArgs) { ); } - return redirectWithErrorMessage(submission.value.failurePath, request, result.error); + submission.error = { branchName: result.error }; + return json(submission); } export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: string } }) { - const lastSubmission = useActionData(); + const fetcher = useFetcher(); const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({ id: "create-branch", - lastSubmission: lastSubmission as any, + lastSubmission: fetcher.data as any, onValidate({ formData }) { return parse(formData, { schema }); }, @@ -78,7 +89,12 @@ export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: <> New branch
-
+
+ + Must not contain: spaces ~{" "} + ^{" "} + :{" "} + ?{" "} + *{" "} + {"["}{" "} + \\{" "} + //{" "} + ..{" "} + {"@{"}{" "} + .lock + {branchName.error} {form.error} @@ -104,7 +133,7 @@ export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: } />
- +
); diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 5ca9b0e6ea..314d3ea204 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -1,41 +1,19 @@ import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; +import slug from "slug"; import { prisma } from "~/db.server"; -import { upsertBranchEnvironment } from "~/models/organization.server"; +import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; import { type CreateBranchOptions } from "~/routes/resources.branches.new"; import { logger } from "./logger.server"; import { getLimit } from "./platform.v3.server"; -import { z } from "zod"; - -/* -Regex that only allows -- alpha (upper, lower) -- dashes -- underscores -- period -- slashes -- At least one character -*/ -const branchRegEx = /[a-zA-Z\-_.]+/; - -// name schema, use on the frontend too to give errors in the browser -const BranchName = z.preprocess((val) => { - return val; -}, z.string()); - -//TODO CreateBranchService -//- Should "upsert" branch - -//TODO At the database layer prevent duplicate projectId, slug -//look at /// The second one implemented in SQL only prevents a TaskRun + Waitpoint with a null batchIndex -// @@unique([taskRunId, waitpointId, batchIndex]) //TODO Archive -// - Save the slug in another column -// - Scramble the slug column (archivedSlug) +// - Save the slug in another column (archivedSlug) +// - Scramble the slug and shortcode columns +// - Disable creative, destructive actions in the dashboard +// - Replay, Cancel runs +// - Create, edit schedules -//TODO unarchiving -// - Only unarchive if there isn't an active branch with the same name -// - Restore the slug from the other column +// TODO Don't allow unarchiving //TODO // When finding an environment for the URL ($envParam) only find non-archived ones @@ -47,7 +25,22 @@ export class UpsertBranchService { this.#prismaClient = prismaClient; } - public async call(userId: string, { parentEnvironmentId, branchName }: CreateBranchOptions) { + public async call(userId: string, { parentEnvironmentId, branchName, git }: CreateBranchOptions) { + const sanitizedBranchName = branchNameFromRef(branchName); + if (!sanitizedBranchName) { + return { + success: false as const, + error: "Branch name has an invalid format", + }; + } + + if (!isValidGitBranchName(sanitizedBranchName)) { + return { + success: false as const, + error: "Invalid branch name, contains disallowed character sequences", + }; + } + try { const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({ where: { @@ -97,17 +90,52 @@ export class UpsertBranchService { }; } - const branch = await upsertBranchEnvironment({ - organization: parentEnvironment.organization, - project: parentEnvironment.project, - parentEnvironment, - branchName, + const branchSlug = `${slug(`${parentEnvironment.slug}-${sanitizedBranchName}`)}`; + const apiKey = createApiKeyForEnv(parentEnvironment.type); + const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); + const shortcode = branchSlug; + + const now = new Date(); + + const branch = await prisma.runtimeEnvironment.upsert({ + where: { + projectId_shortcode: { + projectId: parentEnvironment.project.id, + shortcode: shortcode, + }, + }, + create: { + slug: branchSlug, + apiKey, + pkApiKey, + shortcode, + maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit, + organization: { + connect: { + id: parentEnvironment.organization.id, + }, + }, + project: { + connect: { id: parentEnvironment.project.id }, + }, + branchName: sanitizedBranchName, + type: parentEnvironment.type, + parentEnvironment: { + connect: { id: parentEnvironment.id }, + }, + git: git ?? undefined, + }, + update: { + git: git ?? undefined, + }, }); + const alreadyExisted = branch.createdAt < now; + return { success: true as const, - alreadyExisted: branch.alreadyExisted, - branch: branch.branch, + alreadyExisted: alreadyExisted, + branch, organization: parentEnvironment.organization, project: parentEnvironment.project, }; @@ -143,3 +171,48 @@ export async function checkBranchLimit( isAtLimit: used >= limit, }; } + +export function isValidGitBranchName(branch: string): boolean { + // Must not be empty + if (!branch) return false; + + // Disallowed characters: space, ~, ^, :, ?, *, [, \ + if (/[ \~\^:\?\*\[\\]/.test(branch)) return false; + + // Disallow ASCII control characters (0-31) and DEL (127) + for (let i = 0; i < branch.length; i++) { + const code = branch.charCodeAt(i); + if ((code >= 0 && code <= 31) || code === 127) return false; + } + + // Cannot start or end with a slash + if (branch.startsWith("/") || branch.endsWith("/")) return false; + + // Cannot have consecutive slashes + if (branch.includes("//")) return false; + + // Cannot contain '..' + if (branch.includes("..")) return false; + + // Cannot contain '@{' + if (branch.includes("@{")) return false; + + // Cannot end with '.lock' + if (branch.endsWith(".lock")) return false; + + return true; +} + +export function branchNameFromRef(ref: string): string | null { + if (!ref) return null; + if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length); + if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length); + if (ref.startsWith("refs/tags/")) return ref.substring("refs/tags/".length); + if (ref.startsWith("refs/pull/")) return ref.substring("refs/pull/".length); + if (ref.startsWith("refs/merge/")) return ref.substring("refs/merge/".length); + if (ref.startsWith("refs/release/")) return ref.substring("refs/release/".length); + //unknown ref format, so reject + if (ref.startsWith("refs/")) return null; + + return ref; +} diff --git a/apps/webapp/test/validateGitBranchName.test.ts b/apps/webapp/test/validateGitBranchName.test.ts new file mode 100644 index 0000000000..6a15b38cf0 --- /dev/null +++ b/apps/webapp/test/validateGitBranchName.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { branchNameFromRef, isValidGitBranchName } from "../app/services/upsertBranch.server"; + +describe("isValidGitBranchName", () => { + it("returns true for a valid branch name", async () => { + expect(isValidGitBranchName("feature/valid-branch")).toBe(true); + }); + + it("returns false for an invalid branch name", async () => { + expect(isValidGitBranchName("invalid branch name!")).toBe(false); + }); + + it("disallows control characters (ASCII 0–31)", async () => { + for (let i = 0; i <= 31; i++) { + const branch = `feature${String.fromCharCode(i)}branch`; + // eslint-disable-next-line no-await-in-loop + expect(isValidGitBranchName(branch)).toBe(false); + } + }); + + it("disallows space", async () => { + expect(isValidGitBranchName("feature branch")).toBe(false); + }); + + it("disallows tilde (~)", async () => { + expect(isValidGitBranchName("feature~branch")).toBe(false); + }); + + it("disallows caret (^)", async () => { + expect(isValidGitBranchName("feature^branch")).toBe(false); + }); + + it("disallows colon (:)", async () => { + expect(isValidGitBranchName("feature:branch")).toBe(false); + }); + + it("disallows question mark (?)", async () => { + expect(isValidGitBranchName("feature?branch")).toBe(false); + }); + + it("disallows asterisk (*)", async () => { + expect(isValidGitBranchName("feature*branch")).toBe(false); + }); + + it("disallows open bracket ([)", async () => { + expect(isValidGitBranchName("feature[branch")).toBe(false); + }); + + it("disallows backslash (\\)", async () => { + expect(isValidGitBranchName("feature\\branch")).toBe(false); + }); + + it("disallows branch names that begin with a slash", async () => { + expect(isValidGitBranchName("/feature-branch")).toBe(false); + }); + + it("disallows branch names that end with a slash", async () => { + expect(isValidGitBranchName("feature-branch/")).toBe(false); + }); + + it("disallows consecutive slashes (//)", async () => { + expect(isValidGitBranchName("feature//branch")).toBe(false); + }); + + it("disallows the sequence ..", async () => { + expect(isValidGitBranchName("feature..branch")).toBe(false); + }); + + it("disallows @{ in the name", async () => { + expect(isValidGitBranchName("feature@{branch")).toBe(false); + }); + + it("disallows names ending with .lock", async () => { + expect(isValidGitBranchName("feature-branch.lock")).toBe(false); + }); +}); + +describe("branchNameFromRef", () => { + it("returns the branch name for refs/heads/branch", async () => { + const result = branchNameFromRef("refs/heads/feature/branch"); + expect(result).toBe("feature/branch"); + }); + + it("returns the branch name for refs/remotes/origin/branch", async () => { + const result = branchNameFromRef("refs/remotes/origin/feature/branch"); + expect(result).toBe("origin/feature/branch"); + }); + + it("returns the tag name for refs/tags/v1.0.0", async () => { + const result = branchNameFromRef("refs/tags/v1.0.0"); + expect(result).toBe("v1.0.0"); + }); + + it("returns the input if just a branch name is given", async () => { + const result = branchNameFromRef("feature/branch"); + expect(result).toBe("feature/branch"); + }); + + it("returns null for an invalid ref", async () => { + const result = branchNameFromRef("refs/invalid/branch"); + expect(result).toBeNull(); + }); + + it("returns null for an empty string", async () => { + const result = branchNameFromRef(""); + expect(result).toBeNull(); + }); +}); From 705b375b3507ccd73c7a23bc7ec671c1c9b314ac Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 15:00:33 +0100 Subject: [PATCH 026/121] =?UTF-8?q?Button=20shortcut,=20don=E2=80=99t=20al?= =?UTF-8?q?low=20event=20to=20propagate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/primitives/Buttons.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index ed94d46f95..bafd772b0a 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -299,9 +299,11 @@ export const Button = forwardRef( if (props.shortcut) { useShortcutKeys({ shortcut: props.shortcut, - action: () => { + action: (e) => { if (innerRef.current) { innerRef.current.click(); + e.preventDefault(); + e.stopPropagation(); } }, disabled, From 8719fb007d5c0cc3e1c0aecf7cede8b4b5304a09 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 15:12:14 +0100 Subject: [PATCH 027/121] Better duplicate error message --- apps/webapp/app/routes/resources.branches.new.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/resources.branches.new.tsx b/apps/webapp/app/routes/resources.branches.new.tsx index ad3130b8ae..357f1ff150 100644 --- a/apps/webapp/app/routes/resources.branches.new.tsx +++ b/apps/webapp/app/routes/resources.branches.new.tsx @@ -58,7 +58,9 @@ export async function action({ request }: ActionFunctionArgs) { if (result.success) { if (result.alreadyExisted) { - submission.error = { branchName: `Branch "${result.branch.branchName}" already exists` }; + submission.error = { + branchName: `Branch "${result.branch.branchName}" already exists. You can archive it and create a new one with the same name.`, + }; return json(submission); } From 963540816436850fbe9b81aa24f545d96c188391 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 15:12:28 +0100 Subject: [PATCH 028/121] Filter out archived branches from the env selector --- .../navigation/EnvironmentSelector.tsx | 20 ++++++++++--------- .../OrganizationsPresenter.server.ts | 2 ++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 884d819b18..16a299e6ff 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -218,15 +218,17 @@ function Branches({ > {branchEnvironments.length > 0 ? (
- {branchEnvironments.map((env) => ( - {env.branchName}} - icon={} - isSelected={env.id === currentEnvironment.id} - /> - ))} + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + {env.branchName}} + icon={} + isSelected={env.id === currentEnvironment.id} + /> + ))}
) : (
diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index fca69abe6c..5949aebc5d 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -79,6 +79,7 @@ export class OrganizationsPresenter { isBranchableEnvironment: true, branchName: true, parentEnvironmentId: true, + archivedAt: true, orgMember: { select: { userId: true, @@ -184,6 +185,7 @@ export class OrganizationsPresenter { | "paused" | "parentEnvironmentId" | "isBranchableEnvironment" + | "archivedAt" > & { orgMember: null | { userId: string | undefined; From e7e821a1c48c73a5c1e9209b9cafc673327e8597 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 16:16:42 +0100 Subject: [PATCH 029/121] Archiving/creating tweaked some more --- .../app/components/BlankStatePanels.tsx | 6 +- .../navigation/EnvironmentSelector.tsx | 49 +++-- .../presenters/v3/BranchesPresenter.server.ts | 13 +- .../route.tsx | 164 ++++++++++++++--- .../app/routes/resources.branches.archive.tsx | 169 ++++++------------ .../app/routes/resources.branches.new.tsx | 142 --------------- .../app/services/archiveBranch.server.ts | 27 +-- .../app/services/upsertBranch.server.ts | 2 +- 8 files changed, 238 insertions(+), 334 deletions(-) delete mode 100644 apps/webapp/app/routes/resources.branches.new.tsx diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 4f5a8601d8..a67b46d64b 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -40,8 +40,8 @@ import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { useFeatures } from "~/hooks/useFeatures"; import { DialogContent, DialogTrigger, Dialog } from "./primitives/Dialog"; -import { NewBranchPanel } from "~/routes/resources.branches.new"; import { V4Badge } from "./V4Badge"; +import { NewBranchPanel } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; export function HasNoTasksDev() { return ( @@ -491,7 +491,7 @@ export function BranchesNoBranches({ if (limits.used >= limits.limit) { return ( You've reached the limit ({limits.used}/{limits.limit}) of branches for your plan. Upgrade - to get more branches. + to get branches. ); diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index 16a299e6ff..f37cf87ff6 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -1,25 +1,17 @@ -import { conform, useForm } from "@conform-to/react"; -import { parse } from "@conform-to/zod"; -import { ChevronRightIcon, Cog8ToothIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useActionData, useNavigation } from "@remix-run/react"; -import { GitBranchIcon } from "lucide-react"; +import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid"; +import { useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState } from "react"; -import { z } from "zod"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { useEnvironment } from "~/hooks/useEnvironment"; import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; import { useFeatures } from "~/hooks/useFeatures"; import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; import { cn } from "~/utils/cn"; import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder"; import { EnvironmentCombo } from "../environments/EnvironmentLabel"; -import { Button, ButtonContent } from "../primitives/Buttons"; -import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog"; -import { Fieldset } from "../primitives/Fieldset"; -import { FormButtons } from "../primitives/FormButtons"; -import { FormError } from "../primitives/FormError"; -import { Input } from "../primitives/Input"; -import { InputGroup } from "../primitives/InputGroup"; -import { Label } from "../primitives/Label"; +import { ButtonContent } from "../primitives/Buttons"; +import { Header2 } from "../primitives/Headers"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, @@ -29,14 +21,9 @@ import { PopoverSectionHeader, PopoverTrigger, } from "../primitives/Popover"; -import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; -import { schema } from "~/routes/resources.branches.new"; -import { useProject } from "~/hooks/useProject"; -import { useEnvironment } from "~/hooks/useEnvironment"; -import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; -import { Header2 } from "../primitives/Headers"; import { TextLink } from "../primitives/TextLink"; import { V4Badge } from "../V4Badge"; +import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; export function EnvironmentSelector({ organization, @@ -191,6 +178,14 @@ function Branches({ }, 150); }; + const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); + const state = + branchEnvironments.length === 0 + ? "no-branches" + : activeBranches.length === 0 + ? "no-active-branches" + : "has-branches"; + return ( setMenuOpen(open)} open={isMenuOpen}>
@@ -216,7 +211,7 @@ function Branches({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > - {branchEnvironments.length > 0 ? ( + {state === "has-branches" ? (
{branchEnvironments .filter((env) => env.archivedAt === null) @@ -224,13 +219,13 @@ function Branches({ {env.branchName}} - icon={} + title={{env.branchName}} + icon={} isSelected={env.id === currentEnvironment.id} /> ))}
- ) : ( + ) : state === "no-branches" ? (
@@ -245,6 +240,10 @@ function Branches({ v4 upgrade guide to learn more.
+ ) : ( +
+ All branches are archived. +
)}
0, branches: branches.flatMap((branch) => { if (branch.branchName === null) { return []; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index efa1c5dec3..bcb277a66f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -1,5 +1,6 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; import { - ArchiveBoxIcon, ArrowRightIcon, ArrowUpCircleIcon, CheckIcon, @@ -7,15 +8,18 @@ import { PlusIcon, } from "@heroicons/react/20/solid"; import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { useLocation, useNavigate, useSearchParams } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useActionData, useSearchParams } from "@remix-run/react"; +import { ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useCallback } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels"; import { Feedback } from "~/components/Feedback"; +import { V4Title } from "~/components/V4Badge"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { InlineCode } from "~/components/code/InlineCode"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; @@ -23,15 +27,21 @@ import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTrigger, } from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; import { Header3 } 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 { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; +import { Paragraph } from "~/components/primitives/Paragraph"; import { PopoverMenuItem } from "~/components/primitives/Popover"; import * as Property from "~/components/primitives/PropertyTable"; import { Switch } from "~/components/primitives/Switch"; @@ -46,26 +56,23 @@ import { TableRow, } from "~/components/primitives/Table"; import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"; -import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; -import { usePathName } from "~/hooks/usePathName"; import { useProject } from "~/hooks/useProject"; import { useThrottle } from "~/hooks/useThrottle"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server"; import { requireUserId } from "~/services/session.server"; +import { UpsertBranchService } from "~/services/upsertBranch.server"; import { cn } from "~/utils/cn"; import { + branchesPath, docsPath, ProjectParamSchema, v3BillingPath, v3EnvironmentPath, } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { NewBranchPanel } from "../resources.branches.new"; -import { ArchiveIcon, UnarchiveIcon } from "~/assets/icons/ArchiveIcon"; -import { V4Badge, V4Title } from "~/components/V4Badge"; -import { ArchiveButton, UnarchiveButton } from "../resources.branches.archive"; -import { Paragraph } from "~/components/primitives/Paragraph"; +import { ArchiveButton } from "../resources.branches.archive"; export const BranchesOptions = z.object({ search: z.string().optional(), @@ -98,6 +105,62 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } }; +export const CreateBranchOptions = z.object({ + parentEnvironmentId: z.string(), + branchName: z.string().min(1), + git: z + .object({ + repoOwner: z.string(), + repoName: z.string(), + refFull: z.string(), + refType: z.enum(["branch", "tag", "commit", "pull_request"]), + commitSha: z.string(), + createdBy: z.string().optional(), + pullRequestNumber: z.number().optional(), + }) + .optional(), +}); + +export type CreateBranchOptions = z.infer; + +export const schema = CreateBranchOptions.and( + z.object({ + failurePath: z.string(), + }) +); + +export async function action({ request }: ActionFunctionArgs) { + const userId = await requireUserId(request); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return redirectWithErrorMessage("/", request, "Invalid form data"); + } + + const upsertBranchService = new UpsertBranchService(); + const result = await upsertBranchService.call(userId, submission.value); + + if (result.success) { + if (result.alreadyExisted) { + submission.error = { + branchName: `Branch "${result.branch.branchName}" already exists. You can archive it and create a new one with the same name.`, + }; + return json(submission); + } + + return redirectWithSuccessMessage( + branchesPath(result.organization, result.project, result.branch), + request, + `Branch "${result.branch.branchName}" created` + ); + } + + submission.error = { branchName: result.error }; + return json(submission); +} + export default function Page() { const { branchableEnvironment, @@ -106,7 +169,7 @@ export default function Page() { limits, currentPage, totalPages, - totalCount, + hasBranches, } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -179,7 +242,7 @@ export default function Page() {
- {totalCount === 0 && !hasFilters ? ( + {!hasBranches ? ( {branches.length === 0 ? ( - - There are no matches for your filters + + There are no matches for your filters ) : ( branches.map((branch) => { @@ -268,15 +331,9 @@ export default function Page() { leadingIconClassName="text-blue-500" title="View branch" /> - {branch.archivedAt ? ( - - ) : ( + {!branch.archivedAt ? ( - )} + ) : null} } /> @@ -463,3 +520,64 @@ function UpgradePanel({
); } + +export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: string } }) { + const lastSubmission = useActionData(); + + const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({ + id: "create-branch", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onInput", + }); + + return ( + <> + New branch +
+
+
+ + + + + + + Must not contain: spaces ~{" "} + ^{" "} + :{" "} + ?{" "} + *{" "} + {"["}{" "} + \\{" "} + //{" "} + ..{" "} + {"@{"}{" "} + .lock + + {branchName.error} + + {form.error} + + Create branch + + } + cancelButton={ + + + + } + /> +
+
+
+ + ); +} diff --git a/apps/webapp/app/routes/resources.branches.archive.tsx b/apps/webapp/app/routes/resources.branches.archive.tsx index 7d2c1c2546..6658738ce0 100644 --- a/apps/webapp/app/routes/resources.branches.archive.tsx +++ b/apps/webapp/app/routes/resources.branches.archive.tsx @@ -1,33 +1,22 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { PlusIcon } from "@heroicons/react/24/outline"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useActionData, useLocation } from "@remix-run/react"; -import { type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { Form, useActionData, useFetcher, useLocation } from "@remix-run/react"; +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; -import { ArchiveIcon, UnarchiveIcon } from "~/assets/icons/ArchiveIcon"; -import { Feedback } from "~/components/Feedback"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTrigger, -} from "~/components/primitives/Dialog"; -import { Fieldset } from "~/components/primitives/Fieldset"; +import { ArchiveIcon } from "~/assets/icons/ArchiveIcon"; +import { Button } from "~/components/primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { useOrganization } from "~/hooks/useOrganizations"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { ArchiveBranchService } from "~/services/archiveBranch.server"; import { requireUserId } from "~/services/session.server"; -import { v3BillingPath, v3EnvironmentPath } from "~/utils/pathBuilder"; +import { branchesPath, v3EnvironmentPath } from "~/utils/pathBuilder"; const ArchiveBranchOptions = z.object({ environmentId: z.string(), - action: z.enum(["archive", "unarchive"]), }); const schema = ArchiveBranchOptions.and( @@ -52,11 +41,9 @@ export async function action({ request }: ActionFunctionArgs) { if (result.success) { return redirectWithSuccessMessage( - submission.value.redirectPath, + branchesPath(result.organization, result.project, result.branch), request, - `Branch "${result.branch.branchName}" ${ - submission.value.action === "archive" ? "archived" : "unarchived" - }` + `Branch "${result.branch.branchName}" archived` ); } @@ -68,10 +55,10 @@ export function ArchiveButton({ }: { environment: { id: string; branchName: string }; }) { + const lastSubmission = useActionData(); const location = useLocation(); - const lastSubmission = useActionData(); - const [form, { environmentId, action, redirectPath }] = useForm({ + const [form, { environmentId, redirectPath }] = useForm({ id: "archive-branch", lastSubmission: lastSubmission as any, onValidate({ formData }) { @@ -81,17 +68,9 @@ export function ArchiveButton({ }); return ( - <> -
- - - - {form.error} + + - - - ); -} - -export function UnarchiveButton({ - environment, - limits, - canUpgrade, -}: { - environment: { id: string; branchName: string }; - limits: { used: number; limit: number; isAtLimit: boolean }; - canUpgrade: boolean; -}) { - const location = useLocation(); - const organization = useOrganization(); - const lastSubmission = useActionData(); - - const [form, { environmentId, action, redirectPath }] = useForm({ - id: "archive-branch", - lastSubmission: lastSubmission as any, - onValidate({ formData }) { - return parse(formData, { schema }); - }, - shouldRevalidate: "onInput", - }); - - if (limits.isAtLimit) { - return ( - - - - - - You've exceeded your branch limit -
+ + - You've used {limits.used}/{limits.limit} of your branches. + This will permanently make this branch{" "} + read-only. You won't be able to trigger + runs, execute runs, or use the API for this branch. - You can archive one or upgrade your plan for more. -
- - {canUpgrade ? ( - - Upgrade - - ) : ( - Request more} - defaultValue="help" - /> - )} - -
-
- ); - } - - return ( - - - - - - + + You will still be able to view the branch and its associated runs. + + + Once archived you can create a new branch with the same name. + + {form.error} + + Archive branch + + } + cancelButton={ + + + + } + /> + + + +
); } diff --git a/apps/webapp/app/routes/resources.branches.new.tsx b/apps/webapp/app/routes/resources.branches.new.tsx deleted file mode 100644 index 357f1ff150..0000000000 --- a/apps/webapp/app/routes/resources.branches.new.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { conform, useForm } from "@conform-to/react"; -import { parse } from "@conform-to/zod"; -import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useActionData, useFetcher } from "@remix-run/react"; -import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; -import { z } from "zod"; -import { InlineCode } from "~/components/code/InlineCode"; -import { Button } from "~/components/primitives/Buttons"; -import { DialogHeader } from "~/components/primitives/Dialog"; -import { Fieldset } from "~/components/primitives/Fieldset"; -import { FormButtons } from "~/components/primitives/FormButtons"; -import { FormError } from "~/components/primitives/FormError"; -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 { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { requireUserId } from "~/services/session.server"; -import { UpsertBranchService } from "~/services/upsertBranch.server"; -import { branchesPath, v3EnvironmentPath } from "~/utils/pathBuilder"; - -export const CreateBranchOptions = z.object({ - parentEnvironmentId: z.string(), - branchName: z.string().min(1), - git: z - .object({ - repoOwner: z.string(), - repoName: z.string(), - refFull: z.string(), - refType: z.enum(["branch", "tag", "commit", "pull_request"]), - commitSha: z.string(), - createdBy: z.string().optional(), - pullRequestNumber: z.number().optional(), - }) - .optional(), -}); - -export type CreateBranchOptions = z.infer; - -export const schema = CreateBranchOptions.and( - z.object({ - failurePath: z.string(), - }) -); - -export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - - const formData = await request.formData(); - const submission = parse(formData, { schema }); - - if (!submission.value) { - return redirectWithErrorMessage("/", request, "Invalid form data"); - } - - const upsertBranchService = new UpsertBranchService(); - const result = await upsertBranchService.call(userId, submission.value); - - if (result.success) { - if (result.alreadyExisted) { - submission.error = { - branchName: `Branch "${result.branch.branchName}" already exists. You can archive it and create a new one with the same name.`, - }; - return json(submission); - } - - return redirectWithSuccessMessage( - branchesPath(result.organization, result.project, result.branch), - request, - `Branch "${result.branch.branchName}" created` - ); - } - - submission.error = { branchName: result.error }; - return json(submission); -} - -export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: string } }) { - const fetcher = useFetcher(); - - const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({ - id: "create-branch", - lastSubmission: fetcher.data as any, - onValidate({ formData }) { - return parse(formData, { schema }); - }, - shouldRevalidate: "onInput", - }); - - return ( - <> - New branch -
- -
- - - - - - - Must not contain: spaces ~{" "} - ^{" "} - :{" "} - ?{" "} - *{" "} - {"["}{" "} - \\{" "} - //{" "} - ..{" "} - {"@{"}{" "} - .lock - - {branchName.error} - - {form.error} - - Create branch - - } - cancelButton={ - - - - } - /> -
-
-
- - ); -} diff --git a/apps/webapp/app/services/archiveBranch.server.ts b/apps/webapp/app/services/archiveBranch.server.ts index 92b0c0dcdc..e0ff0e1174 100644 --- a/apps/webapp/app/services/archiveBranch.server.ts +++ b/apps/webapp/app/services/archiveBranch.server.ts @@ -1,7 +1,7 @@ import { type PrismaClient } from "@trigger.dev/database"; import { prisma } from "~/db.server"; -import { checkBranchLimit } from "./upsertBranch.server"; import { logger } from "./logger.server"; +import { nanoid } from "nanoid"; export class ArchiveBranchService { #prismaClient: PrismaClient; @@ -10,10 +10,7 @@ export class ArchiveBranchService { this.#prismaClient = prismaClient; } - public async call( - userId: string, - { action, environmentId }: { action: "archive" | "unarchive"; environmentId: string } - ) { + public async call(userId: string, { environmentId }: { environmentId: string }) { try { const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({ where: { @@ -50,24 +47,12 @@ export class ArchiveBranchService { }; } - if (action === "unarchive") { - const limits = await checkBranchLimit( - this.#prismaClient, - environment.organization.id, - environment.project.id - ); - - if (limits.isAtLimit) { - return { - success: false as const, - error: `You've used all ${limits.used} of ${limits.limit} branches for your plan. Upgrade to get more branches or archive some.`, - }; - } - } + const slug = `${environment.slug}-${nanoid(6)}`; + const shortcode = slug; const updatedBranch = await this.#prismaClient.runtimeEnvironment.update({ where: { id: environmentId }, - data: { archivedAt: action === "archive" ? new Date() : null }, + data: { archivedAt: new Date(), slug, shortcode }, }); return { @@ -77,7 +62,7 @@ export class ArchiveBranchService { project: environment.project, }; } catch (e) { - logger.error("ArchiveBranchService error", { environmentId, action, error: e }); + logger.error("ArchiveBranchService error", { environmentId, error: e }); return { success: false as const, error: "Failed to archive branch", diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 314d3ea204..4941007d84 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -2,9 +2,9 @@ import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/ import slug from "slug"; import { prisma } from "~/db.server"; import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; -import { type CreateBranchOptions } from "~/routes/resources.branches.new"; import { logger } from "./logger.server"; import { getLimit } from "./platform.v3.server"; +import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; //TODO Archive // - Save the slug in another column (archivedSlug) From 865691ed2dd311243e5cc7145a83f0f5bf304e69 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 17:18:52 +0100 Subject: [PATCH 030/121] Add an archived banner to the app, fixes for archived branches and upsells --- .../environments/EnvironmentLabel.tsx | 5 +- .../navigation/EnvironmentBanner.tsx | 83 ++++++++++++++++++ .../navigation/EnvironmentPausedBanner.tsx | 57 ------------ .../navigation/EnvironmentSelector.tsx | 86 ++++++++++++------- .../app/components/primitives/PageHeader.tsx | 8 +- .../app/hooks/useEnvironmentSwitcher.ts | 3 +- .../route.tsx | 65 +++++++++----- .../route.tsx | 14 ++- .../route.tsx | 59 +++++++++---- .../app/services/upsertBranch.server.ts | 12 --- 10 files changed, 242 insertions(+), 150 deletions(-) create mode 100644 apps/webapp/app/components/navigation/EnvironmentBanner.tsx delete mode 100644 apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 0cf4ae3807..2c903439cb 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -1,4 +1,3 @@ -import { GitBranchIcon } from "lucide-react"; import { BranchEnvironmentIconSmall, DeployedEnvironmentIconSmall, @@ -93,6 +92,10 @@ export function environmentTitle(environment: Environment, username?: string) { } export function environmentFullTitle(environment: Environment) { + if (environment.branchName) { + return environment.branchName; + } + switch (environment.type) { case "PRODUCTION": return "Production"; diff --git a/apps/webapp/app/components/navigation/EnvironmentBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentBanner.tsx new file mode 100644 index 0000000000..2a34b9e434 --- /dev/null +++ b/apps/webapp/app/components/navigation/EnvironmentBanner.tsx @@ -0,0 +1,83 @@ +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; +import { useLocation } from "@remix-run/react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment"; +import { useOptionalOrganization, useOrganization } from "~/hooks/useOrganizations"; +import { useOptionalProject, useProject } from "~/hooks/useProject"; +import { v3QueuesPath } from "~/utils/pathBuilder"; +import { environmentFullTitle } from "../environments/EnvironmentLabel"; +import { LinkButton } from "../primitives/Buttons"; +import { Icon } from "../primitives/Icon"; +import { Paragraph } from "../primitives/Paragraph"; + +export function EnvironmentBanner() { + const organization = useOptionalOrganization(); + const project = useOptionalProject(); + const environment = useOptionalEnvironment(); + + const isPaused = organization && project && environment && environment.paused; + const isArchived = organization && project && environment && environment.archivedAt; + + return ( + + {isArchived ? : isPaused ? : null} + + ); +} + +function PausedBanner() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const location = useLocation(); + const hideButton = location.pathname.endsWith("/queues"); + + return ( + +
+ + + {environmentFullTitle(environment)} environment paused. No new runs will be dequeued and + executed. + +
+ {hideButton ? null : ( +
+ + Manage + +
+ )} +
+ ); +} + +function ArchivedBranchBanner() { + const environment = useEnvironment(); + + return ( + +
+ + + "{environment.branchName}" branch is archived and is read-only. No new runs will be + dequeued and executed. + +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx deleted file mode 100644 index bc0501a210..0000000000 --- a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; -import { useLocation } from "@remix-run/react"; -import { AnimatePresence, motion } from "framer-motion"; -import { useOptionalEnvironment } from "~/hooks/useEnvironment"; -import { useOptionalOrganization } from "~/hooks/useOrganizations"; -import { useOptionalProject } from "~/hooks/useProject"; -import { v3QueuesPath } from "~/utils/pathBuilder"; -import { environmentFullTitle } from "../environments/EnvironmentLabel"; -import { LinkButton } from "../primitives/Buttons"; -import { Icon } from "../primitives/Icon"; -import { Paragraph } from "../primitives/Paragraph"; - -export function EnvironmentPausedBanner() { - const organization = useOptionalOrganization(); - const project = useOptionalProject(); - const environment = useOptionalEnvironment(); - const location = useLocation(); - - const hideButton = location.pathname.endsWith("/queues"); - - return ( - - {organization && project && environment && environment.paused ? ( - -
- - - {environmentFullTitle(environment)} environment paused. No new runs will be dequeued - and executed. - -
- {hideButton ? null : ( -
- - Manage - -
- )} -
- ) : null} -
- ); -} - -export function useShowEnvironmentPausedBanner() { - const environment = useOptionalEnvironment(); - const shouldShow = environment?.paused ?? false; - return { shouldShow }; -} diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index f37cf87ff6..def3996f85 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -24,6 +24,7 @@ import { import { TextLink } from "../primitives/TextLink"; import { V4Badge } from "../V4Badge"; import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; +import { Badge } from "../primitives/Badge"; export function EnvironmentSelector({ organization, @@ -186,6 +187,8 @@ function Branches({ ? "no-active-branches" : "has-branches"; + const currentBranchIsArchived = environment.archivedAt !== null; + return ( setMenuOpen(open)} open={isMenuOpen}>
@@ -211,40 +214,57 @@ function Branches({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > - {state === "has-branches" ? ( -
- {branchEnvironments - .filter((env) => env.archivedAt === null) - .map((env) => ( - {env.branchName}} - icon={} - isSelected={env.id === currentEnvironment.id} - /> - ))} -
- ) : state === "no-branches" ? ( -
-
- - Create your first branch +
+ {currentBranchIsArchived && ( + + {environment.branchName} + Archived + + } + icon={} + isSelected={environment.id === currentEnvironment.id} + /> + )} + {state === "has-branches" ? ( + <> + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + {env.branchName}} + icon={} + isSelected={env.id === currentEnvironment.id} + /> + ))} + + ) : state === "no-branches" ? ( +
+
+ + Create your first branch +
+ + Branches are a way to test new features in isolation before merging them into the + main environment. + + + Branches are only available when using or above. Read our{" "} + v4 upgrade guide to learn + more. +
- - Branches are a way to test new features in isolation before merging them into the - main environment. - - - Branches are only available when using or above. Read our{" "} - v4 upgrade guide to learn more. - -
- ) : ( -
- All branches are archived. -
- )} + ) : ( +
+ All branches are archived. +
+ )} +
{children}
- {showUpgradePrompt.shouldShow && organization ? ( - - ) : ( - - )} + {showUpgradePrompt.shouldShow && organization ? : }
); } diff --git a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts index 64c7cf9ebe..5c9aa2059b 100644 --- a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts +++ b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts @@ -84,7 +84,8 @@ export function routeForEnvironmentSwitch({ * Replace the /env// in the path so it's /env/ */ function replaceEnvInPath(path: string, environmentSlug: string) { - return path.replace(/env\/([a-z0-9-]+)/, `env/${environmentSlug}`); + //allow anything except / + return path.replace(/env\/([^/]+)/, `env/${environmentSlug}`); } function fullPath(location: Path) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index 01c296fb22..0f105b9a10 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -132,27 +132,50 @@ export default function Page() { ))} {!hasStaging && ( - - - - - - - Upgrade to get staging environment - - - - - - + <> + + + + + + + Upgrade to get staging environment + + + + + + + + + + + + + Upgrade to get preview branches + + + + + + + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index bcb277a66f..77d50b66b7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -73,6 +73,8 @@ import { } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { ArchiveButton } from "../resources.branches.archive"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { Badge } from "~/components/primitives/Badge"; export const BranchesOptions = z.object({ search: z.string().optional(), @@ -173,6 +175,7 @@ export default function Page() { } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const plan = useCurrentPlan(); const requiresUpgrade = @@ -291,13 +294,20 @@ export default function Page() { branches.map((branch) => { const path = v3EnvironmentPath(organization, project, branch); const cellClass = branch.archivedAt ? "opacity-50" : ""; + const isSelected = branch.id === environment.id; return (
- - + + + {isSelected && Current}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 7f04d9ba5d..c6acd362fa 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -236,23 +236,48 @@ export default function Page() { /> ))} {!hasStaging && ( - - - - - - - - - - - Upgrade your plan to add a Staging environment. - - - + <> + + + + + + + + + + + Upgrade your plan to add a Staging environment. + + + + + + + + + + + + + + Upgrade your plan to add Preview branches. + + + + )} {environmentIds.error} diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 4941007d84..2616ce3fda 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -6,18 +6,6 @@ import { logger } from "./logger.server"; import { getLimit } from "./platform.v3.server"; import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; -//TODO Archive -// - Save the slug in another column (archivedSlug) -// - Scramble the slug and shortcode columns -// - Disable creative, destructive actions in the dashboard -// - Replay, Cancel runs -// - Create, edit schedules - -// TODO Don't allow unarchiving - -//TODO -// When finding an environment for the URL ($envParam) only find non-archived ones - export class UpsertBranchService { #prismaClient: PrismaClient; From ff352ae50790c1f9ba127a3fedd780267af8b55f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 17:21:21 +0100 Subject: [PATCH 031/121] Fixed pagination --- apps/webapp/app/presenters/v3/BranchesPresenter.server.ts | 3 +-- .../route.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index 15c93f1e82..8b79f06de7 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -4,12 +4,11 @@ import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; import { checkBranchLimit } from "~/services/upsertBranch.server"; -import { getLimit } from "~/services/platform.v3.server"; type Result = Awaited>; export type Branch = Result["branches"][number]; -const BRANCHES_PER_PAGE = 10; +const BRANCHES_PER_PAGE = 25; type Options = z.infer; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 77d50b66b7..fe4012ccbe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -10,7 +10,7 @@ import { import { BookOpenIcon } from "@heroicons/react/24/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, useActionData, useSearchParams } from "@remix-run/react"; -import { ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useCallback } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -21,6 +21,7 @@ import { V4Title } from "~/components/V4Badge"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { InlineCode } from "~/components/code/InlineCode"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; @@ -56,6 +57,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip"; +import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useThrottle } from "~/hooks/useThrottle"; @@ -73,13 +75,11 @@ import { } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { ArchiveButton } from "../resources.branches.archive"; -import { useEnvironment } from "~/hooks/useEnvironment"; -import { Badge } from "~/components/primitives/Badge"; export const BranchesOptions = z.object({ search: z.string().optional(), showArchived: z.preprocess((val) => val === "true" || val === true, z.boolean()).optional(), - page: z.number().optional(), + page: z.preprocess((val) => Number(val), z.number()).optional(), }); export const loader = async ({ request, params }: LoaderFunctionArgs) => { From ac47c57fca77543cc9933bf43e5db0a871bc5d26 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 14 May 2025 17:53:17 +0100 Subject: [PATCH 032/121] Disable editing schedules, pausing queues, testing tasks --- apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts | 4 ++++ .../route.tsx | 4 ++++ .../route.tsx | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts index a1d9573c98..60329f0b6e 100644 --- a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts @@ -70,6 +70,10 @@ export class EditSchedulePresenter { throw new ServiceValidationError("No matching environment for project", 404); } + if (environment.archivedAt) { + throw new ServiceValidationError("This branch is archived", 400); + } + //get the latest BackgroundWorker const latestWorker = await findCurrentWorkerFromEnvironment(environment, this.#prismaClient); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 24446a7eea..8ab4b24ba4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -168,6 +168,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const redirectPath = `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues?page=${page}`; + if (environment.archivedAt) { + return redirectWithErrorMessage(redirectPath, request, "This branch is archived"); + } + switch (action) { case "environment-pause": const pauseService = new PauseEnvironmentService(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index abdfcda4c3..7e903bcad0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -115,6 +115,10 @@ export const action: ActionFunction = async ({ request, params }) => { return redirectBackWithErrorMessage(request, "Environment not found"); } + if (environment.archivedAt) { + return redirectBackWithErrorMessage(request, "This branch is archived"); + } + const testService = new TestTaskService(); try { const run = await testService.call(environment, submission.value); From 66ee041eed0fcb7974aaf1461a32384efd93b11a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 May 2025 11:26:41 +0100 Subject: [PATCH 033/121] =?UTF-8?q?Don=E2=80=99t=20allow=20replaying=20if?= =?UTF-8?q?=20the=20env=20is=20archived?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/v3/services/replayTaskRun.server.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index a521c4f435..9e98b6139a 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -27,6 +27,10 @@ export class ReplayTaskRunService extends BaseService { return; } + if (authenticatedEnvironment.archivedAt) { + return; + } + logger.info("Replaying task run", { taskRunId: existingTaskRun.id, taskRunFriendlyId: existingTaskRun.friendlyId, From 1dd75f83f2d73fb7448b8fd68d32f3229b2b7e9c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 May 2025 14:19:15 +0100 Subject: [PATCH 034/121] When deploying detect the correct environment --- .../api.v1.projects.$projectRef.$env.ts | 187 +++++++++++++----- 1 file changed, 139 insertions(+), 48 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts index bfcf174df9..0289e31a3d 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { GetProjectEnvResponse } from "@trigger.dev/core/v3"; +import { RuntimeEnvironment } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { env as processEnv } from "~/env.server"; @@ -9,9 +10,11 @@ import { authenticateApiRequestWithPersonalAccessToken } from "~/services/person const ParamsSchema = z.object({ projectRef: z.string(), - env: z.enum(["dev", "staging", "prod"]), + env: z.enum(["dev", "staging", "prod", "preview"]), }); +type ParamsSchema = z.infer; + export async function loader({ request, params }: LoaderFunctionArgs) { logger.info("projects get env", { url: request.url }); @@ -29,61 +32,38 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const { projectRef, env } = parsedParams.data; - const project = - env === "dev" - ? await prisma.project.findUnique({ - where: { - externalRef: projectRef, - organization: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, - }, - include: { - environments: { - where: { - orgMember: { - userId: authenticationResult.userId, - }, - }, - }, - }, - }) - : await prisma.project.findUnique({ - where: { - externalRef: projectRef, - organization: { - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, - }, - include: { - environments: { - where: { - slug: env === "prod" ? "prod" : "stg", - }, - }, + const project = await prisma.project.findFirst({ + where: { + externalRef: projectRef, + organization: { + members: { + some: { + userId: authenticationResult.userId, }, - }); + }, + }, + }, + }); if (!project) { return json({ error: "Project not found" }, { status: 404 }); } - if (!project.environments.length) { - return json( - { error: `Environment "${env}" not found or is unsupported for this project.` }, - { status: 404 } - ); + const url = new URL(request.url); + const branch = url.searchParams.get("branch"); + + const envResult = await getEnvironmentFromEnv({ + projectId: project.id, + userId: env, + env, + branch, + }); + + if (!envResult.success) { + return json({ error: envResult.error }, { status: 404 }); } - const runtimeEnv = project.environments[0]; + const runtimeEnv = envResult.environment; const result: GetProjectEnvResponse = { apiKey: runtimeEnv.apiKey, @@ -94,3 +74,114 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json(result); } + +async function getEnvironmentFromEnv({ + projectId, + userId, + env, + branch, +}: { + projectId: string; + userId: string; + env: ParamsSchema["env"]; + branch: string | null; +}): Promise< + | { + success: true; + environment: RuntimeEnvironment; + } + | { + success: false; + error: string; + } +> { + if (env === "dev") { + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId, + orgMember: { + userId: userId, + }, + }, + }); + + if (!environment) { + return { + success: false, + error: "Dev environment not found", + }; + } + + return { + success: true, + environment, + }; + } + + if (env !== "preview") { + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId, + slug: env === "staging" ? "stg" : "prod", + }, + }); + + if (!environment) { + return { + success: false, + error: `${env === "staging" ? "Staging" : "Production"} environment not found`, + }; + } + + return { + success: true, + environment, + }; + } + + // Preview branch + + if (!branch) { + return { + success: false, + error: "Preview branch not specified", + }; + } + + // Get the parent preview environment first + const previewEnvironment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId, + slug: "preview", + }, + }); + + if (!previewEnvironment) { + return { + success: false, + error: + "You don't have Preview branches enabled for this project. Visit the dashboard to enable them", + }; + } + + // Now get the branch environment + const branchEnvironment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId, + parentEnvironmentId: previewEnvironment.id, + branchName: branch, + }, + }); + + if (!branchEnvironment) { + return { + success: false, + error: `Preview branch "${branch}" not found`, + }; + } + + return { + success: true, + environment: branchEnvironment, + }; +} From 1ed453c63d9797d746b1157e030aa4dd188c02a9 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 May 2025 14:21:25 +0100 Subject: [PATCH 035/121] =?UTF-8?q?Get=20the=20projectClient=20when=20ther?= =?UTF-8?q?e=E2=80=99s=20a=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli-v3/src/apiClient.ts | 33 ++++++++++++++++-------- packages/cli-v3/src/utilities/session.ts | 2 ++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index 5c01eea72a..496927b3b0 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -161,21 +161,32 @@ export class CliApiClient { }); } - async getProjectEnv({ projectRef, env }: { projectRef: string; env: string }) { + async getProjectEnv({ + projectRef, + env, + branch, + }: { + projectRef: string; + env: string; + branch?: string; + }) { if (!this.accessToken) { throw new Error("getProjectDevEnv: No access token"); } - return wrapZodFetch( - GetProjectEnvResponse, - `${this.apiURL}/api/v1/projects/${projectRef}/${env}`, - { - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - } - ); + const url = new URL(`api/v1/projects/${projectRef}/${env}`, this.apiURL); + if (branch) { + url.searchParams.set("branch", branch); + } + + console.log("url", url.toString()); + + return wrapZodFetch(GetProjectEnvResponse, url.toString(), { + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + }); } async getEnvironmentVariables(projectRef: string) { diff --git a/packages/cli-v3/src/utilities/session.ts b/packages/cli-v3/src/utilities/session.ts index 1853a3847d..f042c68e35 100644 --- a/packages/cli-v3/src/utilities/session.ts +++ b/packages/cli-v3/src/utilities/session.ts @@ -94,6 +94,7 @@ export type GetEnvOptions = { apiUrl: string; projectRef: string; env: string; + branch?: string; profile: string; }; @@ -108,6 +109,7 @@ export async function getProjectClient(options: GetEnvOptions) { const projectEnv = await apiClient.getProjectEnv({ projectRef: options.projectRef, env: options.env, + branch: options.branch, }); if (!projectEnv.success) { From fdf3a866e87acdb7eb7199b8dab71e29638c046b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 May 2025 14:22:23 +0100 Subject: [PATCH 036/121] createGitMeta function, most code from the vercel CLI repo --- packages/cli-v3/package.json | 3 + packages/cli-v3/src/utilities/gitMeta.ts | 145 +++++++++++++++++++++++ pnpm-lock.yaml | 22 ++++ 3 files changed, 170 insertions(+) create mode 100644 packages/cli-v3/src/utilities/gitMeta.ts diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index ad6f4afbef..cf304fe377 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -51,6 +51,7 @@ "@epic-web/test-server": "^0.1.0", "@types/eventsource": "^1.1.15", "@types/gradient-string": "^1.1.2", + "@types/ini": "^4.1.1", "@types/object-hash": "3.0.6", "@types/polka": "^0.5.7", "@types/react": "^18.2.48", @@ -107,10 +108,12 @@ "eventsource": "^3.0.2", "evt": "^2.4.13", "fast-npm-meta": "^0.2.2", + "git-last-commit": "^1.0.1", "gradient-string": "^2.0.2", "has-flag": "^5.0.1", "import-in-the-middle": "1.11.0", "import-meta-resolve": "^4.1.0", + "ini": "^5.0.0", "jsonc-parser": "3.2.1", "magicast": "^0.3.4", "minimatch": "^10.0.1", diff --git a/packages/cli-v3/src/utilities/gitMeta.ts b/packages/cli-v3/src/utilities/gitMeta.ts new file mode 100644 index 0000000000..ddf93662ba --- /dev/null +++ b/packages/cli-v3/src/utilities/gitMeta.ts @@ -0,0 +1,145 @@ +import fs from "fs/promises"; +import { join } from "path"; +import ini from "ini"; +import git from "git-last-commit"; +import { x } from "tinyexec"; +import { logger } from "../utilities/logger.js"; + +export type GitMetadata = { + commitAuthorName?: string; + commitMessage?: string; + commitRef?: string; + commitSha?: string; + dirty?: boolean; + remoteUrl?: string; +}; + +export async function createGitMeta(directory: string): Promise { + const remoteUrl = await getOriginUrl(join(directory, ".git/config")); + + const [commitResult, dirtyResult] = await Promise.allSettled([ + getLastCommit(directory), + isDirty(directory), + ]); + + if (commitResult.status === "rejected") { + logger.debug( + `Failed to get last commit. The directory is likely not a Git repo, there are no latest commits, or it is corrupted.\n${commitResult.reason}` + ); + return; + } + + if (dirtyResult.status === "rejected") { + logger.debug(`Failed to determine if Git repo has been modified:\n${dirtyResult.reason}`); + return; + } + + const dirty = dirtyResult.value; + const commit = commitResult.value; + + return { + remoteUrl: remoteUrl ?? undefined, + commitAuthorName: commit.author.name, + commitMessage: commit.subject, + commitRef: commit.branch, + commitSha: commit.hash, + dirty, + }; +} + +function getLastCommit(directory: string): Promise { + return new Promise((resolve, reject) => { + git.getLastCommit( + (err, commit) => { + if (err) { + return reject(err); + } + + resolve(commit); + }, + { dst: directory } + ); + }); +} + +export async function isDirty(directory: string): Promise { + try { + const result = await x("git", ["--no-optional-locks", "status", "-s"], { + nodeOptions: { + cwd: directory, + }, + }); + + // Example output (when dirty): + // M ../fs-detectors/src/index.ts + return result.stdout.trim().length > 0; + } catch (error) { + throw error; + } +} + +export async function parseGitConfig(configPath: string) { + try { + return ini.parse(await fs.readFile(configPath, "utf8")); + } catch (err: unknown) { + logger.debug(`Error while parsing repo data: ${errorToString(err)}`); + return; + } +} + +export function pluckRemoteUrls(gitConfig: { + [key: string]: any; +}): { [key: string]: string } | undefined { + const remoteUrls: { [key: string]: string } = {}; + + for (const key of Object.keys(gitConfig)) { + if (key.includes("remote")) { + // ex. remote "origin" — matches origin + const remoteName = key.match(/(?<=").*(?=")/g)?.[0]; + const remoteUrl = gitConfig[key]?.url; + if (remoteName && remoteUrl) { + remoteUrls[remoteName] = remoteUrl; + } + } + } + + if (Object.keys(remoteUrls).length === 0) { + return; + } + + return remoteUrls; +} + +export async function getRemoteUrls( + configPath: string +): Promise<{ [key: string]: string } | undefined> { + const config = await parseGitConfig(configPath); + if (!config) { + return; + } + + const remoteUrls = pluckRemoteUrls(config); + return remoteUrls; +} + +export function pluckOriginUrl(gitConfig: { [key: string]: any }): string | undefined { + // Assuming "origin" is the remote url that the user would want to use + return gitConfig['remote "origin"']?.url; +} + +export async function getOriginUrl(configPath: string): Promise { + const gitConfig = await parseGitConfig(configPath); + if (!gitConfig) { + return null; + } + + const originUrl = pluckOriginUrl(gitConfig); + if (originUrl) { + return originUrl; + } + return null; +} + +function errorToString(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d71564af8..6ac7246374 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1197,6 +1197,9 @@ importers: fast-npm-meta: specifier: ^0.2.2 version: 0.2.2 + git-last-commit: + specifier: ^1.0.1 + version: 1.0.1 gradient-string: specifier: ^2.0.2 version: 2.0.2 @@ -1209,6 +1212,9 @@ importers: import-meta-resolve: specifier: ^4.1.0 version: 4.1.0 + ini: + specifier: ^5.0.0 + version: 5.0.0 jsonc-parser: specifier: 3.2.1 version: 3.2.1 @@ -1297,6 +1303,9 @@ importers: '@types/gradient-string': specifier: ^1.1.2 version: 1.1.2 + '@types/ini': + specifier: ^4.1.1 + version: 4.1.1 '@types/object-hash': specifier: 3.0.6 version: 3.0.6 @@ -18143,6 +18152,10 @@ packages: resolution: {integrity: sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w==} dev: true + /@types/ini@4.1.1: + resolution: {integrity: sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==} + dev: true + /@types/interpret@1.1.3: resolution: {integrity: sha512-uBaBhj/BhilG58r64mtDb/BEdH51HIQLgP5bmWzc5qCtFMja8dCk/IOJmk36j0lbi9QHwI6sbtUNGuqXdKCAtQ==} dependencies: @@ -24765,6 +24778,10 @@ packages: tar: 6.2.1 dev: false + /git-last-commit@1.0.1: + resolution: {integrity: sha512-FDSgeMqa7GnJDxt/q0AbrxbfeTyxp4ImxEw1e4nw6NUHA5FMhFUq33dTXI4Xdgcj1VQ1q5QLWF6WxFrJ8KCBOg==} + dev: false + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -25461,6 +25478,11 @@ packages: engines: {node: '>=10'} dev: true + /ini@5.0.0: + resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} + engines: {node: ^18.17.0 || >=20.5.0} + dev: false + /inline-style-parser@0.1.1: resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} dev: true From af5970dd0c1fb47b20c3164cefe9f8d7f6996ad2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 May 2025 14:24:02 +0100 Subject: [PATCH 037/121] Deploy, getting the correct environment client --- packages/cli-v3/src/commands/deploy.ts | 27 +++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 416404df3f..59c138d8de 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -36,11 +36,13 @@ import { login } from "./login.js"; import { updateTriggerPackages } from "./update.js"; import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js"; import { isDirectory } from "../utilities/fileSystem.js"; +import { createGitMeta } from "../utilities/gitMeta.js"; const DeployCommandOptions = CommonCommandOptions.extend({ dryRun: z.boolean().default(false), skipSyncEnvVars: z.boolean().default(false), - env: z.enum(["prod", "staging"]), + env: z.enum(["prod", "staging", "preview"]), + branch: z.string().optional(), loadImage: z.boolean().default(false), buildPlatform: z.enum(["linux/amd64", "linux/arm64"]).default("linux/amd64"), namespace: z.string().optional(), @@ -72,6 +74,10 @@ export function configureDeployCommand(program: Command) { "Deploy to a specific environment (currently only prod and staging are supported)", "prod" ) + .option( + "-b, --branch ", + "The preview branch to deploy to when passing --env preview. If not provided, we'll detect your local git branch." + ) .option("--skip-update-check", "Skip checking for @trigger.dev package updates") .option("-c, --config ", "The name of the config file, found at [path]") .option( @@ -215,11 +221,30 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { logger.debug("Resolved config", resolvedConfig); + const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); + logger.debug("gitMeta", gitMeta); + + let branch = options.branch; + if (options.env === "preview" && !branch) { + if (gitMeta?.commitRef) { + branch = gitMeta.commitRef; + } else { + throw new Error("Could not determine branch name from git metadata"); + } + } + + if (options.env === "preview" && !branch) { + throw new Error( + "You need to specify a preview branch when deploying to preview, pass --branch ." + ); + } + const projectClient = await getProjectClient({ accessToken: authorization.auth.accessToken, apiUrl: authorization.auth.apiUrl, projectRef: resolvedConfig.project, env: options.env, + branch, profile: options.profile, }); From 162f92240341e53aaf7649d8f159fdba64b8c1f5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 May 2025 14:47:45 +0100 Subject: [PATCH 038/121] Added git column to WorkerDeployment --- .../migration.sql | 3 +++ internal-packages/database/prisma/schema.prisma | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql b/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql new file mode 100644 index 0000000000..3f6e1ef8ec --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "WorkerDeployment" +ADD COLUMN "git" JSONB; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 4b059c4204..ffb14f7e28 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -390,6 +390,7 @@ model RuntimeEnvironment { parentEnvironmentId String? childEnvironments RuntimeEnvironment[] @relation("parentEnvironment") + // This is GitMeta type git Json? /// When set API calls will fail @@ -2877,6 +2878,9 @@ model WorkerDeployment { failedAt DateTime? errorData Json? + // This is GitMeta type + git Json? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 199110bcf3783af65d892d8fa24d3f9f5c35eb4b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 May 2025 14:48:08 +0100 Subject: [PATCH 039/121] Add GitMeta to core schemas --- packages/cli-v3/src/commands/deploy.ts | 1 + packages/cli-v3/src/utilities/gitMeta.ts | 12 ++---------- packages/core/src/v3/schemas/api.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 59c138d8de..53f81ff8e5 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -296,6 +296,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { selfHosted: options.selfHosted, registryHost: options.registry, namespace: options.namespace, + gitMeta, type: features.run_engine_v2 ? "MANAGED" : "V1", }); diff --git a/packages/cli-v3/src/utilities/gitMeta.ts b/packages/cli-v3/src/utilities/gitMeta.ts index ddf93662ba..a8113e4328 100644 --- a/packages/cli-v3/src/utilities/gitMeta.ts +++ b/packages/cli-v3/src/utilities/gitMeta.ts @@ -4,17 +4,9 @@ import ini from "ini"; import git from "git-last-commit"; import { x } from "tinyexec"; import { logger } from "../utilities/logger.js"; +import { GitMeta } from "@trigger.dev/core/v3"; -export type GitMetadata = { - commitAuthorName?: string; - commitMessage?: string; - commitRef?: string; - commitSha?: string; - dirty?: boolean; - remoteUrl?: string; -}; - -export async function createGitMeta(directory: string): Promise { +export async function createGitMeta(directory: string): Promise { const remoteUrl = await getOriginUrl(join(directory, ".git/config")); const [commitResult, dirtyResult] = await Promise.allSettled([ diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index bc73ce534b..763f4a8890 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -302,6 +302,17 @@ export const ExternalBuildData = z.object({ export type ExternalBuildData = z.infer; +export const GitMeta = z.object({ + commitAuthorName: z.string().optional(), + commitMessage: z.string().optional(), + commitRef: z.string().optional(), + commitSha: z.string().optional(), + dirty: z.boolean().optional(), + remoteUrl: z.string().optional(), +}); + +export type GitMeta = z.infer; + export const InitializeDeploymentResponseBody = z.object({ id: z.string(), contentHash: z.string(), @@ -320,6 +331,7 @@ export const InitializeDeploymentRequestBody = z.object({ registryHost: z.string().optional(), selfHosted: z.boolean().optional(), namespace: z.string().optional(), + gitMeta: GitMeta.optional(), type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(), }); From b5be9b4a7a30c5146d256bcfcc92a454065a41aa Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 15 May 2025 22:34:50 +0100 Subject: [PATCH 040/121] Create branch when deploying --- .../route.tsx | 13 +-- .../api.v1.projects.$projectRef.$env.ts | 7 +- .../api.v1.projects.$projectRef.branches.ts | 97 +++++++++++++++++++ .../app/services/upsertBranch.server.ts | 13 ++- .../services/initializeDeployment.server.ts | 11 ++- packages/cli-v3/src/apiClient.ts | 24 ++++- packages/cli-v3/src/commands/deploy.ts | 16 ++- packages/cli-v3/src/utilities/session.ts | 26 +++++ packages/core/src/v3/schemas/api.ts | 15 +++ 9 files changed, 196 insertions(+), 26 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index fe4012ccbe..68a850bad4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -75,6 +75,7 @@ import { } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { ArchiveButton } from "../resources.branches.archive"; +import { GitMeta } from "@trigger.dev/core/v3"; export const BranchesOptions = z.object({ search: z.string().optional(), @@ -110,17 +111,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export const CreateBranchOptions = z.object({ parentEnvironmentId: z.string(), branchName: z.string().min(1), - git: z - .object({ - repoOwner: z.string(), - repoName: z.string(), - refFull: z.string(), - refType: z.enum(["branch", "tag", "commit", "pull_request"]), - commitSha: z.string(), - createdBy: z.string().optional(), - pullRequestNumber: z.number().optional(), - }) - .optional(), + git: GitMeta.optional(), }); export type CreateBranchOptions = z.infer; diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts index 0289e31a3d..e2812d50a0 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -1,7 +1,6 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { json } from "@remix-run/server-runtime"; -import { GetProjectEnvResponse } from "@trigger.dev/core/v3"; -import { RuntimeEnvironment } from "@trigger.dev/database"; +import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type GetProjectEnvResponse } from "@trigger.dev/core/v3"; +import { type RuntimeEnvironment } from "@trigger.dev/database"; import { z } from "zod"; import { prisma } from "~/db.server"; import { env as processEnv } from "~/env.server"; diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts new file mode 100644 index 0000000000..23d88a53e6 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts @@ -0,0 +1,97 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { + tryCatch, + UpsertBranchRequestBody, + type GetProjectEnvResponse, +} from "@trigger.dev/core/v3"; +import { type RuntimeEnvironment } from "@trigger.dev/database"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { env as processEnv } from "~/env.server"; +import { logger } from "~/services/logger.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { UpsertBranchService } from "~/services/upsertBranch.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), +}); + +type ParamsSchema = z.infer; + +export async function action({ request, params }: ActionFunctionArgs) { + logger.info("projects get env", { url: request.url }); + + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + const { projectRef } = parsedParams.data; + + const project = await prisma.project.findFirst({ + select: { + id: true, + }, + where: { + externalRef: projectRef, + organization: { + members: { + some: { + userId: authenticationResult.userId, + }, + }, + }, + }, + }); + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + const [error, body] = await tryCatch(request.json()); + if (error) { + return json({ error: error.message }, { status: 400 }); + } + + const parsed = UpsertBranchRequestBody.safeParse(body); + if (!parsed.success) { + return json({ error: parsed.error.message }, { status: 400 }); + } + + const previewEnvironment = await prisma.runtimeEnvironment.findFirst({ + select: { + id: true, + }, + where: { + projectId: project.id, + slug: "preview", + }, + }); + + if (!previewEnvironment) { + return json( + { error: "You don't have preview branches setup. Go to the dashboard to enable them." }, + { status: 400 } + ); + } + + const { branch, env, git } = parsed.data; + + const service = new UpsertBranchService(); + const result = await service.call(authenticationResult.userId, { + branchName: branch, + parentEnvironmentId: previewEnvironment.id, + git, + }); + + if (!result.success) { + return json({ error: result.error }, { status: 400 }); + } + + return json(result.branch); +} diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 2616ce3fda..28e35ba83d 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -30,7 +30,7 @@ export class UpsertBranchService { } try { - const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({ + const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({ where: { id: parentEnvironmentId, organization: { @@ -58,10 +58,17 @@ export class UpsertBranchService { }, }); + if (!parentEnvironment) { + return { + success: false as const, + error: "You don't have preview branches setup. Go to the dashboard to enable them.", + }; + } + if (!parentEnvironment.isBranchableEnvironment) { return { success: false as const, - error: "Parent environment is not branchable", + error: "Your preview environment is not branchable", }; } @@ -131,7 +138,7 @@ export class UpsertBranchService { logger.error("CreateBranchService error", { error: e }); return { success: false as const, - error: "Failed to create branch", + error: e instanceof Error ? e.message : "Failed to create branch", }; } } diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index e99473ca9c..dfcfb3c4f8 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -1,14 +1,14 @@ -import { InitializeDeploymentRequestBody } from "@trigger.dev/core/v3"; +import { type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3"; +import { WorkerDeploymentType } from "@trigger.dev/database"; import { customAlphabet } from "nanoid"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { env } from "~/env.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { createRemoteImageBuild } from "../remoteImageBuilder.server"; import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; -import { env } from "~/env.server"; -import { WorkerDeploymentType } from "@trigger.dev/database"; -import { logger } from "~/services/logger.server"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); @@ -107,6 +107,7 @@ export class InitializeDeploymentService extends BaseService { triggeredById: triggeredBy?.id, type: payload.type, imageReference: isManaged ? undefined : unmanagedImageTag, + git: payload.gitMeta ?? undefined, }, }); diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index 496927b3b0..e63d86d959 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -31,6 +31,9 @@ import { DevDequeueRequestBody, DevDequeueResponseBody, PromoteDeploymentResponseBody, + GitMeta, + UpsertBranchResponseBody, + UpsertBranchRequestBody, } from "@trigger.dev/core/v3"; import { ApiResult, wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch"; import { logger } from "./utilities/logger.js"; @@ -179,8 +182,6 @@ export class CliApiClient { url.searchParams.set("branch", branch); } - console.log("url", url.toString()); - return wrapZodFetch(GetProjectEnvResponse, url.toString(), { headers: { Authorization: `Bearer ${this.accessToken}`, @@ -189,6 +190,25 @@ export class CliApiClient { }); } + async upsertBranch(projectRef: string, body: UpsertBranchRequestBody) { + if (!this.accessToken) { + throw new Error("upsertBranch: No access token"); + } + + return wrapZodFetch( + UpsertBranchResponseBody, + `${this.apiURL}/api/v1/projects/${projectRef}/branches`, + { + method: "POST", + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + } + ); + } + async getEnvironmentVariables(projectRef: string) { if (!this.accessToken) { throw new Error("getEnvironmentVariables: No access token"); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 53f81ff8e5..1405951eec 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -29,7 +29,7 @@ import { chalkError, cliLink, isLinksSupported, prettyError } from "../utilities import { loadDotEnvVars } from "../utilities/dotEnv.js"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; import { logger } from "../utilities/logger.js"; -import { getProjectClient } from "../utilities/session.js"; +import { getProjectClient, upsertBranch } from "../utilities/session.js"; import { getTmpDir } from "../utilities/tempDirectories.js"; import { spinner } from "../utilities/windows.js"; import { login } from "./login.js"; @@ -239,6 +239,20 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { ); } + if (options.env === "preview" && branch) { + const branchEnv = await upsertBranch({ + accessToken: authorization.auth.accessToken, + apiUrl: authorization.auth.apiUrl, + projectRef: resolvedConfig.project, + branch, + gitMeta, + }); + + if (!branchEnv) { + throw new Error(`Failed to create branch "${branch}"`); + } + } + const projectClient = await getProjectClient({ accessToken: authorization.auth.accessToken, apiUrl: authorization.auth.apiUrl, diff --git a/packages/cli-v3/src/utilities/session.ts b/packages/cli-v3/src/utilities/session.ts index f042c68e35..f5cdb2ffc3 100644 --- a/packages/cli-v3/src/utilities/session.ts +++ b/packages/cli-v3/src/utilities/session.ts @@ -3,6 +3,7 @@ import { CliApiClient } from "../apiClient.js"; import { readAuthConfigProfile } from "./configFiles.js"; import { getTracer } from "../telemetry/tracing.js"; import { logger } from "./logger.js"; +import { GitMeta } from "@trigger.dev/core/v3"; const tracer = getTracer(); @@ -134,3 +135,28 @@ export async function getProjectClient(options: GetEnvOptions) { client, }; } + +export type UpsertBranchOptions = { + accessToken: string; + apiUrl: string; + projectRef: string; + branch: string; + gitMeta: GitMeta | undefined; +}; + +export async function upsertBranch(options: UpsertBranchOptions) { + const apiClient = new CliApiClient(options.apiUrl, options.accessToken); + + const branchEnv = await apiClient.upsertBranch(options.projectRef, { + env: "preview", + branch: options.branch, + git: options.gitMeta, + }); + + if (!branchEnv.success) { + logger.error(`Failed to upsert branch: ${branchEnv.error}`); + return; + } + + return branchEnv.data; +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 763f4a8890..ef8cff1ebb 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -309,10 +309,25 @@ export const GitMeta = z.object({ commitSha: z.string().optional(), dirty: z.boolean().optional(), remoteUrl: z.string().optional(), + pullRequestNumber: z.number().optional(), }); export type GitMeta = z.infer; +export const UpsertBranchRequestBody = z.object({ + git: GitMeta.optional(), + env: z.enum(["preview"]), + branch: z.string(), +}); + +export type UpsertBranchRequestBody = z.infer; + +export const UpsertBranchResponseBody = z.object({ + id: z.string(), +}); + +export type UpsertBranchResponseBody = z.infer; + export const InitializeDeploymentResponseBody = z.object({ id: z.string(), contentHash: z.string(), From 06720bbd83e0936bc09269bfea349c31702e84e3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 16 May 2025 14:07:59 +0100 Subject: [PATCH 041/121] WIP on branch support in the API --- .../api.v1.projects.$projectRef.$env.ts | 75 ++------ .../api.v1.projects.$projectRef.branches.ts | 10 +- .../api.v1.projects.$projectRef.envvars.ts | 2 +- packages/cli-v3/src/apiClient.ts | 171 +++++++----------- packages/cli-v3/src/commands/deploy.ts | 15 +- packages/cli-v3/src/commands/workers/build.ts | 21 ++- packages/cli-v3/src/utilities/gitMeta.ts | 6 + packages/cli-v3/src/utilities/session.ts | 3 +- packages/core/src/v3/apiClient/getBranch.ts | 27 +++ packages/core/src/v3/apiClient/index.ts | 2 + 10 files changed, 148 insertions(+), 184 deletions(-) create mode 100644 packages/core/src/v3/apiClient/getBranch.ts diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts index e2812d50a0..9393e0ed68 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts @@ -48,14 +48,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Project not found" }, { status: 404 }); } - const url = new URL(request.url); - const branch = url.searchParams.get("branch"); - const envResult = await getEnvironmentFromEnv({ projectId: project.id, userId: env, env, - branch, }); if (!envResult.success) { @@ -78,12 +74,10 @@ async function getEnvironmentFromEnv({ projectId, userId, env, - branch, }: { projectId: string; userId: string; env: ParamsSchema["env"]; - branch: string | null; }): Promise< | { success: true; @@ -117,70 +111,37 @@ async function getEnvironmentFromEnv({ }; } - if (env !== "preview") { - const environment = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId, - slug: env === "staging" ? "stg" : "prod", - }, - }); - - if (!environment) { - return { - success: false, - error: `${env === "staging" ? "Staging" : "Production"} environment not found`, - }; - } - - return { - success: true, - environment, - }; - } - - // Preview branch - - if (!branch) { - return { - success: false, - error: "Preview branch not specified", - }; - } - - // Get the parent preview environment first - const previewEnvironment = await prisma.runtimeEnvironment.findFirst({ - where: { - projectId, - slug: "preview", - }, - }); - - if (!previewEnvironment) { - return { - success: false, - error: - "You don't have Preview branches enabled for this project. Visit the dashboard to enable them", - }; + let slug: "stg" | "prod" | "preview" = "prod"; + switch (env) { + case "staging": + slug = "stg"; + break; + case "prod": + slug = "prod"; + break; + case "preview": + slug = "preview"; + break; + default: + break; } - // Now get the branch environment - const branchEnvironment = await prisma.runtimeEnvironment.findFirst({ + const environment = await prisma.runtimeEnvironment.findFirst({ where: { projectId, - parentEnvironmentId: previewEnvironment.id, - branchName: branch, + slug, }, }); - if (!branchEnvironment) { + if (!environment) { return { success: false, - error: `Preview branch "${branch}" not found`, + error: `${env === "staging" ? "Staging" : "Production"} environment not found`, }; } return { success: true, - environment: branchEnvironment, + environment, }; } diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts index 23d88a53e6..4c28c39d0d 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts @@ -1,13 +1,7 @@ -import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; -import { - tryCatch, - UpsertBranchRequestBody, - type GetProjectEnvResponse, -} from "@trigger.dev/core/v3"; -import { type RuntimeEnvironment } from "@trigger.dev/database"; +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { env as processEnv } from "~/env.server"; import { logger } from "~/services/logger.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; import { UpsertBranchService } from "~/services/upsertBranch.server"; diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts index 4ea3960729..325bdd2453 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts @@ -1,4 +1,4 @@ -import { LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { authenticateApiRequest } from "~/services/apiAuth.server"; diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index e63d86d959..d01cfd3cbb 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -1,42 +1,37 @@ -import { z } from "zod"; -import { EventSource } from "eventsource"; import { CreateAuthorizationCodeResponseSchema, - GetPersonalAccessTokenResponseSchema, - WhoAmIResponseSchema, CreateBackgroundWorkerRequestBody, CreateBackgroundWorkerResponse, - StartDeploymentIndexingResponseBody, - GetProjectEnvResponse, - GetEnvironmentVariablesResponseBody, - InitializeDeploymentResponseBody, - InitializeDeploymentRequestBody, - StartDeploymentIndexingRequestBody, - GetDeploymentResponseBody, - GetProjectsResponseBody, - GetProjectResponseBody, - ImportEnvironmentVariablesRequestBody, + DevConfigResponseBody, + DevDequeueRequestBody, + DevDequeueResponseBody, EnvironmentVariableResponseBody, - TaskRunExecution, FailDeploymentRequestBody, FailDeploymentResponseBody, FinalizeDeploymentRequestBody, - WorkersListResponseBody, - WorkersCreateResponseBody, - WorkersCreateRequestBody, - TriggerTaskRequestBody, - TriggerTaskResponse, + GetDeploymentResponseBody, + GetEnvironmentVariablesResponseBody, GetLatestDeploymentResponseBody, - DevConfigResponseBody, - DevDequeueRequestBody, - DevDequeueResponseBody, + GetPersonalAccessTokenResponseSchema, + GetProjectEnvResponse, + GetProjectResponseBody, + GetProjectsResponseBody, + ImportEnvironmentVariablesRequestBody, + InitializeDeploymentRequestBody, + InitializeDeploymentResponseBody, PromoteDeploymentResponseBody, - GitMeta, - UpsertBranchResponseBody, + StartDeploymentIndexingRequestBody, + StartDeploymentIndexingResponseBody, + TaskRunExecution, + TriggerTaskRequestBody, + TriggerTaskResponse, UpsertBranchRequestBody, + UpsertBranchResponseBody, + WhoAmIResponseSchema, + WorkersCreateRequestBody, + WorkersCreateResponseBody, + WorkersListResponseBody, } from "@trigger.dev/core/v3"; -import { ApiResult, wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch"; -import { logger } from "./utilities/logger.js"; import { WorkloadDebugLogRequestBody, WorkloadHeartbeatRequestBody, @@ -46,6 +41,10 @@ import { WorkloadRunAttemptStartResponseBody, WorkloadRunLatestSnapshotResponseBody, } from "@trigger.dev/core/v3/workers"; +import { ApiResult, wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch"; +import { EventSource } from "eventsource"; +import { z } from "zod"; +import { logger } from "./utilities/logger.js"; export class CliApiClient { private engineURL: string; @@ -53,10 +52,12 @@ export class CliApiClient { constructor( public readonly apiURL: string, // TODO: consider making this required - public readonly accessToken?: string + public readonly accessToken?: string, + public readonly branch?: string ) { this.apiURL = apiURL.replace(/\/$/, ""); this.engineURL = this.apiURL; + this.branch = branch; } async createAuthorizationCode() { @@ -139,10 +140,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/projects/${projectRef}/background-workers`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), } ); @@ -164,30 +162,21 @@ export class CliApiClient { }); } - async getProjectEnv({ - projectRef, - env, - branch, - }: { - projectRef: string; - env: string; - branch?: string; - }) { + async getProjectEnv({ projectRef, env }: { projectRef: string; env: string }) { if (!this.accessToken) { throw new Error("getProjectDevEnv: No access token"); } - const url = new URL(`api/v1/projects/${projectRef}/${env}`, this.apiURL); - if (branch) { - url.searchParams.set("branch", branch); - } - - return wrapZodFetch(GetProjectEnvResponse, url.toString(), { - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - }); + return wrapZodFetch( + GetProjectEnvResponse, + `${this.apiURL}/api/v1/projects/${projectRef}/${env}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + } + ); } async upsertBranch(projectRef: string, body: UpsertBranchRequestBody) { @@ -218,10 +207,7 @@ export class CliApiClient { GetEnvironmentVariablesResponseBody, `${this.apiURL}/api/v1/projects/${projectRef}/envvars`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), } ); } @@ -240,10 +226,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/projects/${projectRef}/envvars/${slug}/import`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(params), } ); @@ -256,10 +239,7 @@ export class CliApiClient { return wrapZodFetch(InitializeDeploymentResponseBody, `${this.apiURL}/api/v1/deployments`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), }); } @@ -277,10 +257,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/deployments/${deploymentId}/background-workers`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), } ); @@ -296,10 +273,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/deployments/${id}/fail`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), } ); @@ -326,10 +300,7 @@ export class CliApiClient { url: `${this.apiURL}/api/v3/deployments/${id}/finalize`, request: { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), }, messages: { @@ -383,10 +354,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/deployments/${version}/promote`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), } ); } @@ -401,10 +369,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/deployments/${deploymentId}/start-indexing`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), } ); @@ -419,10 +384,7 @@ export class CliApiClient { GetDeploymentResponseBody, `${this.apiURL}/api/v1/deployments/${deploymentId}`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), } ); } @@ -434,10 +396,7 @@ export class CliApiClient { return wrapZodFetch(TriggerTaskResponse, `${this.apiURL}/api/v1/tasks/${taskId}/trigger`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body ?? {}), }); } @@ -480,10 +439,7 @@ export class CliApiClient { GetLatestDeploymentResponseBody, `${this.apiURL}/api/v1/deployments/latest`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), } ); } @@ -494,10 +450,7 @@ export class CliApiClient { } return wrapZodFetch(WorkersListResponseBody, `${this.apiURL}/api/v1/workers`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), }); } @@ -508,10 +461,7 @@ export class CliApiClient { return wrapZodFetch(WorkersCreateResponseBody, `${this.apiURL}/api/v1/workers`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(options), }); } @@ -698,4 +648,17 @@ export class CliApiClient { private setEngineURL(engineURL: string) { this.engineURL = engineURL.replace(/\/$/, ""); } + + private getHeaders() { + const headers: Record = { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }; + + if (this.branch) { + headers["x-trigger-branch"] = this.branch; + } + + return headers; + } } diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 1405951eec..da8a807609 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1,5 +1,5 @@ import { intro, log, outro } from "@clack/prompts"; -import { prepareDeploymentError } from "@trigger.dev/core/v3"; +import { getBranch, prepareDeploymentError } from "@trigger.dev/core/v3"; import { InitializeDeploymentResponseBody } from "@trigger.dev/core/v3/schemas"; import { Command, Option as CommandOption } from "commander"; import { resolve } from "node:path"; @@ -224,15 +224,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); logger.debug("gitMeta", gitMeta); - let branch = options.branch; - if (options.env === "preview" && !branch) { - if (gitMeta?.commitRef) { - branch = gitMeta.commitRef; - } else { - throw new Error("Could not determine branch name from git metadata"); - } - } - + const branch = getBranch({ specified: options.branch, gitMeta }); if (options.env === "preview" && !branch) { throw new Error( "You need to specify a preview branch when deploying to preview, pass --branch ." @@ -240,6 +232,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { } if (options.env === "preview" && branch) { + logger.debug("Upserting branch", { env: options.env, branch }); const branchEnv = await upsertBranch({ accessToken: authorization.auth.accessToken, apiUrl: authorization.auth.apiUrl, @@ -248,6 +241,8 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { gitMeta, }); + logger.debug("Upserted branch env", branchEnv); + if (!branchEnv) { throw new Error(`Failed to create branch "${branch}"`); } diff --git a/packages/cli-v3/src/commands/workers/build.ts b/packages/cli-v3/src/commands/workers/build.ts index 1a85a11269..9f865711a6 100644 --- a/packages/cli-v3/src/commands/workers/build.ts +++ b/packages/cli-v3/src/commands/workers/build.ts @@ -1,5 +1,5 @@ import { intro, outro, log } from "@clack/prompts"; -import { parseDockerImageReference, prepareDeploymentError } from "@trigger.dev/core/v3"; +import { getBranch, parseDockerImageReference, prepareDeploymentError } from "@trigger.dev/core/v3"; import { InitializeDeploymentResponseBody } from "@trigger.dev/core/v3/schemas"; import { Command, Option as CommandOption } from "commander"; import { resolve } from "node:path"; @@ -32,6 +32,7 @@ import { spinner } from "../../utilities/windows.js"; import { login } from "../login.js"; import { updateTriggerPackages } from "../update.js"; import { resolveAlwaysExternal } from "../../build/externals.js"; +import { createGitMeta } from "../../utilities/gitMeta.js"; const WorkersBuildCommandOptions = CommonCommandOptions.extend({ // docker build options @@ -45,7 +46,8 @@ const WorkersBuildCommandOptions = CommonCommandOptions.extend({ local: z.boolean().default(false), // TODO: default to true when webapp has no remote build support dryRun: z.boolean().default(false), skipSyncEnvVars: z.boolean().default(false), - env: z.enum(["prod", "staging"]), + env: z.enum(["prod", "staging", "preview"]), + branch: z.string().optional(), config: z.string().optional(), projectRef: z.string().optional(), apiUrl: z.string().optional(), @@ -69,6 +71,10 @@ export function configureWorkersBuildCommand(program: Command) { "Deploy to a specific environment (currently only prod and staging are supported)", "prod" ) + .option( + "-b, --branch ", + "The branch to deploy to. If not provided, the branch will be detected from the current git branch." + ) .option("--skip-update-check", "Skip checking for @trigger.dev package updates") .option("-c, --config ", "The name of the config file, found at [path]") .option( @@ -168,11 +174,22 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti logger.debug("Resolved config", resolvedConfig); + const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); + logger.debug("gitMeta", gitMeta); + + const branch = getBranch({ specified: options.branch, gitMeta }); + if (options.env === "preview" && !branch) { + throw new Error( + "You need to specify a preview branch when deploying to preview, pass --branch ." + ); + } + const projectClient = await getProjectClient({ accessToken: authorization.auth.accessToken, apiUrl: authorization.auth.apiUrl, projectRef: resolvedConfig.project, env: options.env, + branch: options.branch, profile: options.profile, }); diff --git a/packages/cli-v3/src/utilities/gitMeta.ts b/packages/cli-v3/src/utilities/gitMeta.ts index a8113e4328..042274b793 100644 --- a/packages/cli-v3/src/utilities/gitMeta.ts +++ b/packages/cli-v3/src/utilities/gitMeta.ts @@ -29,6 +29,11 @@ export async function createGitMeta(directory: string): Promise Date: Fri, 16 May 2025 15:35:00 +0100 Subject: [PATCH 042/121] Delete old createTaskRunAttempt fn --- packages/cli-v3/src/apiClient.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index d01cfd3cbb..d2be4b83a2 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -146,22 +146,6 @@ export class CliApiClient { ); } - async createTaskRunAttempt( - runFriendlyId: string - ): Promise>> { - if (!this.accessToken) { - throw new Error("creatTaskRunAttempt: No access token"); - } - - return wrapZodFetch(TaskRunExecution, `${this.apiURL}/api/v1/runs/${runFriendlyId}/attempts`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - }); - } - async getProjectEnv({ projectRef, env }: { projectRef: string; env: string }) { if (!this.accessToken) { throw new Error("getProjectDevEnv: No access token"); From 7d92ebaff183d8fc5e1ea6a4cbedda65c7830486 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 16 May 2025 16:16:58 +0100 Subject: [PATCH 043/121] apiAuth remove export from internal functions --- apps/webapp/app/services/apiAuth.server.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 03c5f128ee..32d0708eee 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -1,23 +1,22 @@ import { json } from "@remix-run/server-runtime"; -import { Prettify } from "@trigger.dev/core"; +import { type Prettify } from "@trigger.dev/core"; import { SignJWT, errors, jwtVerify } from "jose"; import { z } from "zod"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { findProjectByRef } from "~/models/project.server"; import { - RuntimeEnvironment, findEnvironmentByApiKey, findEnvironmentByPublicApiKey, } from "~/models/runtimeEnvironment.server"; +import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { logger } from "./logger.server"; import { - PersonalAccessTokenAuthenticationResult, + type PersonalAccessTokenAuthenticationResult, authenticateApiRequestWithPersonalAccessToken, isPersonalAccessToken, } from "./personalAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; -import { RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -160,7 +159,7 @@ export async function authenticateApiKey( * This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined. * It should be used from now on to ensure that the API key is always validated and provide a failure result. */ -export async function authenticateApiKeyWithFailure( +async function authenticateApiKeyWithFailure( apiKey: string, options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} ): Promise { @@ -254,19 +253,19 @@ export async function authenticateAuthorizationHeader( return authenticateApiKey(apiKey, { allowPublicKey, allowJWT }); } -export function isPublicApiKey(key: string) { +function isPublicApiKey(key: string) { return key.startsWith("pk_"); } -export function isSecretApiKey(key: string) { +function isSecretApiKey(key: string) { return key.startsWith("tr_"); } -export function getApiKeyFromRequest(request: Request) { +function getApiKeyFromRequest(request: Request) { return getApiKeyFromHeader(request.headers.get("Authorization")); } -export function getApiKeyFromHeader(authorization?: string | null) { +function getApiKeyFromHeader(authorization?: string | null) { if (typeof authorization !== "string" || !authorization) { return; } @@ -275,7 +274,7 @@ export function getApiKeyFromHeader(authorization?: string | null) { return apiKey; } -export function getApiKeyResult(apiKey: string): { +function getApiKeyResult(apiKey: string): { apiKey: string; type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT"; } { From 8758961d0bb3dbc776373779e9af1a3c645540ec Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 16 May 2025 16:17:20 +0100 Subject: [PATCH 044/121] =?UTF-8?q?Rename=20env=20var=20to=20=E2=80=9CTRIG?= =?UTF-8?q?GER=5FPREVIEW=5FBRANCH=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/v3/apiClient/getBranch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/v3/apiClient/getBranch.ts b/packages/core/src/v3/apiClient/getBranch.ts index 812341cea6..1dc87a3df1 100644 --- a/packages/core/src/v3/apiClient/getBranch.ts +++ b/packages/core/src/v3/apiClient/getBranch.ts @@ -13,7 +13,7 @@ export function getBranch({ } // not specified, so detect from process.env - const envVar = getEnvVar("TRIGGER_BRANCH"); + const envVar = getEnvVar("TRIGGER_PREVIEW_BRANCH"); if (envVar) { return envVar; } From f5c47d1178d811e63b65495a0dcf0fe8e51fd3c7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 16 May 2025 16:17:30 +0100 Subject: [PATCH 045/121] Add TRIGGER_PREVIEW_BRANCH to resolved env vars for runs --- .../environmentVariablesRepository.server.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index fdcbdc7e08..46da0a1ee7 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -780,6 +780,7 @@ export const RuntimeEnvironmentForEnvRepoPayload = { projectId: true, apiKey: true, organizationId: true, + branchName: true, }, } as const; @@ -888,6 +889,7 @@ async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmen key: "TRIGGER_SECRET_KEY", value: runtimeEnvironment.apiKey, }, + { key: "TRIGGER_API_URL", value: env.API_ORIGIN ?? env.APP_ORIGIN, @@ -906,6 +908,15 @@ async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmen }, ]; + if (runtimeEnvironment.branchName) { + result = result.concat([ + { + key: "TRIGGER_PREVIEW_BRANCH", + value: runtimeEnvironment.branchName, + }, + ]); + } + if (env.PROD_OTEL_BATCH_PROCESSING_ENABLED === "1") { result = result.concat([ { From efbaf6ca2bdce3f8dd17a0dd4f337dec05ec521d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 19 May 2025 13:34:55 +0100 Subject: [PATCH 046/121] First preview deploy and run working --- .../app/models/runtimeEnvironment.server.ts | 32 +++++++++++++++-- apps/webapp/app/services/apiAuth.server.ts | 34 +++++++++++-------- .../app/services/upsertBranch.server.ts | 4 +-- .../webapp/test/validateGitBranchName.test.ts | 14 ++++---- packages/cli-v3/src/commands/deploy.ts | 4 ++- packages/cli-v3/src/commands/workers/build.ts | 4 ++- packages/cli-v3/src/deploy/buildImage.ts | 14 ++++++++ .../entryPoints/managed-index-controller.ts | 6 +++- 8 files changed, 84 insertions(+), 28 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index d9f616f6cf..945989b0f9 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -1,12 +1,14 @@ import type { AuthenticatedEnvironment } from "@internal/run-engine"; import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@trigger.dev/database"; import { prisma } from "~/db.server"; +import { sanitizeBranchName } from "~/services/upsertBranch.server"; import { getUsername } from "~/utils/username"; export type { RuntimeEnvironment }; export async function findEnvironmentByApiKey( - apiKey: string + apiKey: string, + branchName: string | undefined ): Promise { const environment = await prisma.runtimeEnvironment.findFirst({ where: { @@ -16,6 +18,14 @@ export async function findEnvironmentByApiKey( project: true, organization: true, orgMember: true, + childEnvironments: branchName + ? { + where: { + branchName: sanitizeBranchName(branchName), + archivedAt: null, + }, + } + : undefined, }, }); @@ -24,11 +34,29 @@ export async function findEnvironmentByApiKey( return null; } + if (branchName && environment.type === "PREVIEW") { + const childEnvironment = environment?.childEnvironments.at(0); + + if (childEnvironment) { + return { + ...childEnvironment, + apiKey: environment.apiKey, + orgMember: environment.orgMember, + organization: environment.organization, + project: environment.project, + }; + } + + //A branch was specified but no child environment was found + return null; + } + return environment; } export async function findEnvironmentByPublicApiKey( - apiKey: string + apiKey: string, + branchName: string | undefined ): Promise { const environment = await prisma.runtimeEnvironment.findFirst({ where: { diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 32d0708eee..4a83155789 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -56,13 +56,13 @@ export async function authenticateApiRequest( request: Request, options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} ): Promise { - const apiKey = getApiKeyFromRequest(request); + const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return; } - const authentication = await authenticateApiKey(apiKey, options); + const authentication = await authenticateApiKey(apiKey, { ...options, branchName }); return authentication; } @@ -75,7 +75,7 @@ export async function authenticateApiRequestWithFailure( request: Request, options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} ): Promise { - const apiKey = getApiKeyFromRequest(request); + const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return { @@ -84,7 +84,7 @@ export async function authenticateApiRequestWithFailure( }; } - const authentication = await authenticateApiKeyWithFailure(apiKey, options); + const authentication = await authenticateApiKeyWithFailure(apiKey, { ...options, branchName }); return authentication; } @@ -94,7 +94,7 @@ export async function authenticateApiRequestWithFailure( */ export async function authenticateApiKey( apiKey: string, - options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} + options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {} ): Promise { const result = getApiKeyResult(apiKey); @@ -112,7 +112,7 @@ export async function authenticateApiKey( switch (result.type) { case "PUBLIC": { - const environment = await findEnvironmentByPublicApiKey(result.apiKey); + const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName); if (!environment) { return; } @@ -124,7 +124,7 @@ export async function authenticateApiKey( }; } case "PRIVATE": { - const environment = await findEnvironmentByApiKey(result.apiKey); + const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName); if (!environment) { return; } @@ -161,7 +161,7 @@ export async function authenticateApiKey( */ async function authenticateApiKeyWithFailure( apiKey: string, - options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} + options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {} ): Promise { const result = getApiKeyResult(apiKey); @@ -188,7 +188,7 @@ async function authenticateApiKeyWithFailure( switch (result.type) { case "PUBLIC": { - const environment = await findEnvironmentByPublicApiKey(result.apiKey); + const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName); if (!environment) { return { ok: false, @@ -203,7 +203,7 @@ async function authenticateApiKeyWithFailure( }; } case "PRIVATE": { - const environment = await findEnvironmentByApiKey(result.apiKey); + const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName); if (!environment) { return { ok: false, @@ -261,8 +261,14 @@ function isSecretApiKey(key: string) { return key.startsWith("tr_"); } -function getApiKeyFromRequest(request: Request) { - return getApiKeyFromHeader(request.headers.get("Authorization")); +function getApiKeyFromRequest(request: Request): { + apiKey: string | undefined; + branchName: string | undefined; +} { + const apiKey = getApiKeyFromHeader(request.headers.get("Authorization")); + const branchHeaderValue = request.headers.get("x-trigger-branch"); + + return { apiKey, branchName: branchHeaderValue ? branchHeaderValue : undefined }; } function getApiKeyFromHeader(authorization?: string | null) { @@ -301,7 +307,7 @@ export type DualAuthenticationResult = export async function authenticateProjectApiKeyOrPersonalAccessToken( request: Request ): Promise { - const apiKey = getApiKeyFromRequest(request); + const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return; } @@ -319,7 +325,7 @@ export async function authenticateProjectApiKeyOrPersonalAccessToken( }; } - const result = await authenticateApiKey(apiKey, { allowPublicKey: false }); + const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName }); if (!result) { return; diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 28e35ba83d..ae1e40b8b8 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -14,7 +14,7 @@ export class UpsertBranchService { } public async call(userId: string, { parentEnvironmentId, branchName, git }: CreateBranchOptions) { - const sanitizedBranchName = branchNameFromRef(branchName); + const sanitizedBranchName = sanitizeBranchName(branchName); if (!sanitizedBranchName) { return { success: false as const, @@ -198,7 +198,7 @@ export function isValidGitBranchName(branch: string): boolean { return true; } -export function branchNameFromRef(ref: string): string | null { +export function sanitizeBranchName(ref: string): string | null { if (!ref) return null; if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length); if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length); diff --git a/apps/webapp/test/validateGitBranchName.test.ts b/apps/webapp/test/validateGitBranchName.test.ts index 6a15b38cf0..dc5acc9224 100644 --- a/apps/webapp/test/validateGitBranchName.test.ts +++ b/apps/webapp/test/validateGitBranchName.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { branchNameFromRef, isValidGitBranchName } from "../app/services/upsertBranch.server"; +import { sanitizeBranchName, isValidGitBranchName } from "../app/services/upsertBranch.server"; describe("isValidGitBranchName", () => { it("returns true for a valid branch name", async () => { @@ -77,32 +77,32 @@ describe("isValidGitBranchName", () => { describe("branchNameFromRef", () => { it("returns the branch name for refs/heads/branch", async () => { - const result = branchNameFromRef("refs/heads/feature/branch"); + const result = sanitizeBranchName("refs/heads/feature/branch"); expect(result).toBe("feature/branch"); }); it("returns the branch name for refs/remotes/origin/branch", async () => { - const result = branchNameFromRef("refs/remotes/origin/feature/branch"); + const result = sanitizeBranchName("refs/remotes/origin/feature/branch"); expect(result).toBe("origin/feature/branch"); }); it("returns the tag name for refs/tags/v1.0.0", async () => { - const result = branchNameFromRef("refs/tags/v1.0.0"); + const result = sanitizeBranchName("refs/tags/v1.0.0"); expect(result).toBe("v1.0.0"); }); it("returns the input if just a branch name is given", async () => { - const result = branchNameFromRef("feature/branch"); + const result = sanitizeBranchName("feature/branch"); expect(result).toBe("feature/branch"); }); it("returns null for an invalid ref", async () => { - const result = branchNameFromRef("refs/invalid/branch"); + const result = sanitizeBranchName("refs/invalid/branch"); expect(result).toBeNull(); }); it("returns null for an empty string", async () => { - const result = branchNameFromRef(""); + const result = sanitizeBranchName(""); expect(result).toBeNull(); }); }); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index da8a807609..3b091cb88d 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -224,7 +224,8 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); logger.debug("gitMeta", gitMeta); - const branch = getBranch({ specified: options.branch, gitMeta }); + const branch = + options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined; if (options.env === "preview" && !branch) { throw new Error( "You need to specify a preview branch when deploying to preview, pass --branch ." @@ -417,6 +418,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { projectRef: resolvedConfig.project, apiUrl: projectClient.client.apiURL, apiKey: projectClient.client.accessToken!, + branchName: branch, authAccessToken: authorization.auth.accessToken, compilationPath: destination.path, buildEnvVars: buildManifest.build.env, diff --git a/packages/cli-v3/src/commands/workers/build.ts b/packages/cli-v3/src/commands/workers/build.ts index 9f865711a6..38af795bd7 100644 --- a/packages/cli-v3/src/commands/workers/build.ts +++ b/packages/cli-v3/src/commands/workers/build.ts @@ -177,7 +177,8 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); logger.debug("gitMeta", gitMeta); - const branch = getBranch({ specified: options.branch, gitMeta }); + const branch = + options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined; if (options.env === "preview" && !branch) { throw new Error( "You need to specify a preview branch when deploying to preview, pass --branch ." @@ -344,6 +345,7 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti projectRef: resolvedConfig.project, apiUrl: projectClient.client.apiURL, apiKey: projectClient.client.accessToken!, + branchName: branch, authAccessToken: authorization.auth.accessToken, compilationPath: destination.path, buildEnvVars: buildManifest.build.env, diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 6721317e40..1d4da183fe 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -35,6 +35,7 @@ export interface BuildImageOptions { extraCACerts?: string; apiUrl: string; apiKey: string; + branchName?: string; buildEnvVars?: Record; onLog?: (log: string) => void; @@ -65,6 +66,7 @@ export async function buildImage(options: BuildImageOptions) { extraCACerts, apiUrl, apiKey, + branchName, buildEnvVars, network, onLog, @@ -87,6 +89,7 @@ export async function buildImage(options: BuildImageOptions) { extraCACerts, apiUrl, apiKey, + branchName, buildEnvVars, network, onLog, @@ -124,6 +127,7 @@ export async function buildImage(options: BuildImageOptions) { extraCACerts, apiUrl, apiKey, + branchName, buildEnvVars, onLog, }); @@ -145,6 +149,7 @@ export interface DepotBuildImageOptions { buildPlatform: string; apiUrl: string; apiKey: string; + branchName?: string; loadImage?: boolean; noCache?: boolean; extraCACerts?: string; @@ -202,6 +207,8 @@ async function depotBuildImage(options: DepotBuildImageOptions): Promise; @@ -329,6 +337,8 @@ async function selfHostedBuildImage( "--build-arg", `TRIGGER_API_URL=${normalizeApiUrlForBuild(options.apiUrl)}`, "--build-arg", + `TRIGGER_PREVIEW_BRANCH=${options.branchName ?? ""}`, + "--build-arg", `TRIGGER_SECRET_KEY=${options.apiKey}`, ...(buildArgs || []), ...(options.extraCACerts ? ["--build-arg", `NODE_EXTRA_CA_CERTS=${options.extraCACerts}`] : []), @@ -572,6 +582,7 @@ ARG TRIGGER_PROJECT_REF ARG NODE_EXTRA_CA_CERTS ARG TRIGGER_SECRET_KEY ARG TRIGGER_API_URL +ARG TRIGGER_PREVIEW_BRANCH ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \ @@ -580,6 +591,7 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_CONTENT_HASH=\${TRIGGER_CONTENT_HASH} \ TRIGGER_SECRET_KEY=\${TRIGGER_SECRET_KEY} \ TRIGGER_API_URL=\${TRIGGER_API_URL} \ + TRIGGER_PREVIEW_BRANCH=\${TRIGGER_PREVIEW_BRANCH} \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ NODE_ENV=production @@ -676,6 +688,7 @@ ARG TRIGGER_PROJECT_REF ARG NODE_EXTRA_CA_CERTS ARG TRIGGER_SECRET_KEY ARG TRIGGER_API_URL +ARG TRIGGER_PREVIEW_BRANCH ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \ @@ -684,6 +697,7 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_CONTENT_HASH=\${TRIGGER_CONTENT_HASH} \ TRIGGER_SECRET_KEY=\${TRIGGER_SECRET_KEY} \ TRIGGER_API_URL=\${TRIGGER_API_URL} \ + TRIGGER_PREVIEW_BRANCH=\${TRIGGER_PREVIEW_BRANCH} \ TRIGGER_LOG_LEVEL=debug \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ NODE_ENV=production \ diff --git a/packages/cli-v3/src/entryPoints/managed-index-controller.ts b/packages/cli-v3/src/entryPoints/managed-index-controller.ts index d7c2f048a9..26b3332bb1 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-controller.ts @@ -26,7 +26,11 @@ async function bootstrap() { process.exit(1); } - const cliApiClient = new CliApiClient(env.TRIGGER_API_URL, env.TRIGGER_SECRET_KEY); + const cliApiClient = new CliApiClient( + env.TRIGGER_API_URL, + env.TRIGGER_SECRET_KEY, + env.TRIGGER_PREVIEW_BRANCH + ); if (!env.TRIGGER_PROJECT_REF) { console.error("TRIGGER_PROJECT_REF is not set"); From 6430da6d700131f604042e642994b47de3457b1d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 19 May 2025 13:58:52 +0100 Subject: [PATCH 047/121] Set the preview branch in the main SDK --- packages/core/src/v3/apiClient/index.ts | 13 ++++++++++++- packages/core/src/v3/apiClientManager/index.ts | 10 ++++++++-- packages/core/src/v3/apiClientManager/types.ts | 4 ++++ packages/react-hooks/src/hooks/useApiClient.ts | 6 ++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index a59ca0b32f..117792466c 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -136,11 +136,18 @@ export * from "./getBranch.js"; export class ApiClient { public readonly baseUrl: string; public readonly accessToken: string; + public readonly previewBranch?: string; private readonly defaultRequestOptions: ZodFetchOptions; - constructor(baseUrl: string, accessToken: string, requestOptions: ApiRequestOptions = {}) { + constructor( + baseUrl: string, + accessToken: string, + previewBranch?: string, + requestOptions: ApiRequestOptions = {} + ) { this.accessToken = accessToken; this.baseUrl = baseUrl.replace(/\/$/, ""); + this.previewBranch = previewBranch; this.defaultRequestOptions = mergeRequestOptions(DEFAULT_ZOD_FETCH_OPTIONS, requestOptions); } @@ -986,6 +993,10 @@ export class ApiClient { ), }; + if (this.previewBranch) { + headers["x-trigger-branch"] = this.previewBranch; + } + // Only inject the context if we are inside a task if (taskContext.isInsideTask) { headers["x-trigger-worker"] = "true"; diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index e2f47c9261..daaf354e0b 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -44,12 +44,18 @@ export class APIClientManagerAPI { ); } + get branchName(): string | undefined { + const config = this.#getConfig(); + const value = config?.previewBranch ?? getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? undefined; + return value ? value : undefined; + } + get client(): ApiClient | undefined { if (!this.baseURL || !this.accessToken) { return undefined; } - return new ApiClient(this.baseURL, this.accessToken); + return new ApiClient(this.baseURL, this.accessToken, this.branchName); } clientOrThrow(): ApiClient { @@ -57,7 +63,7 @@ export class APIClientManagerAPI { throw new ApiClientMissingError(this.apiClientMissingError()); } - return new ApiClient(this.baseURL, this.accessToken); + return new ApiClient(this.baseURL, this.accessToken, this.branchName); } runWithConfig Promise>( diff --git a/packages/core/src/v3/apiClientManager/types.ts b/packages/core/src/v3/apiClientManager/types.ts index b0e3da624c..2905af6d8e 100644 --- a/packages/core/src/v3/apiClientManager/types.ts +++ b/packages/core/src/v3/apiClientManager/types.ts @@ -10,5 +10,9 @@ export type ApiClientConfiguration = { * The access token to authenticate with the Trigger API. */ accessToken?: string; + /** + * The preview branch name (for preview environments) + */ + previewBranch?: string; requestOptions?: ApiRequestOptions; }; diff --git a/packages/react-hooks/src/hooks/useApiClient.ts b/packages/react-hooks/src/hooks/useApiClient.ts index b2cb9c6082..21f0aa53de 100644 --- a/packages/react-hooks/src/hooks/useApiClient.ts +++ b/packages/react-hooks/src/hooks/useApiClient.ts @@ -11,6 +11,8 @@ export type UseApiClientOptions = { accessToken?: string; /** Optional base URL for the API endpoints */ baseURL?: string; + /** Optional preview branch name for preview environments */ + previewBranch?: string; /** Optional additional request configuration */ requestOptions?: ApiRequestOptions; @@ -47,7 +49,7 @@ export function useApiClient(options?: UseApiClientOptions): ApiClient | undefin const baseUrl = options?.baseURL ?? auth?.baseURL ?? "https://api.trigger.dev"; const accessToken = options?.accessToken ?? auth?.accessToken; - + const previewBranch = options?.previewBranch ?? auth?.previewBranch; if (!accessToken) { if (options?.enabled === false) { return undefined; @@ -61,5 +63,5 @@ export function useApiClient(options?: UseApiClientOptions): ApiClient | undefin ...options?.requestOptions, }; - return new ApiClient(baseUrl, accessToken, requestOptions); + return new ApiClient(baseUrl, accessToken, previewBranch, requestOptions); } From fc0e4a7f013b7d663ed39aca0cd076382755c4bf Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 19 May 2025 15:05:01 +0100 Subject: [PATCH 048/121] Added git links to the preview branches table --- .../presenters/v3/BranchesPresenter.server.ts | 91 ++++++++++++++++--- .../route.tsx | 50 +++++++--- 2 files changed, 118 insertions(+), 23 deletions(-) diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index 8b79f06de7..7ab79541da 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -1,4 +1,5 @@ -import { z } from "zod"; +import { GitMeta } from "@trigger.dev/core/v3"; +import { type z } from "zod"; import { type PrismaClient, prisma } from "~/db.server"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; @@ -12,16 +13,30 @@ const BRANCHES_PER_PAGE = 25; type Options = z.infer; -export const BranchGit = z - .object({ - repo: z.string(), - pr: z.string().optional(), - branch: z.string().optional(), - commit: z.string().optional(), - }) - .nullable(); - -export type BranchGit = z.infer; +type GitMetaLinks = { + /** The cleaned repository URL without any username/password */ + repositoryUrl: string; + /** The branch name */ + branchName: string; + /** Link to the specific branch */ + branchUrl: string; + /** Link to the specific commit */ + commitUrl: string; + /** Link to the pull request (if available) */ + pullRequestUrl?: string; + /** The pull request number (if available) */ + pullRequestNumber?: number; + /** Link to compare this branch with main */ + compareUrl: string; + /** Shortened commit SHA (first 7 characters) */ + shortSha: string; + /** Whether the branch has uncommitted changes */ + isDirty: boolean; + /** The commit message */ + commitMessage: string; + /** The commit author */ + commitAuthor: string; +}; export class BranchesPresenter { #prismaClient: PrismaClient; @@ -155,11 +170,19 @@ export class BranchesPresenter { return []; } + let git: GitMetaLinks | null = null; + if (branch.git) { + const parsed = GitMeta.safeParse(branch.git); + if (parsed.success) { + git = this.processGitMeta(parsed.data); + } + } + return [ { ...branch, branchName: branch.branchName, - git: BranchGit.parse(branch.git), + git, } as const, ]; }), @@ -167,4 +190,48 @@ export class BranchesPresenter { limits, }; } + + private processGitMeta(gitMeta: GitMeta): GitMetaLinks | null { + if (!gitMeta || !gitMeta.remoteUrl) return null; + + // Clean the remote URL by removing any username/password and ensuring it's a proper GitHub URL + const cleanRemoteUrl = (() => { + try { + const url = new URL(gitMeta.remoteUrl); + // Remove any username/password from the URL + url.username = ""; + url.password = ""; + // Ensure we're using https + url.protocol = "https:"; + // Remove any trailing .git + return url.toString().replace(/\.git$/, ""); + } catch (e) { + // If URL parsing fails, try to clean it manually + return gitMeta.remoteUrl + .replace(/^git@github\.com:/, "https://github.com/") + .replace(/^https?:\/\/[^@]+@/, "https://") + .replace(/\.git$/, ""); + } + })(); + + if (!gitMeta.commitRef || !gitMeta.commitSha) return null; + + const shortSha = gitMeta.commitSha.slice(0, 7); + + return { + repositoryUrl: cleanRemoteUrl, + branchName: gitMeta.commitRef, + branchUrl: `${cleanRemoteUrl}/tree/${gitMeta.commitRef}`, + commitUrl: `${cleanRemoteUrl}/commit/${gitMeta.commitSha}`, + pullRequestUrl: gitMeta.pullRequestNumber + ? `${cleanRemoteUrl}/pull/${gitMeta.pullRequestNumber}` + : undefined, + pullRequestNumber: gitMeta.pullRequestNumber, + compareUrl: `${cleanRemoteUrl}/compare/main...${gitMeta.commitRef}`, + shortSha, + isDirty: gitMeta.dirty ?? false, + commitMessage: gitMeta.commitMessage ?? "", + commitAuthor: gitMeta.commitAuthorName ?? "", + }; + } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 68a850bad4..a182c6a474 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -76,6 +76,9 @@ import { import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { ArchiveButton } from "../resources.branches.archive"; import { GitMeta } from "@trigger.dev/core/v3"; +import { logger } from "~/services/logger.server"; +import { TextLink } from "~/components/primitives/TextLink"; +import { GitBranchIcon, GitCommitIcon, GitPullRequestIcon } from "lucide-react"; export const BranchesOptions = z.object({ search: z.string().optional(), @@ -101,6 +104,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return typedjson(result); } catch (error) { + logger.error("Error loading preview branches page", { error }); throw new Response(undefined, { status: 400, statusText: "Something went wrong, if this problem persists please contact support.", @@ -268,8 +272,7 @@ export default function Page() { Branch Created - Git branch - Git PR + Git Archived Actions @@ -278,7 +281,7 @@ export default function Page() { {branches.length === 0 ? ( - + There are no matches for your filters ) : ( @@ -305,15 +308,40 @@ export default function Page() { - {branch.git?.branch ? ( - - ) : ( - "–" - )} - - - {branch.git?.pr ? : "–"} +
+ {branch.git?.branchUrl && ( + } + iconSpacing="gap-x-1" + to={branch.git.branchUrl} + > + {branch.branchName} + + )} + {branch.git?.shortSha && ( + } + iconSpacing="gap-x-1" + > + {`${branch.git.shortSha} / ${branch.git.commitMessage}`} + + )} + {branch.git?.pullRequestUrl && ( + } + iconSpacing="gap-x-1" + > + #{branch.git.pullRequestNumber} + + )} +
+ {branch.archivedAt ? ( From e80177e49592fe28ed4f0c22b388355cfbcd39ab Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 19 May 2025 16:51:08 +0100 Subject: [PATCH 049/121] Better errors when replaying/testing archived branches --- .../route.tsx | 2 +- apps/webapp/app/v3/services/replayTaskRun.server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 7e903bcad0..6f40a21a05 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -116,7 +116,7 @@ export const action: ActionFunction = async ({ request, params }) => { } if (environment.archivedAt) { - return redirectBackWithErrorMessage(request, "This branch is archived"); + return redirectBackWithErrorMessage(request, "Can't run a test on an archived environment"); } const testService = new TestTaskService(); diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index 9e98b6139a..5b7ca7098b 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -28,7 +28,7 @@ export class ReplayTaskRunService extends BaseService { } if (authenticatedEnvironment.archivedAt) { - return; + throw new Error("Can't replay a run on an archived environment"); } logger.info("Replaying task run", { From 862af376d5fb7627e2296e386b4c4c974aeedf3e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 19 May 2025 18:12:28 +0100 Subject: [PATCH 050/121] =?UTF-8?q?Don=E2=80=99t=20dequeue=20archived=20en?= =?UTF-8?q?vironments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../environmentVariablesRepository.server.ts | 1 - .../run-engine/src/engine/db/worker.ts | 13 ++++++++++++- .../run-engine/src/engine/systems/dequeueSystem.ts | 13 +++++++++++++ packages/cli-v3/src/commands/deploy.ts | 2 ++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 46da0a1ee7..5fc60ccd73 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -889,7 +889,6 @@ async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmen key: "TRIGGER_SECRET_KEY", value: runtimeEnvironment.apiKey, }, - { key: "TRIGGER_API_URL", value: env.API_ORIGIN ?? env.APP_ORIGIN, diff --git a/internal-packages/run-engine/src/engine/db/worker.ts b/internal-packages/run-engine/src/engine/db/worker.ts index 3e4ce60b61..6b89eacbaf 100644 --- a/internal-packages/run-engine/src/engine/db/worker.ts +++ b/internal-packages/run-engine/src/engine/db/worker.ts @@ -32,7 +32,8 @@ type RunWithBackgroundWorkerTasksResult = | "TASK_NOT_IN_LATEST" | "TASK_NEVER_REGISTERED" | "BACKGROUND_WORKER_MISMATCH" - | "QUEUE_NOT_FOUND"; + | "QUEUE_NOT_FOUND" + | "RUN_ENVIRONMENT_ARCHIVED"; message: string; run: RunWithMininimalEnvironment; } @@ -69,6 +70,7 @@ export async function getRunWithBackgroundWorkerTasks( select: { id: true, type: true, + archivedAt: true, }, }, lockedToVersion: { @@ -88,6 +90,15 @@ export async function getRunWithBackgroundWorkerTasks( }; } + if (run.runtimeEnvironment.archivedAt) { + return { + success: false as const, + code: "RUN_ENVIRONMENT_ARCHIVED", + message: `Run is on an archived environment: ${run.id}`, + run, + }; + } + const workerId = run.lockedToVersionId ?? backgroundWorkerId; //get the relevant BackgroundWorker with tasks and deployment (if not DEV) diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index 91e47c7ec4..d02ac75f97 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -198,6 +198,19 @@ export class DequeueSystem { await this.$.runQueue.acknowledgeMessage(orgId, runId); return null; } + case "RUN_ENVIRONMENT_ARCHIVED": { + //this happens if the preview branch was archived + this.$.logger.warn( + "RunEngine.dequeueFromMasterQueue(): Run environment archived", + { + runId, + latestSnapshot: snapshot.id, + result, + } + ); + await this.$.runQueue.acknowledgeMessage(orgId, runId); + return null; + } case "NO_WORKER": case "TASK_NEVER_REGISTERED": case "QUEUE_NOT_FOUND": diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 3b091cb88d..0e4cc52f0c 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -244,6 +244,8 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { logger.debug("Upserted branch env", branchEnv); + log.success(`Using preview branch "${branch}"`); + if (!branchEnv) { throw new Error(`Failed to create branch "${branch}"`); } From 5de831784337ddb9f1ae8ccc55e6ec6c1a5d1b54 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 10:53:31 +0100 Subject: [PATCH 051/121] Env var resolution with parent environment --- .../app/models/runtimeEnvironment.server.ts | 9 ++- .../api.v1.projects.$projectRef.envvars.ts | 23 ++++++-- .../environmentVariablesRepository.server.ts | 57 ++++++++++++++----- .../worker/workerGroupTokenService.server.ts | 25 ++++++-- 4 files changed, 87 insertions(+), 27 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 945989b0f9..50b909ce78 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -34,7 +34,14 @@ export async function findEnvironmentByApiKey( return null; } - if (branchName && environment.type === "PREVIEW") { + if (environment.type === "PREVIEW") { + if (!branchName) { + logger.error("findEnvironmentByApiKey(): Preview env with no branch name provided", { + environmentId: environment.id, + }); + return null; + } + const childEnvironment = environment?.childEnvironments.at(0); if (childEnvironment) { diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts index 325bdd2453..151c182e16 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts @@ -17,13 +17,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // Next authenticate the request const authenticationResult = await authenticateApiRequest(request); - if (!authenticationResult) { return json({ error: "Invalid or Missing API key" }, { status: 401 }); } - const authenticatedEnv = authenticationResult.environment; - const { projectRef } = parsedParams.data; const project = await prisma.project.findFirst({ @@ -31,7 +28,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { externalRef: projectRef, environments: { some: { - id: authenticatedEnv.id, + id: authenticationResult.environment.id, }, }, }, @@ -41,7 +38,23 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json({ error: "Project not found" }, { status: 404 }); } - const variables = await resolveVariablesForEnvironment(authenticatedEnv); + const envVarEnvironment = await prisma.runtimeEnvironment.findFirst({ + where: { + id: authenticationResult.environment.id, + }, + include: { + parentEnvironment: true, + }, + }); + + if (!envVarEnvironment) { + return json({ error: "Environment not found" }, { status: 404 }); + } + + const variables = await resolveVariablesForEnvironment( + envVarEnvironment, + envVarEnvironment.parentEnvironment ?? undefined + ); return json({ variables: variables.reduce((acc: Record, variable) => { diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 5fc60ccd73..6d15fe93d6 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -546,7 +546,11 @@ export class EnvironmentVariablesRepository implements Repository { }); } - async getEnvironment(projectId: string, environmentId: string): Promise { + async getEnvironment( + projectId: string, + environmentId: string, + parentEnvironmentId?: string + ): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -568,36 +572,54 @@ export class EnvironmentVariablesRepository implements Repository { return []; } - return this.getEnvironmentVariables(projectId, environmentId); + return this.getEnvironmentVariables(projectId, environmentId, parentEnvironmentId); } async #getSecretEnvironmentVariables( projectId: string, - environmentId: string + environmentId: string, + parentEnvironmentId?: string ): Promise { const secretStore = getSecretStore("DATABASE", { prismaClient: this.prismaClient, }); - const secrets = await secretStore.getSecrets( + const parentSecrets = parentEnvironmentId + ? await secretStore.getSecrets( + SecretValue, + secretKeyEnvironmentPrefix(projectId, parentEnvironmentId) + ) + : []; + + const childSecrets = await secretStore.getSecrets( SecretValue, secretKeyEnvironmentPrefix(projectId, environmentId) ); - return secrets.map((secret) => { - const { key } = parseSecretKey(secret.key); + // Merge the secrets, we want child ones to override parent ones + const mergedSecrets = new Map(); + for (const secret of parentSecrets) { + mergedSecrets.set(secret.key, secret.value.secret); + } + for (const secret of childSecrets) { + mergedSecrets.set(secret.key, secret.value.secret); + } + + return Array.from(mergedSecrets.entries()).map(([key, value]) => { + const { key: parsedKey } = parseSecretKey(key); return { - key, - value: secret.value.secret, + key: parsedKey, + value, }; }); } async getEnvironmentVariables( projectId: string, - environmentId: string + environmentId: string, + parentEnvironmentId?: string ): Promise { - return this.#getSecretEnvironmentVariables(projectId, environmentId); + return this.#getSecretEnvironmentVariables(projectId, environmentId, parentEnvironmentId); } async delete(projectId: string, options: DeleteEnvironmentVariable): Promise { @@ -791,11 +813,13 @@ export type RuntimeEnvironmentForEnvRepo = Prisma.RuntimeEnvironmentGetPayload< export const environmentVariablesRepository = new EnvironmentVariablesRepository(); export async function resolveVariablesForEnvironment( - runtimeEnvironment: RuntimeEnvironmentForEnvRepo + runtimeEnvironment: RuntimeEnvironmentForEnvRepo, + parentEnvironment?: RuntimeEnvironmentForEnvRepo ) { const projectSecrets = await environmentVariablesRepository.getEnvironmentVariables( runtimeEnvironment.projectId, - runtimeEnvironment.id + runtimeEnvironment.id, + parentEnvironment?.id ); const overridableTriggerVariables = await resolveOverridableTriggerVariables(runtimeEnvironment); @@ -803,7 +827,7 @@ export async function resolveVariablesForEnvironment( const builtInVariables = runtimeEnvironment.type === "DEVELOPMENT" ? await resolveBuiltInDevVariables(runtimeEnvironment) - : await resolveBuiltInProdVariables(runtimeEnvironment); + : await resolveBuiltInProdVariables(runtimeEnvironment, parentEnvironment); return [...overridableTriggerVariables, ...projectSecrets, ...builtInVariables]; } @@ -883,11 +907,14 @@ async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment return [...result, ...commonVariables]; } -async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmentForEnvRepo) { +async function resolveBuiltInProdVariables( + runtimeEnvironment: RuntimeEnvironmentForEnvRepo, + parentEnvironment?: RuntimeEnvironmentForEnvRepo +) { let result: Array = [ { key: "TRIGGER_SECRET_KEY", - value: runtimeEnvironment.apiKey, + value: parentEnvironment?.apiKey ?? runtimeEnvironment.apiKey, }, { key: "TRIGGER_API_URL", diff --git a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts index 5c3a8d1cce..341122f8fa 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts @@ -457,7 +457,11 @@ export class WorkerGroupTokenService extends WithRunEngine { }, include: { deployment: true, - environment: true, + environment: { + include: { + parentEnvironment: true, + }, + }, }, }); @@ -481,6 +485,10 @@ export class WorkerGroupTokenService extends WithRunEngine { export const WorkerInstanceEnv = z.enum(["dev", "staging", "prod"]).default("prod"); export type WorkerInstanceEnv = z.infer; +type EnvironmentWithParent = RuntimeEnvironment & { + parentEnvironment?: RuntimeEnvironment | null; +}; + export type AuthenticatedWorkerInstanceOptions = WithRunEngineOptions<{ type: WorkerInstanceGroupType; name: string; @@ -491,7 +499,7 @@ export type AuthenticatedWorkerInstanceOptions = WithRunEngineOptions<{ deploymentId?: string; backgroundWorkerId?: string; runnerId?: string; - environment: RuntimeEnvironment | null; + environment: EnvironmentWithParent | null; }>; export class AuthenticatedWorkerInstance extends WithRunEngine { @@ -501,7 +509,7 @@ export class AuthenticatedWorkerInstance extends WithRunEngine { readonly workerInstanceId: string; readonly runnerId?: string; readonly masterQueue: string; - readonly environment: RuntimeEnvironment | null; + readonly environment: EnvironmentWithParent | null; readonly deploymentId?: string; readonly backgroundWorkerId?: string; @@ -686,13 +694,17 @@ export class AuthenticatedWorkerInstance extends WithRunEngine { where: { id: engineResult.execution.environment.id, }, + include: { + parentEnvironment: true, + }, })); const envVars = environment ? await this.getEnvVars( environment, engineResult.run.id, - engineResult.execution.machine ?? defaultMachinePreset + engineResult.execution.machine ?? defaultMachinePreset, + environment.parentEnvironment ?? undefined ) : {}; @@ -797,9 +809,10 @@ export class AuthenticatedWorkerInstance extends WithRunEngine { private async getEnvVars( environment: RuntimeEnvironment, runId: string, - machinePreset: MachinePreset + machinePreset: MachinePreset, + parentEnvironment?: RuntimeEnvironment ): Promise> { - const variables = await resolveVariablesForEnvironment(environment); + const variables = await resolveVariablesForEnvironment(environment, parentEnvironment); const jwt = await generateJWTTokenForEnvironment(environment, { run_id: runId, From 43dd223fdc9464edb4f35ec14a9f5f628358ff18 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 10:56:09 +0100 Subject: [PATCH 052/121] Hello world default machine small-2x to save my memory --- references/hello-world/trigger.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/references/hello-world/trigger.config.ts b/references/hello-world/trigger.config.ts index 6712952fdf..f07c199129 100644 --- a/references/hello-world/trigger.config.ts +++ b/references/hello-world/trigger.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ randomize: true, }, }, - machine: "large-1x", + machine: "small-2x", build: { extensions: [ { From 082bdad0b64831630376535041c2e84d2e3508e3 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 12:18:26 +0100 Subject: [PATCH 053/121] Fix for more env var functions --- apps/webapp/app/models/runtimeEnvironment.server.ts | 1 + .../route.tsx | 6 +++--- ...api.v1.projects.$projectRef.envvars.$slug.$name.ts | 3 ++- .../api.v1.projects.$projectRef.envvars.$slug.ts | 3 ++- .../environmentVariablesRepository.server.ts | 11 +++++++---- references/hello-world/src/trigger/envvars.ts | 4 ++-- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 50b909ce78..4aea38b81b 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -1,6 +1,7 @@ import type { AuthenticatedEnvironment } from "@internal/run-engine"; import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@trigger.dev/database"; import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; import { sanitizeBranchName } from "~/services/upsertBranch.server"; import { getUsername } from "~/utils/username"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index c6acd362fa..dfc0344613 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -1,5 +1,5 @@ import { - FieldConfig, + type FieldConfig, list, requestIntent, useFieldList, @@ -9,9 +9,9 @@ import { import { parse } from "@conform-to/zod"; import { LockClosedIcon, LockOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react"; -import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import dotenv from "dotenv"; -import { RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts index 2b63697990..7682f6bbbe 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts @@ -125,7 +125,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const variables = await repository.getEnvironmentWithRedactedSecrets( environment.project.id, - environment.id + environment.id, + environment.parentEnvironmentId ?? undefined ); const environmentVariable = variables.find((v) => v.key === parsedParams.data.name); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index eae0e586e7..6fb1cfba1d 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -82,7 +82,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const variables = await repository.getEnvironmentWithRedactedSecrets( environment.project.id, - environment.id + environment.id, + environment.parentEnvironmentId ?? undefined ); return json( diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 6d15fe93d6..55cbce799f 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -512,14 +512,17 @@ export class EnvironmentVariablesRepository implements Repository { async getEnvironmentWithRedactedSecrets( projectId: string, - environmentId: string + environmentId: string, + parentEnvironmentId?: string ): Promise { - const variables = await this.getEnvironment(projectId, environmentId); + const variables = await this.getEnvironment(projectId, environmentId, parentEnvironmentId); // Get the keys of all secret variables const secretValues = await this.prismaClient.environmentVariableValue.findMany({ where: { - environmentId, + environmentId: parentEnvironmentId + ? { in: [environmentId, parentEnvironmentId] } + : environmentId, isSecret: true, }, select: { @@ -532,7 +535,7 @@ export class EnvironmentVariablesRepository implements Repository { }); const secretVarKeys = secretValues.map((r) => r.variable.key); - // Filter out secret variables if includeSecrets is false + // Filter out secret variables return variables.map((v) => { if (secretVarKeys.includes(v.key)) { return { diff --git a/references/hello-world/src/trigger/envvars.ts b/references/hello-world/src/trigger/envvars.ts index 3a6bd700d8..efc1dc79cb 100644 --- a/references/hello-world/src/trigger/envvars.ts +++ b/references/hello-world/src/trigger/envvars.ts @@ -32,8 +32,8 @@ export const secretEnvVar = task({ //get secret env var const secretEnvVar = vars.find((v) => v.isSecret); - assert.equal(secretEnvVar?.isSecret, true); - assert.equal(secretEnvVar?.value, ""); + assert.equal(secretEnvVar?.isSecret, true, "no secretEnvVar found"); + assert.equal(secretEnvVar?.value, "", "secretEnvVar value should be redacted"); //retrieve the secret env var const retrievedSecret = await envvars.retrieve( From e203f3c992c1c7ab722341fbd5bece0f39c90f97 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 12:57:25 +0100 Subject: [PATCH 054/121] Only return non-archived envs --- .../app/presenters/v3/EnvironmentVariablesPresenter.server.ts | 1 + .../route.tsx | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 2053fab532..15e968a178 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -83,6 +83,7 @@ export class EnvironmentVariablesPresenter { project: { slug: projectSlug, }, + archivedAt: null, }, }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 8bae828d8c..430668c364 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -180,7 +180,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { export default function Page() { const [isOpen, setIsOpen] = useState(false); const { environments, hasStaging } = useTypedLoaderData(); - const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState([]); const lastSubmission = useActionData(); const navigation = useNavigation(); const navigate = useNavigate(); From 932a4eb932bec96e7dfb7bc146464cd9720a54aa Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 13:01:25 +0100 Subject: [PATCH 055/121] Switch to controlled state for the checkboxes --- .../route.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 430668c364..bb1dc5f90f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -186,6 +186,7 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState>(new Set()); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post"; @@ -199,6 +200,22 @@ export default function Page() { shouldRevalidate: "onSubmit", }); + const handleEnvironmentChange = ( + environmentId: string, + isChecked: boolean, + environmentType?: string + ) => { + setSelectedEnvironmentIds((prev) => { + const newSet = new Set(prev); + if (isChecked) { + newSet.add(environmentId); + } else { + newSet.delete(environmentId); + } + return newSet; + }); + }; + const [revealAll, setRevealAll] = useState(true); useEffect(() => { @@ -229,7 +246,10 @@ export default function Page() { id={environment.id} value={environment.id} name="environmentIds" - type="radio" + defaultChecked={selectedEnvironmentIds.has(environment.id)} + onChange={(isChecked) => + handleEnvironmentChange(environment.id, isChecked, environment.type) + } label={} variant="button" /> From 615080f7ac6f6380f2cb9d96611d40b493012844 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 13:02:32 +0100 Subject: [PATCH 056/121] Uncheck everything when PREVIEW is checked --- .../route.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index bb1dc5f90f..007f817897 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -207,11 +207,24 @@ export default function Page() { ) => { setSelectedEnvironmentIds((prev) => { const newSet = new Set(prev); + if (isChecked) { - newSet.add(environmentId); + if (environmentType === "PREVIEW") { + // If PREVIEW is checked, clear all other selections + newSet.clear(); + newSet.add(environmentId); + } else { + // If a non-PREVIEW environment is checked, remove PREVIEW if it's selected + const previewEnv = environments.find((env) => env.type === "PREVIEW"); + if (previewEnv) { + newSet.delete(previewEnv.id); + } + newSet.add(environmentId); + } } else { newSet.delete(environmentId); } + return newSet; }); }; From 184835bf673d3fb5237580b94083ceb41cb6b4c4 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 13:49:23 +0100 Subject: [PATCH 057/121] WIP on branch UI --- .../route.tsx | 85 +++++++++++++++---- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 007f817897..957a313717 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -51,6 +51,7 @@ import { } from "~/utils/pathBuilder"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository"; +import { Select, SelectItem } from "~/components/primitives/Select"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -187,6 +188,14 @@ export default function Page() { const project = useProject(); const environment = useEnvironment(); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState>(new Set()); + const [selectedBranchIds, setSelectedBranchIds] = useState([]); + + const branchEnvironments = environments.filter((env) => env.branchName); + const nonBranchEnvironments = environments.filter((env) => !env.branchName); + const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id)); + const previewIsSelected = selectedEnvironments.some( + (env) => env.branchName !== null || env.type === "PREVIEW" + ); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post"; @@ -210,8 +219,9 @@ export default function Page() { if (isChecked) { if (environmentType === "PREVIEW") { - // If PREVIEW is checked, clear all other selections + // If PREVIEW is checked, clear all other selections including branches newSet.clear(); + setSelectedBranchIds([]); newSet.add(environmentId); } else { // If a non-PREVIEW environment is checked, remove PREVIEW if it's selected @@ -229,6 +239,19 @@ export default function Page() { }); }; + const handleBranchChange = (branchIds: string[]) => { + setSelectedBranchIds(branchIds); + setSelectedEnvironmentIds((prev) => { + const newSet = new Set(prev); + // Remove PREVIEW environment if it was selected + const previewEnv = environments.find((env) => env.branchName === null); + if (previewEnv) { + newSet.delete(previewEnv.id); + } + return newSet; + }); + }; + const [revealAll, setRevealAll] = useState(true); useEffect(() => { @@ -251,22 +274,20 @@ export default function Page() {
- {environments - .filter((env) => !env.branchName) - .map((environment) => ( - - handleEnvironmentChange(environment.id, isChecked, environment.type) - } - label={} - variant="button" - /> - ))} + {nonBranchEnvironments.map((environment) => ( + + handleEnvironmentChange(environment.id, isChecked, environment.type) + } + label={} + variant="button" + /> + ))} {!hasStaging && ( <> @@ -318,6 +339,36 @@ export default function Page() { file when running locally. + + {previewIsSelected && ( + + + + + Select specific preview branches to apply these environment variables to. + + + )} + Date: Tue, 20 May 2025 14:08:41 +0100 Subject: [PATCH 058/121] Show the preview branch label on the env vars list --- .../app/presenters/v3/EnvironmentVariablesPresenter.server.ts | 2 +- .../route.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 15e968a178..730591f4eb 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -112,7 +112,7 @@ export class EnvironmentVariablesPresenter { { id: environmentVariable.id, key: environmentVariable.key, - environment: { type: env.type, id: env.id }, + environment: { type: env.type, id: env.id, branchName: env.branchName }, value: isSecret ? "" : val.value, isSecret, }, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 1258c58d4d..dbf62eaa61 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -18,7 +18,6 @@ import { import { useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; -import { InlineCode } from "~/components/code/InlineCode"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; From a1a3c444495f1034d6b992010c2e4dcb40382da5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 14:08:56 +0100 Subject: [PATCH 059/121] Fix for overriding env vars --- .../environmentVariablesRepository.server.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 55cbce799f..cc2752fd36 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -602,16 +602,17 @@ export class EnvironmentVariablesRepository implements Repository { // Merge the secrets, we want child ones to override parent ones const mergedSecrets = new Map(); for (const secret of parentSecrets) { - mergedSecrets.set(secret.key, secret.value.secret); + const { key: parsedKey } = parseSecretKey(secret.key); + mergedSecrets.set(parsedKey, secret.value.secret); } for (const secret of childSecrets) { - mergedSecrets.set(secret.key, secret.value.secret); + const { key: parsedKey } = parseSecretKey(secret.key); + mergedSecrets.set(parsedKey, secret.value.secret); } return Array.from(mergedSecrets.entries()).map(([key, value]) => { - const { key: parsedKey } = parseSecretKey(key); return { - key: parsedKey, + key, value, }; }); From 8ec543bd1ca3d05498d0348dc30b661e16263582 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 14:09:10 +0100 Subject: [PATCH 060/121] Adding preview branch env vars working --- .../route.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 957a313717..0c1b98c27f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -273,13 +273,19 @@ export default function Page() {
+ {selectedBranchIds.length > 0 + ? selectedBranchIds.map((id) => ( + + )) + : Array.from(selectedEnvironmentIds).map((id) => ( + + ))}
{nonBranchEnvironments.map((environment) => ( handleEnvironmentChange(environment.id, isChecked, environment.type) @@ -344,7 +350,6 @@ export default function Page() { item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? ""], + }} text={(vals) => - vals - ?.map((env) => branchEnvironments.find((b) => b.id === env)?.branchName) - .join(", ") + vals.length > 0 + ? vals + ?.map((env) => branchEnvironments.find((b) => b.id === env)?.branchName) + .join(", ") + : null } + dropdownIcon > {(matches) => matches?.map((env) => ( @@ -369,7 +378,10 @@ export default function Page() { } - Select specific preview branches to apply these environment variables to. + You can select branches to override variables.{" "} + {selectedBranchIds.length > 0 + ? "The variables below will be overriden for runs on these branches." + : "No overrides are currently set."} )} From 25372d6cb064c12c8e168ae01d1860fd487319f5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 16:21:42 +0100 Subject: [PATCH 062/121] Only allow selecting a single branch --- .../route.tsx | 72 +++++++++---------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index be4d46c2f4..347f6b7ee6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -188,7 +188,7 @@ export default function Page() { const project = useProject(); const environment = useEnvironment(); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState>(new Set()); - const [selectedBranchIds, setSelectedBranchIds] = useState([]); + const [selectedBranchId, setSelectedBranchId] = useState(undefined); const branchEnvironments = environments.filter((env) => env.branchName); const nonBranchEnvironments = environments.filter((env) => !env.branchName); @@ -221,7 +221,7 @@ export default function Page() { if (environmentType === "PREVIEW") { // If PREVIEW is checked, clear all other selections including branches newSet.clear(); - setSelectedBranchIds([]); + newSet.add(environmentId); } else { // If a non-PREVIEW environment is checked, remove PREVIEW if it's selected @@ -230,7 +230,7 @@ export default function Page() { newSet.delete(previewEnv.id); } newSet.add(environmentId); - setSelectedBranchIds([]); + setSelectedBranchId(undefined); } } else { newSet.delete(environmentId); @@ -240,17 +240,12 @@ export default function Page() { }); }; - const handleBranchChange = (branchIds: string[]) => { - setSelectedBranchIds(branchIds); - setSelectedEnvironmentIds((prev) => { - const newSet = new Set(prev); - // Remove PREVIEW environment if it was selected - const previewEnv = environments.find((env) => env.branchName === null); - if (previewEnv) { - newSet.delete(previewEnv.id); + const handleBranchChange = (branchId: string) => { + if (branchId === "all") { + setSelectedBranchId(undefined); + } else { + setSelectedBranchId(branchId); } - return newSet; - }); }; const [revealAll, setRevealAll] = useState(true); @@ -274,13 +269,13 @@ export default function Page() {
- {selectedBranchIds.length > 0 - ? selectedBranchIds.map((id) => ( + {selectedBranchId ? ( + + ) : ( + Array.from(selectedEnvironmentIds).map((id) => ( )) - : Array.from(selectedEnvironmentIds).map((id) => ( - - ))} + )}
{nonBranchEnvironments.map((environment) => ( - + +
- - You can select branches to override variables.{" "} - {selectedBranchIds.length > 0 - ? "The variables below will be overriden for runs on these branches." - : "No overrides are currently set."} - + {selectedBranchId !== "all" && selectedBranchId !== undefined && ( +
+ Select a branch to override variables in the Preview environment. )} @@ -547,7 +545,7 @@ function VariableFields({ type="button" onClick={() => { requestIntent(formRef.current ?? undefined, list.append(variablesFields.name)); - append([{ key: "", value: "" }]); + list.append(variablesFields.name, { defaultValue: [{ key: "", value: "" }] }); }} LeadingIcon={PlusIcon} > From 74d9923bc84b9a2374cf48822fbd8d8ee19b2064 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 16:21:59 +0100 Subject: [PATCH 063/121] Layout fix when there are errors --- .../route.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 347f6b7ee6..2bc1ba9c89 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -584,6 +584,7 @@ function VariableField({ return (
+
-
+ {fields.key.error} +
+
+
onChange({ ...value, value: e.currentTarget.value })} /> + {fields.value.error} +
{showDeleteButton && (
); } From 32e99500c1354ab29ee1a3868193d9b68bc26d78 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 20 May 2025 16:47:48 +0100 Subject: [PATCH 064/121] Set the defaultValue so there are some fields --- .../route.tsx | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 2bc1ba9c89..e374dbbedc 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -207,6 +207,9 @@ export default function Page() { return parse(formData, { schema }); }, shouldRevalidate: "onSubmit", + defaultValue: { + variables: [{ key: "", value: "" }], + }, }); const handleEnvironmentChange = ( @@ -245,7 +248,7 @@ export default function Page() { setSelectedBranchId(undefined); } else { setSelectedBranchId(branchId); - } + } }; const [revealAll, setRevealAll] = useState(true); @@ -273,8 +276,8 @@ export default function Page() { ) : ( Array.from(selectedEnvironmentIds).map((id) => ( - - )) + + )) )}
{nonBranchEnvironments.map((environment) => ( @@ -346,31 +349,31 @@ export default function Page() {
- + } + dropdownIcon + > + {(matches) => + matches?.map((env) => ( + + {env.branchName} + + )) + } + {selectedBranchId !== "all" && selectedBranchId !== undefined && ( - - - - - + } + parentEnvironment={parentEnvironment} + /> } > diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx index 7fa644ccb9..7726e6a9b1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -9,10 +9,10 @@ import { } from "@heroicons/react/20/solid"; import { BookOpenIcon } from "@heroicons/react/24/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useActionData, useSearchParams } from "@remix-run/react"; +import { Form, useActionData, useLocation, useSearchParams } from "@remix-run/react"; import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { GitMeta } from "@trigger.dev/core/v3"; -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; @@ -143,7 +143,7 @@ export async function action({ request }: ActionFunctionArgs) { } return redirectWithSuccessMessage( - branchesPath(result.organization, result.project, result.branch), + `${branchesPath(result.organization, result.project, result.branch)}?dialogClosed=true`, request, `Branch "${result.branch.branchName}" created` ); @@ -217,8 +217,8 @@ export default function Page() { {limits.isAtLimit ? ( ) : ( - - + New branch - - - - - + } + parentEnvironment={branchableEnvironment} + /> )} @@ -528,8 +526,17 @@ function UpgradePanel({ ); } -export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: string } }) { +export function NewBranchPanel({ + button, + parentEnvironment, +}: { + button: React.ReactNode; + parentEnvironment: { id: string }; +}) { const lastSubmission = useActionData(); + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const [isOpen, setIsOpen] = useState(false); const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({ id: "create-branch", @@ -540,51 +547,67 @@ export function NewBranchPanel({ parentEnvironment }: { parentEnvironment: { id: shouldRevalidate: "onInput", }); + useEffect(() => { + if (searchParams.has("dialogClosed")) { + setSearchParams((s) => { + s.delete("dialogClosed"); + return s; + }); + setIsOpen(false); + } + }, [searchParams, setSearchParams]); + return ( - <> - New branch -
-
-
- - - - - - - Must not contain: spaces ~{" "} - ^{" "} - :{" "} - ?{" "} - *{" "} - {"["}{" "} - \{" "} - //{" "} - ..{" "} - {"@{"}{" "} - .lock - - {branchName.error} - - {form.error} - - Create branch - - } - cancelButton={ - - - - } - /> -
-
-
- + + {button} + + New branch +
+
+
+ + + + + + + Must not contain: spaces ~{" "} + ^{" "} + :{" "} + ?{" "} + *{" "} + {"["}{" "} + \{" "} + //{" "} + ..{" "} + {"@{"}{" "} + .lock + + {branchName.error} + + {form.error} + + Create branch + + } + cancelButton={ + + + + } + /> +
+
+
+
+
); } From 42f69d5117e94860ce06658b85315fad0cc757c2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 May 2025 17:34:23 +0100 Subject: [PATCH 109/121] Update apps/webapp/app/services/upsertBranch.server.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/webapp/app/services/upsertBranch.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index ae1e40b8b8..716abb6b80 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -92,7 +92,7 @@ export class UpsertBranchService { const now = new Date(); - const branch = await prisma.runtimeEnvironment.upsert({ + const branch = await this.#prismaClient.runtimeEnvironment.upsert({ where: { projectId_shortcode: { projectId: parentEnvironment.project.id, From 085c53ae129d79defa11700da2610806e597b512 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 May 2025 17:45:34 +0100 Subject: [PATCH 110/121] Removed findUniques from WorkerGroupTokenService --- .../worker/workerGroupTokenService.server.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts index 341122f8fa..ecdb49724e 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts @@ -73,7 +73,7 @@ export class WorkerGroupTokenService extends WithRunEngine { } async rotateToken({ workerGroupId }: { workerGroupId: string }) { - const workerGroup = await this._prisma.workerInstanceGroup.findUnique({ + const workerGroup = await this._prisma.workerInstanceGroup.findFirst({ where: { id: workerGroupId, }, @@ -266,12 +266,10 @@ export class WorkerGroupTokenService extends WithRunEngine { return await $transaction(this._prisma, async (tx) => { const resourceIdentifier = deploymentId ? `${deploymentId}:${instanceName}` : instanceName; - const workerInstance = await tx.workerInstance.findUnique({ + const workerInstance = await tx.workerInstance.findFirst({ where: { - workerGroupId_resourceIdentifier: { - workerGroupId: workerGroup.id, - resourceIdentifier, - }, + workerGroupId: workerGroup.id, + resourceIdentifier, }, include: { deployment: true, @@ -315,12 +313,10 @@ export class WorkerGroupTokenService extends WithRunEngine { // Unique constraint violation if (error.code === "P2002") { try { - const existingWorkerInstance = await tx.workerInstance.findUnique({ + const existingWorkerInstance = await tx.workerInstance.findFirst({ where: { - workerGroupId_resourceIdentifier: { - workerGroupId: workerGroup.id, - resourceIdentifier, - }, + workerGroupId: workerGroup.id, + resourceIdentifier, }, include: { deployment: true, @@ -363,7 +359,7 @@ export class WorkerGroupTokenService extends WithRunEngine { // Unmanaged workers instances are locked to a specific deployment version - const deployment = await tx.workerDeployment.findUnique({ + const deployment = await tx.workerDeployment.findFirst({ where: { ...(deploymentId.startsWith("deployment_") ? { @@ -690,7 +686,7 @@ export class AuthenticatedWorkerInstance extends WithRunEngine { const environment = this.environment ?? - (await this._prisma.runtimeEnvironment.findUnique({ + (await this._prisma.runtimeEnvironment.findFirst({ where: { id: engineResult.execution.environment.id, }, From 0bdd9db2d400d8a5fef9358ac5121a2050ad725e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 May 2025 17:58:53 +0100 Subject: [PATCH 111/121] Made the parentEnvironmentId migrations safe --- .../migration.sql | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql index d93feeee2f..d4e6fa8fed 100644 --- a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql +++ b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql @@ -1,8 +1,8 @@ -- AlterTable ALTER TABLE "RuntimeEnvironment" -ADD COLUMN "archivedAt" TIMESTAMP(3), -ADD COLUMN "branchName" TEXT, -ADD COLUMN "git" JSONB; +ADD COLUMN IF NOT EXISTS "archivedAt" TIMESTAMP(3), +ADD COLUMN IF NOT EXISTS "branchName" TEXT, +ADD COLUMN IF NOT EXISTS "git" JSONB; -- Add the parentEnvironmentId column DO $$ @@ -19,4 +19,19 @@ BEGIN END $$; -- AddForeignKey -ALTER TABLE "RuntimeEnvironment" ADD CONSTRAINT "RuntimeEnvironment_parentEnvironmentId_fkey" FOREIGN KEY ("parentEnvironmentId") REFERENCES "RuntimeEnvironment" ("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_name = 'RuntimeEnvironment' + AND constraint_name = 'RuntimeEnvironment_parentEnvironmentId_fkey' + ) THEN + ALTER TABLE "RuntimeEnvironment" + ADD CONSTRAINT "RuntimeEnvironment_parentEnvironmentId_fkey" + FOREIGN KEY ("parentEnvironmentId") + REFERENCES "RuntimeEnvironment" ("id") + ON DELETE CASCADE + ON UPDATE CASCADE; + END IF; +END $$; \ No newline at end of file From 9eac17e92a8625b35f4e67a4da746cadcf80fefe Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 May 2025 18:03:35 +0100 Subject: [PATCH 112/121] Latest lockfile --- pnpm-lock.yaml | 270 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 265 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d142b076..6e55c2a198 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@prisma/instrumentation': specifier: ^5.11.0 version: 5.11.0 + '@radix-ui/react-accordion': + specifier: ^1.2.11 + version: 1.2.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-alert-dialog': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) @@ -403,8 +406,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.14 - version: 1.0.14 + specifier: 1.0.15 + version: 1.0.15 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -1233,6 +1236,9 @@ importers: fast-npm-meta: specifier: ^0.2.2 version: 0.2.2 + git-last-commit: + specifier: ^1.0.1 + version: 1.0.1 gradient-string: specifier: ^2.0.2 version: 2.0.2 @@ -1245,6 +1251,9 @@ importers: import-meta-resolve: specifier: ^4.1.0 version: 4.1.0 + ini: + specifier: ^5.0.0 + version: 5.0.0 jsonc-parser: specifier: 3.2.1 version: 3.2.1 @@ -1333,6 +1342,9 @@ importers: '@types/gradient-string': specifier: ^1.1.2 version: 1.1.2 + '@types/ini': + specifier: ^4.1.1 + version: 4.1.1 '@types/object-hash': specifier: 3.0.6 version: 3.0.6 @@ -1960,6 +1972,9 @@ importers: references/hello-world: dependencies: + '@trigger.dev/build': + specifier: workspace:* + version: link:../../packages/build '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk @@ -9904,6 +9919,38 @@ packages: resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} dev: false + /@radix-ui/primitive@1.1.2: + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + dev: false + + /@radix-ui/react-accordion@1.2.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-alert-dialog@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-jbfBCRlKYlhbitueOAv7z74PXYeIQmWpKwm3jllsdkw7fGWNkxqP3v0nY9WmOzcPqpQuoorNtvViBgL46n5gVg==} peerDependencies: @@ -10014,6 +10061,33 @@ packages: react-dom: 18.2.0(react@18.3.1) dev: false + /@radix-ui/react-collapsible@1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-collection@1.0.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-s8WdQQ6wNXpaxdZ308KSr8fEWGrg4un8i4r/w7fhiS4ElRNjk5rRcl0/C6TANG2LvLOGIxtzo/jAg6Qf73TEBw==} peerDependencies: @@ -10092,6 +10166,29 @@ packages: react-dom: 18.2.0(react@18.3.1) dev: false + /@radix-ui/react-collection@1.1.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-compose-refs@1.0.0(react@18.2.0): resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==} peerDependencies: @@ -10204,6 +10301,19 @@ packages: react: 19.0.0 dev: false + /@radix-ui/react-compose-refs@1.1.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-context@1.0.0(react@18.2.0): resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} peerDependencies: @@ -10276,6 +10386,19 @@ packages: react: 19.0.0 dev: false + /@radix-ui/react-context@1.1.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-dialog@1.0.3(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-owNhq36kNPqC2/a+zJRioPg6HHnTn5B/sh/NjTY8r4W9g1L5VJlrzZIVcBr7R9Mg8iLjVmh6MGgMlfoVf/WO/A==} peerDependencies: @@ -10412,7 +10535,7 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.26.7 '@types/react': 18.3.1 react: 18.3.1 dev: false @@ -10430,6 +10553,19 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-direction@1.1.1(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-dismissable-layer@1.0.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q==} peerDependencies: @@ -10699,6 +10835,20 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-id@1.1.1(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-label@2.0.1(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-qcfbS3B8hTYmEO44RNcXB6pegkxRsJIbdxTMu0PEX0Luv5O2DvTIwwVYxQfUwLpM88EL84QRPLOLgwUSApMsLQ==} peerDependencies: @@ -11023,6 +11173,27 @@ packages: react-dom: 18.2.0(react@18.3.1) dev: false + /@radix-ui/react-presence@1.1.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-primitive@1.0.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==} peerDependencies: @@ -11149,6 +11320,26 @@ packages: react-dom: 19.0.0(react@19.0.0) dev: false + /@radix-ui/react-primitive@2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-progress@1.1.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1): resolution: {integrity: sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==} peerDependencies: @@ -11498,6 +11689,20 @@ packages: react: 19.0.0 dev: false + /@radix-ui/react-slot@1.2.3(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} peerDependencies: @@ -11792,6 +11997,35 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + + /@radix-ui/react-use-effect-event@0.0.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-use-escape-keydown@1.0.2(react@18.2.0): resolution: {integrity: sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==} peerDependencies: @@ -11904,6 +12138,19 @@ packages: react: 19.0.0 dev: false + /@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-use-previous@1.0.0(react@18.2.0): resolution: {integrity: sha512-RG2K8z/K7InnOKpq6YLDmT49HGjNmrK+fr82UCVKT2sW0GYfVnYp4wZWBooT/EYfQ5faA9uIjvsuMMhH61rheg==} peerDependencies: @@ -17143,8 +17390,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@trigger.dev/platform@1.0.14: - resolution: {integrity: sha512-sYWzsH5oNnSTe4zhm1s0JFtvuRyAjBadScE9REN4f0AkntRG572mJHOolr0HyER4k1gS1mSK0nQScXSG5LCVIA==} + /@trigger.dev/platform@1.0.15: + resolution: {integrity: sha512-rorRJJl7ecyiO8iQZcHGlXR00bTzm7e1xZt0ddCYJFhaQjxq2bo2oen5DVxUbLZsE2cp60ipQWFrmAipFwK79Q==} dependencies: zod: 3.23.8 dev: false @@ -17402,6 +17649,10 @@ packages: resolution: {integrity: sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w==} dev: true + /@types/ini@4.1.1: + resolution: {integrity: sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==} + dev: true + /@types/interpret@1.1.3: resolution: {integrity: sha512-uBaBhj/BhilG58r64mtDb/BEdH51HIQLgP5bmWzc5qCtFMja8dCk/IOJmk36j0lbi9QHwI6sbtUNGuqXdKCAtQ==} dependencies: @@ -23767,6 +24018,10 @@ packages: tar: 6.2.1 dev: false + /git-last-commit@1.0.1: + resolution: {integrity: sha512-FDSgeMqa7GnJDxt/q0AbrxbfeTyxp4ImxEw1e4nw6NUHA5FMhFUq33dTXI4Xdgcj1VQ1q5QLWF6WxFrJ8KCBOg==} + dev: false + /github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} dev: false @@ -24471,6 +24726,11 @@ packages: engines: {node: '>=10'} dev: true + /ini@5.0.0: + resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} + engines: {node: ^18.17.0 || >=20.5.0} + dev: false + /inline-style-parser@0.1.1: resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} dev: true From c14e029f92a3e1c541440f9695e5db0970db77b1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 May 2025 18:25:27 +0100 Subject: [PATCH 113/121] Update packages/cli-v3/src/commands/workers/build.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/cli-v3/src/commands/workers/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-v3/src/commands/workers/build.ts b/packages/cli-v3/src/commands/workers/build.ts index dccc37e7c0..9daca0ff3c 100644 --- a/packages/cli-v3/src/commands/workers/build.ts +++ b/packages/cli-v3/src/commands/workers/build.ts @@ -190,7 +190,7 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti apiUrl: authorization.auth.apiUrl, projectRef: resolvedConfig.project, env: options.env, - branch: options.branch, + branch, profile: options.profile, }); From 24917fe7142b04883eaa6dcfa72afe22520a3faa Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 May 2025 22:02:43 +0100 Subject: [PATCH 114/121] Move isValidGitBranchName to a separate file --- .../app/services/upsertBranch.server.ts | 32 +------------------ apps/webapp/app/v3/validGitBranch.ts | 30 +++++++++++++++++ .../webapp/test/validateGitBranchName.test.ts | 3 +- 3 files changed, 33 insertions(+), 32 deletions(-) create mode 100644 apps/webapp/app/v3/validGitBranch.ts diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 716abb6b80..04d24e29d1 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -5,6 +5,7 @@ import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.serve import { logger } from "./logger.server"; import { getLimit } from "./platform.v3.server"; import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; +import { isValidGitBranchName } from "~/v3/validGitBranch"; export class UpsertBranchService { #prismaClient: PrismaClient; @@ -167,37 +168,6 @@ export async function checkBranchLimit( }; } -export function isValidGitBranchName(branch: string): boolean { - // Must not be empty - if (!branch) return false; - - // Disallowed characters: space, ~, ^, :, ?, *, [, \ - if (/[ \~\^:\?\*\[\\]/.test(branch)) return false; - - // Disallow ASCII control characters (0-31) and DEL (127) - for (let i = 0; i < branch.length; i++) { - const code = branch.charCodeAt(i); - if ((code >= 0 && code <= 31) || code === 127) return false; - } - - // Cannot start or end with a slash - if (branch.startsWith("/") || branch.endsWith("/")) return false; - - // Cannot have consecutive slashes - if (branch.includes("//")) return false; - - // Cannot contain '..' - if (branch.includes("..")) return false; - - // Cannot contain '@{' - if (branch.includes("@{")) return false; - - // Cannot end with '.lock' - if (branch.endsWith(".lock")) return false; - - return true; -} - export function sanitizeBranchName(ref: string): string | null { if (!ref) return null; if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length); diff --git a/apps/webapp/app/v3/validGitBranch.ts b/apps/webapp/app/v3/validGitBranch.ts new file mode 100644 index 0000000000..6d5e6d3176 --- /dev/null +++ b/apps/webapp/app/v3/validGitBranch.ts @@ -0,0 +1,30 @@ +export function isValidGitBranchName(branch: string): boolean { + // Must not be empty + if (!branch) return false; + + // Disallowed characters: space, ~, ^, :, ?, *, [, \ + if (/[ \~\^:\?\*\[\\]/.test(branch)) return false; + + // Disallow ASCII control characters (0-31) and DEL (127) + for (let i = 0; i < branch.length; i++) { + const code = branch.charCodeAt(i); + if ((code >= 0 && code <= 31) || code === 127) return false; + } + + // Cannot start or end with a slash + if (branch.startsWith("/") || branch.endsWith("/")) return false; + + // Cannot have consecutive slashes + if (branch.includes("//")) return false; + + // Cannot contain '..' + if (branch.includes("..")) return false; + + // Cannot contain '@{' + if (branch.includes("@{")) return false; + + // Cannot end with '.lock' + if (branch.endsWith(".lock")) return false; + + return true; +} diff --git a/apps/webapp/test/validateGitBranchName.test.ts b/apps/webapp/test/validateGitBranchName.test.ts index dc5acc9224..91e44a26f2 100644 --- a/apps/webapp/test/validateGitBranchName.test.ts +++ b/apps/webapp/test/validateGitBranchName.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { sanitizeBranchName, isValidGitBranchName } from "../app/services/upsertBranch.server"; +import { sanitizeBranchName } from "../app/services/upsertBranch.server"; +import { isValidGitBranchName } from "~/v3/validGitBranch"; describe("isValidGitBranchName", () => { it("returns true for a valid branch name", async () => { From 0d5905e65a40ad80183e79c33634500723e7c2d5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 May 2025 22:08:15 +0100 Subject: [PATCH 115/121] Move the sanitize fn too --- .../webapp/app/services/upsertBranch.server.ts | 18 ++---------------- .../app/v3/{validGitBranch.ts => gitBranch.ts} | 14 ++++++++++++++ apps/webapp/test/validateGitBranchName.test.ts | 3 +-- 3 files changed, 17 insertions(+), 18 deletions(-) rename apps/webapp/app/v3/{validGitBranch.ts => gitBranch.ts} (55%) diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 04d24e29d1..4aaeb66349 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -2,10 +2,10 @@ import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/ import slug from "slug"; import { prisma } from "~/db.server"; import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; +import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; +import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; import { logger } from "./logger.server"; import { getLimit } from "./platform.v3.server"; -import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; -import { isValidGitBranchName } from "~/v3/validGitBranch"; export class UpsertBranchService { #prismaClient: PrismaClient; @@ -167,17 +167,3 @@ export async function checkBranchLimit( isAtLimit: used >= limit, }; } - -export function sanitizeBranchName(ref: string): string | null { - if (!ref) return null; - if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length); - if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length); - if (ref.startsWith("refs/tags/")) return ref.substring("refs/tags/".length); - if (ref.startsWith("refs/pull/")) return ref.substring("refs/pull/".length); - if (ref.startsWith("refs/merge/")) return ref.substring("refs/merge/".length); - if (ref.startsWith("refs/release/")) return ref.substring("refs/release/".length); - //unknown ref format, so reject - if (ref.startsWith("refs/")) return null; - - return ref; -} diff --git a/apps/webapp/app/v3/validGitBranch.ts b/apps/webapp/app/v3/gitBranch.ts similarity index 55% rename from apps/webapp/app/v3/validGitBranch.ts rename to apps/webapp/app/v3/gitBranch.ts index 6d5e6d3176..109de645b9 100644 --- a/apps/webapp/app/v3/validGitBranch.ts +++ b/apps/webapp/app/v3/gitBranch.ts @@ -28,3 +28,17 @@ export function isValidGitBranchName(branch: string): boolean { return true; } + +export function sanitizeBranchName(ref: string): string | null { + if (!ref) return null; + if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length); + if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length); + if (ref.startsWith("refs/tags/")) return ref.substring("refs/tags/".length); + if (ref.startsWith("refs/pull/")) return ref.substring("refs/pull/".length); + if (ref.startsWith("refs/merge/")) return ref.substring("refs/merge/".length); + if (ref.startsWith("refs/release/")) return ref.substring("refs/release/".length); + //unknown ref format, so reject + if (ref.startsWith("refs/")) return null; + + return ref; +} diff --git a/apps/webapp/test/validateGitBranchName.test.ts b/apps/webapp/test/validateGitBranchName.test.ts index 91e44a26f2..28f4056c46 100644 --- a/apps/webapp/test/validateGitBranchName.test.ts +++ b/apps/webapp/test/validateGitBranchName.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sanitizeBranchName } from "../app/services/upsertBranch.server"; -import { isValidGitBranchName } from "~/v3/validGitBranch"; +import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; describe("isValidGitBranchName", () => { it("returns true for a valid branch name", async () => { From 345da557fc26f968ce4b8e5a84d56d4c84a4f9b7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 May 2025 23:27:28 +0100 Subject: [PATCH 116/121] removeBlacklistedVariables moved to a separate file --- .../app/v3/environmentVariableRules.server.ts | 40 +++++++++++++++++++ .../environmentVariablesRepository.server.ts | 40 +------------------ .../test/environmentVariableRules.test.ts | 2 +- 3 files changed, 42 insertions(+), 40 deletions(-) create mode 100644 apps/webapp/app/v3/environmentVariableRules.server.ts diff --git a/apps/webapp/app/v3/environmentVariableRules.server.ts b/apps/webapp/app/v3/environmentVariableRules.server.ts new file mode 100644 index 0000000000..ddaee2b249 --- /dev/null +++ b/apps/webapp/app/v3/environmentVariableRules.server.ts @@ -0,0 +1,40 @@ +import { type EnvironmentVariable } from "./environmentVariables/repository"; + +type VariableRule = + | { type: "exact"; key: string } + | { type: "prefix"; prefix: string } + | { type: "whitelist"; key: string }; + +const blacklistedVariables: VariableRule[] = [ + { type: "exact", key: "TRIGGER_SECRET_KEY" }, + { type: "exact", key: "TRIGGER_API_URL" }, + { type: "prefix", prefix: "OTEL_" }, + { type: "whitelist", key: "OTEL_LOG_LEVEL" }, +]; + +export function removeBlacklistedVariables( + variables: EnvironmentVariable[] +): EnvironmentVariable[] { + return variables.filter((v) => { + const whitelisted = blacklistedVariables.find( + (bv) => bv.type === "whitelist" && bv.key === v.key + ); + if (whitelisted) { + return true; + } + + const exact = blacklistedVariables.find((bv) => bv.type === "exact" && bv.key === v.key); + if (exact) { + return false; + } + + const prefix = blacklistedVariables.find( + (bv) => bv.type === "prefix" && v.key.startsWith(bv.prefix) + ); + if (prefix) { + return false; + } + + return true; + }); +} diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index d151b84fc6..dbf2b7f0d5 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -15,6 +15,7 @@ import { type Repository, type Result, } from "./repository"; +import { removeBlacklistedVariables } from "../environmentVariableRules.server"; function secretKeyProjectPrefix(projectId: string) { return `environmentvariable:${projectId}:`; @@ -1038,42 +1039,3 @@ async function resolveCommonBuiltInVariables( ): Promise> { return []; } - -type VariableRule = - | { type: "exact"; key: string } - | { type: "prefix"; prefix: string } - | { type: "whitelist"; key: string }; - -const blacklistedVariables: VariableRule[] = [ - { type: "exact", key: "TRIGGER_SECRET_KEY" }, - { type: "exact", key: "TRIGGER_API_URL" }, - { type: "prefix", prefix: "OTEL_" }, - { type: "whitelist", key: "OTEL_LOG_LEVEL" }, -]; - -export function removeBlacklistedVariables( - variables: EnvironmentVariable[] -): EnvironmentVariable[] { - return variables.filter((v) => { - const whitelisted = blacklistedVariables.find( - (bv) => bv.type === "whitelist" && bv.key === v.key - ); - if (whitelisted) { - return true; - } - - const exact = blacklistedVariables.find((bv) => bv.type === "exact" && bv.key === v.key); - if (exact) { - return false; - } - - const prefix = blacklistedVariables.find( - (bv) => bv.type === "prefix" && v.key.startsWith(bv.prefix) - ); - if (prefix) { - return false; - } - - return true; - }); -} diff --git a/apps/webapp/test/environmentVariableRules.test.ts b/apps/webapp/test/environmentVariableRules.test.ts index ff4bc61222..af27dd3c7c 100644 --- a/apps/webapp/test/environmentVariableRules.test.ts +++ b/apps/webapp/test/environmentVariableRules.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { removeBlacklistedVariables } from "../app/v3/environmentVariables/environmentVariablesRepository.server"; import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository"; +import { removeBlacklistedVariables } from "~/v3/environmentVariableRules.server"; describe("removeBlacklistedVariables", () => { it("should remove exact match blacklisted variables", () => { From f823f76d8263a3fe8f9e35b6e8e23e4787d76e51 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 28 May 2025 09:56:17 +0100 Subject: [PATCH 117/121] =?UTF-8?q?Moved=20deduplicateVariableArray=20to?= =?UTF-8?q?=20a=20separate=20file=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/v3/deduplicateVariableArray.server.ts | 14 ++++++++++++++ .../environmentVariablesRepository.server.ts | 14 +------------- .../test/environmentVariableDeduplication.test.ts | 2 +- 3 files changed, 16 insertions(+), 14 deletions(-) create mode 100644 apps/webapp/app/v3/deduplicateVariableArray.server.ts diff --git a/apps/webapp/app/v3/deduplicateVariableArray.server.ts b/apps/webapp/app/v3/deduplicateVariableArray.server.ts new file mode 100644 index 0000000000..886ec7a440 --- /dev/null +++ b/apps/webapp/app/v3/deduplicateVariableArray.server.ts @@ -0,0 +1,14 @@ +import { type EnvironmentVariable } from "./environmentVariables/repository"; + +/** Later variables override earlier ones */ +export function deduplicateVariableArray(variables: EnvironmentVariable[]) { + const result: EnvironmentVariable[] = []; + // Process array in reverse order so later variables override earlier ones + for (const variable of [...variables].reverse()) { + if (!result.some((v) => v.key === variable.key)) { + result.push(variable); + } + } + // Reverse back to maintain original order but with later variables taking precedence + return result.reverse(); +} diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index dbf2b7f0d5..53f38ce9b2 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -16,6 +16,7 @@ import { type Result, } from "./repository"; import { removeBlacklistedVariables } from "../environmentVariableRules.server"; +import { deduplicateVariableArray } from "../deduplicateVariableArray.server"; function secretKeyProjectPrefix(projectId: string) { return `environmentvariable:${projectId}:`; @@ -842,19 +843,6 @@ export async function resolveVariablesForEnvironment( ]); } -/** Later variables override earlier ones */ -export function deduplicateVariableArray(variables: EnvironmentVariable[]) { - const result: EnvironmentVariable[] = []; - // Process array in reverse order so later variables override earlier ones - for (const variable of [...variables].reverse()) { - if (!result.some((v) => v.key === variable.key)) { - result.push(variable); - } - } - // Reverse back to maintain original order but with later variables taking precedence - return result.reverse(); -} - async function resolveOverridableTriggerVariables( runtimeEnvironment: RuntimeEnvironmentForEnvRepo ) { diff --git a/apps/webapp/test/environmentVariableDeduplication.test.ts b/apps/webapp/test/environmentVariableDeduplication.test.ts index 30a2f07cc8..d47da457a0 100644 --- a/apps/webapp/test/environmentVariableDeduplication.test.ts +++ b/apps/webapp/test/environmentVariableDeduplication.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { deduplicateVariableArray } from "../app/v3/environmentVariables/environmentVariablesRepository.server"; import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository"; +import { deduplicateVariableArray } from "~/v3/deduplicateVariableArray.server"; describe("Deduplicate variables", () => { it("should keep later variables when there are duplicates", () => { From 9713615a570c4876011773f81a585b7ef272233c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 28 May 2025 09:59:06 +0100 Subject: [PATCH 118/121] Fix broken sanitizeBranchName import --- apps/webapp/app/models/runtimeEnvironment.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index aa2ea3d29e..80820fa910 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -2,8 +2,8 @@ import type { AuthenticatedEnvironment } from "@internal/run-engine"; import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; -import { sanitizeBranchName } from "~/services/upsertBranch.server"; import { getUsername } from "~/utils/username"; +import { sanitizeBranchName } from "~/v3/gitBranch"; export type { RuntimeEnvironment }; From 7885045f63ae615258997b4a0ba88ac01063242c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 28 May 2025 10:06:11 +0100 Subject: [PATCH 119/121] =?UTF-8?q?Another=20import=20fix=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/services/apiAuth.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 6291f3085f..bed78809b6 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -17,7 +17,7 @@ import { isPersonalAccessToken, } from "./personalAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; -import { sanitizeBranchName } from "./upsertBranch.server"; +import { sanitizeBranchName } from "~/v3/gitBranch"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), From 641b74cbfd363bc09c3bb5178957e62c1bc1ed70 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 28 May 2025 10:24:48 +0100 Subject: [PATCH 120/121] Improved blacklisted error message --- .../environmentVariablesRepository.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index 53f38ce9b2..4462a7e15d 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -92,7 +92,7 @@ export class EnvironmentVariablesRepository implements Repository { // Remove `TRIGGER_SECRET_KEY` or `TRIGGER_API_URL` let values = removeBlacklistedVariables(options.variables); - const removedDuplicates = values.length !== options.variables.length; + const removedBlacklisted = values.length !== options.variables.length; //get rid of empty variables values = values.filter((v) => v.key.trim() !== "" && v.value.trim() !== ""); @@ -100,7 +100,7 @@ export class EnvironmentVariablesRepository implements Repository { return { success: false as const, error: `You must set at least one valid variable.${ - removedDuplicates ? " These were ignored because they're blacklisted." : "" + removedBlacklisted ? " All the variables submitted are not allowed." : "" }`, }; } From 2f3993667e4e897f1a8bb09c5bc90d03e711cb0c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 28 May 2025 10:46:45 +0100 Subject: [PATCH 121/121] SImplified migration to use `ADD COLUMN IF NOT EXISTS "parentEnvironmentId" TEXT` --- .../migration.sql | 17 ++--------------- internal-packages/database/prisma/schema.prisma | 4 ++-- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql index d4e6fa8fed..eb32648d76 100644 --- a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql +++ b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql @@ -2,21 +2,8 @@ ALTER TABLE "RuntimeEnvironment" ADD COLUMN IF NOT EXISTS "archivedAt" TIMESTAMP(3), ADD COLUMN IF NOT EXISTS "branchName" TEXT, -ADD COLUMN IF NOT EXISTS "git" JSONB; - --- Add the parentEnvironmentId column -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'RuntimeEnvironment' - AND column_name = 'parentEnvironmentId' - ) THEN - ALTER TABLE "RuntimeEnvironment" - ADD COLUMN "parentEnvironmentId" TEXT; - END IF; -END $$; +ADD COLUMN IF NOT EXISTS "git" JSONB, +ADD COLUMN IF NOT EXISTS "parentEnvironmentId" TEXT; -- AddForeignKey DO $$ diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 52506e23c0..0435c7e779 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -377,7 +377,7 @@ model RuntimeEnvironment { slug String apiKey String @unique - /// Deprecated, was for v2 + /// @deprecated was for v2 pkApiKey String @unique type RuntimeEnvironmentType @default(DEVELOPMENT) @@ -417,7 +417,7 @@ model RuntimeEnvironment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - /// Deprecated (v2) + /// @deprecated (v2) tunnelId String? endpoints Endpoint[] jobVersions JobVersion[]