From 770e63e400f651ba0a2f562e979207ce8b161727 Mon Sep 17 00:00:00 2001 From: rohan Date: Fri, 10 Oct 2025 14:29:48 +0530 Subject: [PATCH 01/13] feat: add custom env support for vercel syncs Signed-off-by: rohan --- backend/api/utils/syncing/vercel/main.py | 194 +++++++++++++++--- frontend/apollo/gql.ts | 4 +- frontend/apollo/graphql.ts | 15 +- frontend/apollo/schema.graphql | 10 +- .../syncing/Vercel/CreateVercelSync.tsx | 16 +- .../queries/syncing/vercel/getProject.gql | 6 +- 6 files changed, 204 insertions(+), 41 deletions(-) diff --git a/backend/api/utils/syncing/vercel/main.py b/backend/api/utils/syncing/vercel/main.py index 4c8b602f5..3c3e631ae 100644 --- a/backend/api/utils/syncing/vercel/main.py +++ b/backend/api/utils/syncing/vercel/main.py @@ -1,19 +1,25 @@ import requests -import graphene -from graphene import ObjectType - +import logging +from graphene import ObjectType, List, ID, String from api.utils.syncing.auth import get_credentials +logger = logging.getLogger(__name__) + VERCEL_API_BASE_URL = "https://api.vercel.com" -from graphene import ObjectType, List, ID, String +class VercelEnvironmentType(ObjectType): + id = ID(required=True) + name = String(required=True) + description = String() + slug = String(required=True) + type = String() # "standard" or "custom" class VercelProjectType(ObjectType): id = ID(required=True) name = String(required=True) - environment = List(String) + environments = List(VercelEnvironmentType) class VercelTeamProjectsType(ObjectType): @@ -45,6 +51,42 @@ def test_vercel_creds(credential_id): return False +def get_project_custom_environments(token, project_id, team_id=None): + """ + Retrieve custom environments for a specific Vercel project. + + Args: + token (str): Vercel API token + project_id (str): Project ID + team_id (str, optional): Team ID + + Returns: + list: List of custom environment dictionaries + """ + url = f"{VERCEL_API_BASE_URL}/v1/projects/{project_id}/custom-environments" + if team_id: + url += f"?teamId={team_id}" + + response = requests.get(url, headers=get_vercel_headers(token)) + + if response.status_code != 200: + # If custom environments endpoint fails, return empty list (project might not have custom envs) + logger.info("No custom environments found or error occurred.") + return [] + + # Parse the correct response structure + custom_envs = response.json().get("environments", []) + return [ + { + "id": env["id"], + "name": env["slug"].title(), # Use slug as name, capitalize first letter + "description": env.get("description", ""), + "slug": env["slug"], + } + for env in custom_envs + ] + + def list_vercel_projects(credential_id): """ List all Vercel projects accessible with the provided credentials. @@ -79,24 +121,68 @@ def list_vercel_projects(credential_id): team_projects_url, headers=get_vercel_headers(token) ) if team_projects_response.status_code != 200: - print( + logger.error( f"Failed to list projects for team {team_name}: {team_projects_response.text}" ) continue team_projects = team_projects_response.json().get("projects", []) + + # Get available environments for each project (including custom environments) + projects_with_envs = [] + for project in team_projects: + project_id = project["id"] + custom_envs = get_project_custom_environments( + token, project_id, team_id + ) + + # Standard environments + environments = [ + { + "id": "dev", + "name": "Development", + "slug": "development", + "type": "standard", + }, + { + "id": "prev", + "name": "Preview", + "slug": "preview", + "type": "standard", + }, + { + "id": "prod", + "name": "Production", + "slug": "production", + "type": "standard", + }, + ] + + # Add custom environments with full details + for env in custom_envs: + environments.append( + { + "id": env["id"], + "name": env["name"], + "description": env.get("description", ""), + "slug": env["slug"], + "type": "custom", + } + ) + + projects_with_envs.append( + { + "id": project["id"], + "name": project["name"], + "environments": environments, + } + ) + result.append( { "id": team_id, "team_name": team_name, - "projects": [ - { - "id": project["id"], - "name": project["name"], - "environment": ["development", "preview", "production"], - } - for project in team_projects - ], + "projects": projects_with_envs, } ) @@ -109,7 +195,7 @@ def list_vercel_projects(credential_id): def get_existing_env_vars(token, project_id, team_id=None, target_environment=None): """ Retrieve environment variables for a specific Vercel project and environment. - + Args: token (str): Vercel API token project_id (str): Project ID @@ -125,7 +211,7 @@ def get_existing_env_vars(token, project_id, team_id=None, target_environment=No raise Exception(f"Error retrieving environment variables: {response.text}") envs = response.json().get("envs", []) - + # Filter variables by target environment if specified if target_environment: envs = [env for env in envs if target_environment in env["target"]] @@ -152,6 +238,45 @@ def delete_env_var(token, project_id, team_id, env_var_id): raise Exception(f"Error deleting environment variable: {response.text}") +def resolve_environment_targets(token, project_id, team_id, environment): + """ + Resolve environment string to actual target environments. + Handles standard environments and custom environment resolution. + + Args: + token (str): Vercel API token + project_id (str): Project ID + team_id (str): Team ID + environment (str): Environment specification + + Returns: + list: List of resolved environment targets + """ + # Handle "all" case + if environment == "all": + standard_envs = ["production", "preview", "development"] + custom_envs = get_project_custom_environments(token, project_id, team_id) + custom_env_slugs = [env["slug"] for env in custom_envs] + return standard_envs + custom_env_slugs + + # Handle standard environments + if environment in ["production", "preview", "development"]: + return [environment] + + # Check if it's a custom environment slug + custom_envs = get_project_custom_environments(token, project_id, team_id) + custom_env_slugs = [env["slug"] for env in custom_envs] + + if environment in custom_env_slugs: + return [environment] + + # If not found, raise an error + available_envs = ["production", "preview", "development"] + custom_env_slugs + raise Exception( + f"Environment '{environment}' not found. Available environments: {available_envs}" + ) + + def sync_vercel_secrets( secrets, credential_id, @@ -162,13 +287,14 @@ def sync_vercel_secrets( ): """ Sync secrets to a specific Vercel project environment. + Now properly handles custom environments using customEnvironmentIds. Args: secrets (list of tuple): List of (key, value, comment) tuples to sync credential_id (str): The ID of the stored credentials project_id (str): The Vercel project ID team_id (str): The Vercel project team ID - environment (str): Target environment (development/preview/production/all) + environment (str): Target environment (development/preview/production/all/custom-env-slug) secret_type (str): Type of secret (plain/encrypted/sensitive) Returns: @@ -177,13 +303,15 @@ def sync_vercel_secrets( try: token = get_vercel_credentials(credential_id) - # Determine target environments - target_environments = ( - ["production", "preview", "development"] - if environment == "all" - else [environment] + # Resolve target environments (handles custom environments) + target_environments = resolve_environment_targets( + token, project_id, team_id, environment ) + # Get custom environments mapping for proper target identification + custom_envs = get_project_custom_environments(token, project_id, team_id) + custom_env_map = {env["slug"]: env["id"] for env in custom_envs} + all_updates_successful = True messages = [] @@ -200,21 +328,31 @@ def sync_vercel_secrets( # Check if the environment variable exists and needs updating if key in existing_env_vars: existing_var = existing_env_vars[key] - if ( - value != existing_var["value"] - or comment != existing_var.get("comment") + if value != existing_var["value"] or comment != existing_var.get( + "comment" ): # Only delete if we're updating this specific variable delete_env_var(token, project_id, team_id, existing_var["id"]) + # Create environment variable with proper targeting env_var = { "key": key, "value": value, "type": secret_type, - "target": [target_env], # Set target to specific environment } + + # Add comment if provided if comment: env_var["comment"] = comment + + # Handle custom vs standard environments differently + if target_env in custom_env_map: + # For custom environments, use customEnvironmentIds + env_var["customEnvironmentIds"] = [custom_env_map[target_env]] + else: + # For standard environments, use target array + env_var["target"] = [target_env] + payload.append(env_var) # Delete environment variables not in the source (only for this environment) @@ -224,14 +362,16 @@ def sync_vercel_secrets( # Bulk create environment variables if payload: + url = f"{VERCEL_API_BASE_URL}/v10/projects/{project_id}/env?upsert=true" if team_id is not None: url += f"&teamId={team_id}" + response = requests.post( url, headers=get_vercel_headers(token), json=payload ) - if response.status_code != 201: + if response.status_code not in [200, 201]: all_updates_successful = False messages.append( f"Failed to sync secrets for environment {target_env}: {response.text}" diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 2e2ef231f..22aaee8ae 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -162,7 +162,7 @@ const documents = { "query GetRailwayProjects($credentialId: ID!) {\n railwayProjects(credentialId: $credentialId) {\n id\n name\n environments {\n id\n name\n }\n services {\n id\n name\n }\n }\n}": types.GetRailwayProjectsDocument, "query GetRenderResources($credentialId: ID!) {\n renderServices(credentialId: $credentialId) {\n id\n name\n type\n }\n renderEnvgroups(credentialId: $credentialId) {\n id\n name\n }\n}": types.GetRenderResourcesDocument, "query TestVaultAuth($credentialId: ID!) {\n testVaultCreds(credentialId: $credentialId)\n}": types.TestVaultAuthDocument, - "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environment\n }\n }\n}": types.GetVercelProjectsDocument, + "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n }\n }\n }\n}": types.GetVercelProjectsDocument, "query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\n id\n role {\n id\n name\n description\n permissions\n color\n }\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n lastLogin\n self\n appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}": types.GetOrganisationMemberDetailDocument, "query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}": types.GetUserTokensDocument, }; @@ -780,7 +780,7 @@ export function graphql(source: "query TestVaultAuth($credentialId: ID!) {\n te /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environment\n }\n }\n}"): (typeof documents)["query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environment\n }\n }\n}"]; +export function graphql(source: "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n }\n }\n }\n}"): (typeof documents)["query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n }\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index f6de5ce83..bbb5619f4 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -2523,9 +2523,18 @@ export type UserTokenType = { wrappedKeyShare: Scalars['String']['output']; }; +export type VercelEnvironmentType = { + __typename?: 'VercelEnvironmentType'; + description?: Maybe; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; + slug: Scalars['String']['output']; + type?: Maybe; +}; + export type VercelProjectType = { __typename?: 'VercelProjectType'; - environment?: Maybe>>; + environments?: Maybe>>; id: Scalars['ID']['output']; name: Scalars['String']['output']; }; @@ -3800,7 +3809,7 @@ export type GetVercelProjectsQueryVariables = Exact<{ }>; -export type GetVercelProjectsQuery = { __typename?: 'Query', vercelProjects?: Array<{ __typename?: 'VercelTeamProjectsType', id: string, teamName: string, projects?: Array<{ __typename?: 'VercelProjectType', id: string, name: string, environment?: Array | null } | null> | null } | null> | null }; +export type GetVercelProjectsQuery = { __typename?: 'Query', vercelProjects?: Array<{ __typename?: 'VercelTeamProjectsType', id: string, teamName: string, projects?: Array<{ __typename?: 'VercelProjectType', id: string, name: string, environments?: Array<{ __typename?: 'VercelEnvironmentType', id: string, name: string } | null> | null } | null> | null } | null> | null }; export type GetOrganisationMemberDetailQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; @@ -3967,6 +3976,6 @@ export const TestNomadAuthDocument = {"kind":"Document","definitions":[{"kind":" export const GetRailwayProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRailwayProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"railwayProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetRenderResourcesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRenderResources"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renderServices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"renderEnvgroups"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const TestVaultAuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TestVaultAuth"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testVaultCreds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}]}]}}]} as unknown as DocumentNode; -export const GetVercelProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVercelProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vercelProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"teamName"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environment"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetVercelProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVercelProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vercelProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"teamName"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetOrganisationMemberDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationMemberDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationMembers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastLogin"}},{"kind":"Field","name":{"kind":"Name","value":"self"}},{"kind":"Field","name":{"kind":"Name","value":"appMemberships"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"networkPolicies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowedIps"}},{"kind":"Field","name":{"kind":"Name","value":"isGlobal"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetUserTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyShare"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index e87d120d1..16dbd9b12 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -864,7 +864,15 @@ type VercelTeamProjectsType { type VercelProjectType { id: ID! name: String! - environment: [String] + environments: [VercelEnvironmentType] +} + +type VercelEnvironmentType { + id: ID! + name: String! + description: String + slug: String! + type: String } type RenderServiceType { diff --git a/frontend/components/syncing/Vercel/CreateVercelSync.tsx b/frontend/components/syncing/Vercel/CreateVercelSync.tsx index ee5b3fcdb..a6a4b959a 100644 --- a/frontend/components/syncing/Vercel/CreateVercelSync.tsx +++ b/frontend/components/syncing/Vercel/CreateVercelSync.tsx @@ -9,6 +9,7 @@ import { Button } from '../../common/Button' import { EnvironmentType, ProviderCredentialsType, + VercelEnvironmentType, VercelProjectType, VercelTeamProjectsType, } from '@/apollo/graphql' @@ -48,7 +49,7 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void const [phaseEnv, setPhaseEnv] = useState(null) const [path, setPath] = useState('/') - const [environment, setEnvironment] = useState('production') + const [vercelEnvironment, setVercelEnvironment] = useState(null) const [secretType, setSecretType] = useState('encrypted') const [credentialsValid, setCredentialsValid] = useState(false) @@ -99,7 +100,7 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void projectName: vercelProject.name, teamId: vercelTeam?.id, teamName: vercelTeam?.teamName, - environment, + environment: vercelEnvironment?.slug, secretType, }, refetchQueries: [{ query: GetAppSyncStatus, variables: { appId } }], @@ -125,7 +126,8 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void project!.name?.toLowerCase().includes(projectQuery.toLowerCase()) ) - const environments = ['production', 'preview', 'development', 'all'] + const environments: VercelEnvironmentType[] = + (vercelProject?.environments as VercelEnvironmentType[]) || [] const secretTypes = [ { value: 'plain', label: 'Plain Text' }, { value: 'encrypted', label: 'Encrypted' }, @@ -133,7 +135,7 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void ] return ( -
+
@@ -335,7 +337,7 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void )}
- +
{environments.map((env) => ( - + {({ active, checked }) => (
void )} > {checked ? : } - {env} + {env.name}
)}
diff --git a/frontend/graphql/queries/syncing/vercel/getProject.gql b/frontend/graphql/queries/syncing/vercel/getProject.gql index 9c4c2d5e5..ec6ec1ac6 100644 --- a/frontend/graphql/queries/syncing/vercel/getProject.gql +++ b/frontend/graphql/queries/syncing/vercel/getProject.gql @@ -5,7 +5,11 @@ query GetVercelProjects($credentialId: ID!) { projects { id name - environment + environments { + id + name + slug + } } } } From 68ef5bc879d20cba185ba93ea6f0ad0cde489313 Mon Sep 17 00:00:00 2001 From: rohan Date: Fri, 10 Oct 2025 14:30:46 +0530 Subject: [PATCH 02/13] fix: padding Signed-off-by: rohan --- frontend/components/syncing/Vercel/CreateVercelSync.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/syncing/Vercel/CreateVercelSync.tsx b/frontend/components/syncing/Vercel/CreateVercelSync.tsx index a6a4b959a..5c5f875db 100644 --- a/frontend/components/syncing/Vercel/CreateVercelSync.tsx +++ b/frontend/components/syncing/Vercel/CreateVercelSync.tsx @@ -135,7 +135,7 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void ] return ( -
+
From 6ad9ca659a7ddf831a50679329e78bd441bcd47a Mon Sep 17 00:00:00 2001 From: Rohan Chaturvedi Date: Fri, 10 Oct 2025 14:38:51 +0530 Subject: [PATCH 03/13] chore: add more verbos logging Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/api/utils/syncing/vercel/main.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/api/utils/syncing/vercel/main.py b/backend/api/utils/syncing/vercel/main.py index 3c3e631ae..f817aa988 100644 --- a/backend/api/utils/syncing/vercel/main.py +++ b/backend/api/utils/syncing/vercel/main.py @@ -70,12 +70,21 @@ def get_project_custom_environments(token, project_id, team_id=None): response = requests.get(url, headers=get_vercel_headers(token)) if response.status_code != 200: - # If custom environments endpoint fails, return empty list (project might not have custom envs) - logger.info("No custom environments found or error occurred.") + # Log error details for debugging + logger.error( + f"Failed to fetch custom environments for project '{project_id}'" + f"{' (team: ' + team_id + ')' if team_id else ''}: " + f"Status code: {response.status_code}, Response: {response.text}" + ) return [] # Parse the correct response structure custom_envs = response.json().get("environments", []) + if not custom_envs: + logger.info( + f"No custom environments exist for project '{project_id}'" + f"{' (team: ' + team_id + ')' if team_id else ''}." + ) return [ { "id": env["id"], From fc81d766f48fa5aa95b72289a7f5e0146a649a3f Mon Sep 17 00:00:00 2001 From: Rohan Chaturvedi Date: Fri, 10 Oct 2025 14:39:06 +0530 Subject: [PATCH 04/13] chore: update comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/api/utils/syncing/vercel/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/utils/syncing/vercel/main.py b/backend/api/utils/syncing/vercel/main.py index f817aa988..058ac808f 100644 --- a/backend/api/utils/syncing/vercel/main.py +++ b/backend/api/utils/syncing/vercel/main.py @@ -379,7 +379,7 @@ def sync_vercel_secrets( response = requests.post( url, headers=get_vercel_headers(token), json=payload ) - + # Accept both 200 and 201 status codes as successful responses if response.status_code not in [200, 201]: all_updates_successful = False messages.append( From 763c85a606ededd80af3fa924618b384c2a6d51a Mon Sep 17 00:00:00 2001 From: Nimish Date: Sun, 12 Oct 2025 13:34:18 +0530 Subject: [PATCH 05/13] feat: preselect first available Vercel team in CreateVercelSync component to save a click --- frontend/components/syncing/Vercel/CreateVercelSync.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/components/syncing/Vercel/CreateVercelSync.tsx b/frontend/components/syncing/Vercel/CreateVercelSync.tsx index 5c5f875db..3152a603b 100644 --- a/frontend/components/syncing/Vercel/CreateVercelSync.tsx +++ b/frontend/components/syncing/Vercel/CreateVercelSync.tsx @@ -68,6 +68,13 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void } }, [appEnvsData]) + // Preselect first available Vercel team + useEffect(() => { + if (vercelTeams.length > 0) { + setVercelTeam(vercelTeams[0]) + } + }, [vercelTeams]) + const handleSubmit = async (e: { preventDefault: () => void }) => { e.preventDefault() From 8687b15853748ac6be48c0d52daf0961e70f9d9f Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 13 Oct 2025 18:39:44 +0530 Subject: [PATCH 06/13] feat: enhance Vercel project and environment handling with support for 'all' environments and improved filtering Signed-off-by: rohan --- backend/api/utils/syncing/vercel/main.py | 178 +++++++++++------- frontend/apollo/gql.ts | 4 +- frontend/apollo/graphql.ts | 4 +- .../syncing/Vercel/CreateVercelSync.tsx | 107 ++++++++++- .../queries/syncing/vercel/getProject.gql | 1 + 5 files changed, 212 insertions(+), 82 deletions(-) diff --git a/backend/api/utils/syncing/vercel/main.py b/backend/api/utils/syncing/vercel/main.py index 058ac808f..020f29a95 100644 --- a/backend/api/utils/syncing/vercel/main.py +++ b/backend/api/utils/syncing/vercel/main.py @@ -147,6 +147,12 @@ def list_vercel_projects(credential_id): # Standard environments environments = [ + { + "id": "all", + "name": "All Environments", + "slug": "all", + "type": "all", + }, { "id": "dev", "name": "Development", @@ -223,7 +229,18 @@ def get_existing_env_vars(token, project_id, team_id=None, target_environment=No # Filter variables by target environment if specified if target_environment: - envs = [env for env in envs if target_environment in env["target"]] + # Get custom environments to map slugs to IDs for proper filtering + custom_envs = get_project_custom_environments(token, project_id, team_id) + custom_env_map = {env["slug"]: env["id"] for env in custom_envs} + + # Check if target_environment is a custom environment + if target_environment in custom_env_map: + # For custom environments, check if the custom environment ID is in the target array + custom_env_id = custom_env_map[target_environment] + envs = [env for env in envs if custom_env_id in env["target"]] + else: + # For standard environments, use the slug directly + envs = [env for env in envs if target_environment in env["target"]] return { env["key"]: { @@ -296,7 +313,7 @@ def sync_vercel_secrets( ): """ Sync secrets to a specific Vercel project environment. - Now properly handles custom environments using customEnvironmentIds. + Now properly handles custom environments and 'all' environments using Vercel's native targeting. Args: secrets (list of tuple): List of (key, value, comment) tuples to sync @@ -312,85 +329,104 @@ def sync_vercel_secrets( try: token = get_vercel_credentials(credential_id) - # Resolve target environments (handles custom environments) - target_environments = resolve_environment_targets( - token, project_id, team_id, environment - ) - # Get custom environments mapping for proper target identification custom_envs = get_project_custom_environments(token, project_id, team_id) custom_env_map = {env["slug"]: env["id"] for env in custom_envs} - all_updates_successful = True - messages = [] + logger.info(f"Syncing secrets to environment: {environment}") - # Process each target environment separately - for target_env in target_environments: - # Get existing environment variables for this specific environment + # Get existing environment variables + if environment == "all": + # For 'all', get all env vars without filtering + existing_env_vars = get_existing_env_vars(token, project_id, team_id) + else: + # For specific environments, filter appropriately existing_env_vars = get_existing_env_vars( - token, project_id, team_id, target_environment=target_env + token, project_id, team_id, target_environment=environment ) - # Prepare payload for bulk creation - payload = [] - for key, value, comment in secrets: - # Check if the environment variable exists and needs updating - if key in existing_env_vars: - existing_var = existing_env_vars[key] - if value != existing_var["value"] or comment != existing_var.get( - "comment" - ): - # Only delete if we're updating this specific variable - delete_env_var(token, project_id, team_id, existing_var["id"]) - - # Create environment variable with proper targeting - env_var = { - "key": key, - "value": value, - "type": secret_type, - } - - # Add comment if provided - if comment: - env_var["comment"] = comment - - # Handle custom vs standard environments differently - if target_env in custom_env_map: - # For custom environments, use customEnvironmentIds - env_var["customEnvironmentIds"] = [custom_env_map[target_env]] - else: - # For standard environments, use target array - env_var["target"] = [target_env] - - payload.append(env_var) - - # Delete environment variables not in the source (only for this environment) - for key, env_var in existing_env_vars.items(): - if not any(s[0] == key for s in secrets): - delete_env_var(token, project_id, team_id, env_var["id"]) - - # Bulk create environment variables - if payload: + # Prepare payload for bulk creation + payload = [] + for key, value, comment in secrets: + # Check if the environment variable exists and needs updating + if key in existing_env_vars: + existing_var = existing_env_vars[key] + if value != existing_var["value"] or comment != existing_var.get( + "comment" + ): + # Delete the existing variable so we can recreate it + delete_env_var(token, project_id, team_id, existing_var["id"]) + + # Create environment variable with proper targeting + env_var = { + "key": key, + "value": value, + "type": secret_type, + } + + # Add comment if provided + if comment: + env_var["comment"] = comment + + # Handle different environment targeting + if environment == "all": + # For 'all' environments, target standard environments and add custom environment IDs + env_var["target"] = ["production", "preview", "development"] + if custom_envs: + env_var["customEnvironmentIds"] = [env["id"] for env in custom_envs] + logger.info( + f"Targeting all environments with custom IDs: {[env['id'] for env in custom_envs]}" + ) + elif environment in custom_env_map: + # For custom environments, use customEnvironmentIds + env_var["customEnvironmentIds"] = [custom_env_map[environment]] + logger.info( + f"Using customEnvironmentIds for {environment}: {custom_env_map[environment]}" + ) + else: + # For standard environments, use target array + env_var["target"] = [environment] + logger.info(f"Using target for {environment}") + + payload.append(env_var) + + # Handle deletion of variables not in the source + # For 'all' environments, we need to be more careful about deletion + # to ensure we delete from all environments that the variable was originally in + for key, env_var in existing_env_vars.items(): + if not any(s[0] == key for s in secrets): + delete_env_var(token, project_id, team_id, env_var["id"]) + + # Bulk create environment variables + if payload: + logger.info( + f"Syncing {len(payload)} variables to environment: {environment}" + ) - url = f"{VERCEL_API_BASE_URL}/v10/projects/{project_id}/env?upsert=true" - if team_id is not None: - url += f"&teamId={team_id}" + url = f"{VERCEL_API_BASE_URL}/v10/projects/{project_id}/env?upsert=true" + if team_id is not None: + url += f"&teamId={team_id}" - response = requests.post( - url, headers=get_vercel_headers(token), json=payload - ) - # Accept both 200 and 201 status codes as successful responses - if response.status_code not in [200, 201]: - all_updates_successful = False - messages.append( - f"Failed to sync secrets for environment {target_env}: {response.text}" - ) - else: - messages.append( - f"Successfully synced secrets to environment: {target_env}" - ) + response = requests.post( + url, headers=get_vercel_headers(token), json=payload + ) - return all_updates_successful, {"message": "; ".join(messages)} + logger.info(f"API response: {response.status_code}") + if response.status_code >= 400: + logger.error(f"Response body: {response.text}") + + if response.status_code not in [200, 201]: + error_msg = f"Failed to sync secrets: {response.text}" + logger.error(error_msg) + return False, {"message": error_msg} + else: + success_msg = f"Successfully synced {len(payload)} secrets to environment: {environment}" + logger.info(success_msg) + return True, {"message": success_msg} + else: + return True, {"message": "No secrets to sync"} except Exception as e: - return False, {"message": f"Failed to sync secrets: {str(e)}"} + error_msg = f"Failed to sync secrets: {str(e)}" + logger.error(error_msg) + return False, {"message": error_msg} diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 22aaee8ae..c2cd191b7 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -162,7 +162,7 @@ const documents = { "query GetRailwayProjects($credentialId: ID!) {\n railwayProjects(credentialId: $credentialId) {\n id\n name\n environments {\n id\n name\n }\n services {\n id\n name\n }\n }\n}": types.GetRailwayProjectsDocument, "query GetRenderResources($credentialId: ID!) {\n renderServices(credentialId: $credentialId) {\n id\n name\n type\n }\n renderEnvgroups(credentialId: $credentialId) {\n id\n name\n }\n}": types.GetRenderResourcesDocument, "query TestVaultAuth($credentialId: ID!) {\n testVaultCreds(credentialId: $credentialId)\n}": types.TestVaultAuthDocument, - "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n }\n }\n }\n}": types.GetVercelProjectsDocument, + "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\n }\n }\n}": types.GetVercelProjectsDocument, "query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\n id\n role {\n id\n name\n description\n permissions\n color\n }\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n lastLogin\n self\n appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}": types.GetOrganisationMemberDetailDocument, "query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}": types.GetUserTokensDocument, }; @@ -780,7 +780,7 @@ export function graphql(source: "query TestVaultAuth($credentialId: ID!) {\n te /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n }\n }\n }\n}"): (typeof documents)["query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n }\n }\n }\n}"]; +export function graphql(source: "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\n }\n }\n}"): (typeof documents)["query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index bbb5619f4..65f5a0a90 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -3809,7 +3809,7 @@ export type GetVercelProjectsQueryVariables = Exact<{ }>; -export type GetVercelProjectsQuery = { __typename?: 'Query', vercelProjects?: Array<{ __typename?: 'VercelTeamProjectsType', id: string, teamName: string, projects?: Array<{ __typename?: 'VercelProjectType', id: string, name: string, environments?: Array<{ __typename?: 'VercelEnvironmentType', id: string, name: string } | null> | null } | null> | null } | null> | null }; +export type GetVercelProjectsQuery = { __typename?: 'Query', vercelProjects?: Array<{ __typename?: 'VercelTeamProjectsType', id: string, teamName: string, projects?: Array<{ __typename?: 'VercelProjectType', id: string, name: string, environments?: Array<{ __typename?: 'VercelEnvironmentType', id: string, name: string, slug: string, type?: string | null } | null> | null } | null> | null } | null> | null }; export type GetOrganisationMemberDetailQueryVariables = Exact<{ organisationId: Scalars['ID']['input']; @@ -3976,6 +3976,6 @@ export const TestNomadAuthDocument = {"kind":"Document","definitions":[{"kind":" export const GetRailwayProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRailwayProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"railwayProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetRenderResourcesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRenderResources"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renderServices"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"renderEnvgroups"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const TestVaultAuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TestVaultAuth"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testVaultCreds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}]}]}}]} as unknown as DocumentNode; -export const GetVercelProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVercelProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vercelProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"teamName"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetVercelProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetVercelProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vercelProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"teamName"}},{"kind":"Field","name":{"kind":"Name","value":"projects"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetOrganisationMemberDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationMemberDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationMembers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"lastLogin"}},{"kind":"Field","name":{"kind":"Name","value":"self"}},{"kind":"Field","name":{"kind":"Name","value":"appMemberships"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"networkPolicies"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"allowedIps"}},{"kind":"Field","name":{"kind":"Name","value":"isGlobal"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetUserTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyShare"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/components/syncing/Vercel/CreateVercelSync.tsx b/frontend/components/syncing/Vercel/CreateVercelSync.tsx index 3152a603b..5f340d738 100644 --- a/frontend/components/syncing/Vercel/CreateVercelSync.tsx +++ b/frontend/components/syncing/Vercel/CreateVercelSync.tsx @@ -47,9 +47,12 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void const [vercelProject, setVercelProject] = useState(null) const [projectQuery, setProjectQuery] = useState('') + const [vercelEnvironment, setVercelEnvironment] = useState(null) + const [envQuery, setEnvQuery] = useState('') + const [phaseEnv, setPhaseEnv] = useState(null) const [path, setPath] = useState('/') - const [vercelEnvironment, setVercelEnvironment] = useState(null) + const [secretType, setSecretType] = useState('encrypted') const [credentialsValid, setCredentialsValid] = useState(false) @@ -133,14 +136,28 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void project!.name?.toLowerCase().includes(projectQuery.toLowerCase()) ) - const environments: VercelEnvironmentType[] = - (vercelProject?.environments as VercelEnvironmentType[]) || [] + // const environments: VercelEnvironmentType[] = + // (vercelProject?.environments as VercelEnvironmentType[]) || [] + const filteredEnvs: VercelEnvironmentType[] = + envQuery === '' + ? (vercelProject?.environments as VercelEnvironmentType[]) ?? [] + : ((vercelProject?.environments as VercelEnvironmentType[]) ?? []).filter((env) => + env?.name?.toLowerCase().includes(envQuery.toLowerCase()) + ) + const secretTypes = [ { value: 'plain', label: 'Plain Text' }, { value: 'encrypted', label: 'Encrypted' }, { value: 'sensitive', label: 'Sensitive' }, ] + // Environment type badge styling + const envTypeBadgeStyles = { + all: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200', + standard: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + custom: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + } + return (
@@ -314,7 +331,7 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void leaveTo="transform scale-95 opacity-0" > -
+
{filteredProjects!.map((project) => ( {({ active }) => ( @@ -343,8 +360,84 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void
)} -
- +
+ + {({ open }) => ( + <> +
+ + + +
+ setEnvQuery(event.target.value)} + displayValue={(env: VercelEnvironmentType) => env?.name!} + required + /> +
+ + + +
+
+
+ + +
+ {filteredEnvs!.map((env) => ( + + {({ active }) => ( +
+
+
+ {env!.name} +
+
+ {env!.id} +
+
+
+ {env!.type} +
+
+ )} +
+ ))} +
+
+
+ + )} +
+ + {/*
-
+ */}
diff --git a/frontend/graphql/queries/syncing/vercel/getProject.gql b/frontend/graphql/queries/syncing/vercel/getProject.gql index ec6ec1ac6..449e3ab46 100644 --- a/frontend/graphql/queries/syncing/vercel/getProject.gql +++ b/frontend/graphql/queries/syncing/vercel/getProject.gql @@ -9,6 +9,7 @@ query GetVercelProjects($credentialId: ID!) { id name slug + type } } } From 74eb45145aeed1cfc9cb997a8023ac89f3c0a3ed Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 13 Oct 2025 18:42:46 +0530 Subject: [PATCH 07/13] feat: improve light theme combobox styles Signed-off-by: rohan --- .../components/syncing/Vercel/CreateVercelSync.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/components/syncing/Vercel/CreateVercelSync.tsx b/frontend/components/syncing/Vercel/CreateVercelSync.tsx index 5f340d738..74ced4563 100644 --- a/frontend/components/syncing/Vercel/CreateVercelSync.tsx +++ b/frontend/components/syncing/Vercel/CreateVercelSync.tsx @@ -267,14 +267,14 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void leaveTo="transform scale-95 opacity-0" > -
+
{filteredTeams.map((team) => ( {({ active }) => (
@@ -331,14 +331,14 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void leaveTo="transform scale-95 opacity-0" > -
+
{filteredProjects!.map((project) => ( {({ active }) => (
@@ -398,14 +398,14 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void leaveTo="transform scale-95 opacity-0" > -
+
{filteredEnvs!.map((env) => ( {({ active }) => (
From c71d1670a3a54ee6611603abc264efb58dce8051 Mon Sep 17 00:00:00 2001 From: Rohan Chaturvedi Date: Mon, 13 Oct 2025 18:43:50 +0530 Subject: [PATCH 08/13] chore: clean up dead code Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/components/syncing/Vercel/CreateVercelSync.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/components/syncing/Vercel/CreateVercelSync.tsx b/frontend/components/syncing/Vercel/CreateVercelSync.tsx index 74ced4563..45a341329 100644 --- a/frontend/components/syncing/Vercel/CreateVercelSync.tsx +++ b/frontend/components/syncing/Vercel/CreateVercelSync.tsx @@ -136,8 +136,6 @@ export const CreateVercelSync = (props: { appId: string; closeModal: () => void project!.name?.toLowerCase().includes(projectQuery.toLowerCase()) ) - // const environments: VercelEnvironmentType[] = - // (vercelProject?.environments as VercelEnvironmentType[]) || [] const filteredEnvs: VercelEnvironmentType[] = envQuery === '' ? (vercelProject?.environments as VercelEnvironmentType[]) ?? [] From d0c3e3b58dcfe265a04c0fd121fd0e82ab48e63d Mon Sep 17 00:00:00 2001 From: Rohan Chaturvedi Date: Mon, 13 Oct 2025 18:45:09 +0530 Subject: [PATCH 09/13] Update backend/api/utils/syncing/vercel/main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/api/utils/syncing/vercel/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/utils/syncing/vercel/main.py b/backend/api/utils/syncing/vercel/main.py index 020f29a95..b2a759b4f 100644 --- a/backend/api/utils/syncing/vercel/main.py +++ b/backend/api/utils/syncing/vercel/main.py @@ -88,7 +88,7 @@ def get_project_custom_environments(token, project_id, team_id=None): return [ { "id": env["id"], - "name": env["slug"].title(), # Use slug as name, capitalize first letter + "name": env["slug"].capitalize(), # Use slug as name, capitalize first letter "description": env.get("description", ""), "slug": env["slug"], } From 9058cc18a635e6614152280538ed12bb8951c590 Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 13 Oct 2025 19:59:24 +0530 Subject: [PATCH 10/13] feat: enhance error handling and logging in get_project_custom_environments and sync_vercel_secrets functions Signed-off-by: rohan --- backend/api/utils/syncing/vercel/main.py | 92 ++++++++++++++++-------- 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/backend/api/utils/syncing/vercel/main.py b/backend/api/utils/syncing/vercel/main.py index b2a759b4f..eb57b093c 100644 --- a/backend/api/utils/syncing/vercel/main.py +++ b/backend/api/utils/syncing/vercel/main.py @@ -67,33 +67,35 @@ def get_project_custom_environments(token, project_id, team_id=None): if team_id: url += f"?teamId={team_id}" - response = requests.get(url, headers=get_vercel_headers(token)) + try: + response = requests.get(url, headers=get_vercel_headers(token)) - if response.status_code != 200: - # Log error details for debugging - logger.error( - f"Failed to fetch custom environments for project '{project_id}'" - f"{' (team: ' + team_id + ')' if team_id else ''}: " - f"Status code: {response.status_code}, Response: {response.text}" - ) - return [] + if response.status_code != 200: + logger.warning( + f"Failed to fetch custom environments for project '{project_id}'" + f"{' (team: ' + team_id + ')' if team_id else ''}: " + f"Status code: {response.status_code}, Response: {response.text}" + ) + # Return empty list but log the failure + return [] - # Parse the correct response structure - custom_envs = response.json().get("environments", []) - if not custom_envs: + custom_envs = response.json().get("environments", []) logger.info( - f"No custom environments exist for project '{project_id}'" - f"{' (team: ' + team_id + ')' if team_id else ''}." + f"Found {len(custom_envs)} custom environments for project {project_id}" ) - return [ - { - "id": env["id"], - "name": env["slug"].capitalize(), # Use slug as name, capitalize first letter - "description": env.get("description", ""), - "slug": env["slug"], - } - for env in custom_envs - ] + + return [ + { + "id": env["id"], + "name": env["slug"].capitalize(), + "description": env.get("description", ""), + "slug": env["slug"], + } + for env in custom_envs + ] + except Exception as e: + logger.error(f"Exception fetching custom environments: {str(e)}") + return [] def list_vercel_projects(credential_id): @@ -313,7 +315,6 @@ def sync_vercel_secrets( ): """ Sync secrets to a specific Vercel project environment. - Now properly handles custom environments and 'all' environments using Vercel's native targeting. Args: secrets (list of tuple): List of (key, value, comment) tuples to sync @@ -333,12 +334,43 @@ def sync_vercel_secrets( custom_envs = get_project_custom_environments(token, project_id, team_id) custom_env_map = {env["slug"]: env["id"] for env in custom_envs} + # Validate environment exists before proceeding + if ( + environment not in ["all", "production", "preview", "development"] + and environment not in custom_env_map + ): + # If custom envs failed to load, give a more helpful error + if not custom_envs: + logger.warning( + "Custom environments could not be loaded - this may cause sync issues" + ) + + available_envs = ["all", "production", "preview", "development"] + list( + custom_env_map.keys() + ) + raise Exception( + f"Environment '{environment}' not found. Available environments: {available_envs}" + ) + logger.info(f"Syncing secrets to environment: {environment}") - # Get existing environment variables + # Get existing environment variables with proper scoping if environment == "all": - # For 'all', get all env vars without filtering - existing_env_vars = get_existing_env_vars(token, project_id, team_id) + # For 'all', we need to be more surgical about which vars to delete + # Only delete vars that exist in the environments we're targeting + all_existing_vars = get_existing_env_vars(token, project_id, team_id) + + # Filter to only vars that target the environments we're syncing to + target_env_ids = set(["production", "preview", "development"]) + if custom_envs: + target_env_ids.update([env["id"] for env in custom_envs]) + + existing_env_vars = {} + for key, var in all_existing_vars.items(): + # Check if this var targets any of our sync environments + var_targets = set(var["target"]) + if var_targets.intersection(target_env_ids): + existing_env_vars[key] = var else: # For specific environments, filter appropriately existing_env_vars = get_existing_env_vars( @@ -391,10 +423,12 @@ def sync_vercel_secrets( payload.append(env_var) # Handle deletion of variables not in the source - # For 'all' environments, we need to be more careful about deletion - # to ensure we delete from all environments that the variable was originally in + # Only delete vars that were in our filtered existing_env_vars for key, env_var in existing_env_vars.items(): if not any(s[0] == key for s in secrets): + logger.info( + f"Deleting environment variable '{key}' (ID: {env_var['id']})" + ) delete_env_var(token, project_id, team_id, env_var["id"]) # Bulk create environment variables From b07cc73771babec2a26e990d1b814f6f4572215f Mon Sep 17 00:00:00 2001 From: rohan Date: Tue, 14 Oct 2025 20:40:57 +0530 Subject: [PATCH 11/13] fix: display environment name in manageSyncDialog Signed-off-by: rohan --- frontend/components/syncing/ManageSyncDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/syncing/ManageSyncDialog.tsx b/frontend/components/syncing/ManageSyncDialog.tsx index c68a56015..88436d6e1 100644 --- a/frontend/components/syncing/ManageSyncDialog.tsx +++ b/frontend/components/syncing/ManageSyncDialog.tsx @@ -82,7 +82,7 @@ export const ManageSyncDialog = (props: { sync: EnvironmentSyncType; button: Rea - {sync.environment.envType} + {sync.environment.name}
From e772e5406663781a08a243df620c6245dcb4ef74 Mon Sep 17 00:00:00 2001 From: rohan Date: Tue, 14 Oct 2025 20:41:26 +0530 Subject: [PATCH 12/13] refactor: misc fixes and improvements to vercel syncing logic Signed-off-by: rohan --- backend/api/utils/syncing/vercel/main.py | 303 +++++++++++------------ 1 file changed, 139 insertions(+), 164 deletions(-) diff --git a/backend/api/utils/syncing/vercel/main.py b/backend/api/utils/syncing/vercel/main.py index eb57b093c..6ab0046f2 100644 --- a/backend/api/utils/syncing/vercel/main.py +++ b/backend/api/utils/syncing/vercel/main.py @@ -51,6 +51,17 @@ def test_vercel_creds(credential_id): return False +def delete_env_var(token, project_id, team_id, env_var_id): + """Delete a Vercel environment variable using its ID.""" + url = f"{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env/{env_var_id}" + if team_id is not None: + url += f"?teamId={team_id}" + response = requests.delete(url, headers=get_vercel_headers(token)) + + if response.status_code != 200: + raise Exception(f"Error deleting environment variable: {response.text}") + + def get_project_custom_environments(token, project_id, team_id=None): """ Retrieve custom environments for a specific Vercel project. @@ -74,9 +85,8 @@ def get_project_custom_environments(token, project_id, team_id=None): logger.warning( f"Failed to fetch custom environments for project '{project_id}'" f"{' (team: ' + team_id + ')' if team_id else ''}: " - f"Status code: {response.status_code}, Response: {response.text}" + f"Status code: {response.status_code}" ) - # Return empty list but log the failure return [] custom_envs = response.json().get("environments", []) @@ -209,40 +219,62 @@ def list_vercel_projects(credential_id): raise Exception(f"Error listing Vercel projects: {str(e)}") -def get_existing_env_vars(token, project_id, team_id=None, target_environment=None): +def get_existing_env_vars(token, project_id, team_id, target_environment): """ Retrieve environment variables for a specific Vercel project and environment. + Only returns variables that EXCLUSIVELY target the specified environment. Args: token (str): Vercel API token project_id (str): Project ID - team_id (str, optional): Team ID - target_environment (str, optional): Specific environment to filter by + team_id (str): Team ID + target_environment (str): Specific environment to filter by (REQUIRED) + + Returns: + dict: Dictionary mapping variable keys to their metadata (id, value, target, comment) """ url = f"{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env" if team_id is not None: url += f"?teamId={team_id}" - response = requests.get(url, headers=get_vercel_headers(token)) + response = requests.get(url, headers=get_vercel_headers(token)) if response.status_code != 200: raise Exception(f"Error retrieving environment variables: {response.text}") envs = response.json().get("envs", []) + filtered_envs = [] - # Filter variables by target environment if specified - if target_environment: - # Get custom environments to map slugs to IDs for proper filtering - custom_envs = get_project_custom_environments(token, project_id, team_id) - custom_env_map = {env["slug"]: env["id"] for env in custom_envs} + # Get custom environments to map slugs to IDs for proper filtering + custom_envs = get_project_custom_environments(token, project_id, team_id) + custom_env_map = {env["slug"]: env["id"] for env in custom_envs} + + for env in envs: + env_targets = set(env.get("target", [])) + custom_env_ids = set(env.get("customEnvironmentIds", [])) - # Check if target_environment is a custom environment if target_environment in custom_env_map: - # For custom environments, check if the custom environment ID is in the target array + # For custom environments, check customEnvironmentIds field custom_env_id = custom_env_map[target_environment] - envs = [env for env in envs if custom_env_id in env["target"]] + + # Variable should ONLY target this custom environment + if ( + len(custom_env_ids) == 1 + and custom_env_id in custom_env_ids + and len(env_targets) == 0 + ): + filtered_envs.append(env) else: - # For standard environments, use the slug directly - envs = [env for env in envs if target_environment in env["target"]] + # For standard environments, only include if it EXCLUSIVELY targets this environment + if ( + len(env_targets) == 1 + and target_environment in env_targets + and len(custom_env_ids) == 0 + ): + filtered_envs.append(env) + + logger.info( + f"Found {len(filtered_envs)} variables exclusively targeting environment: {target_environment}" + ) return { env["key"]: { @@ -251,60 +283,10 @@ def get_existing_env_vars(token, project_id, team_id=None, target_environment=No "target": env["target"], "comment": env.get("comment"), } - for env in envs + for env in filtered_envs } -def delete_env_var(token, project_id, team_id, env_var_id): - """Delete a Vercel environment variable using its ID.""" - url = f"{VERCEL_API_BASE_URL}/v9/projects/{project_id}/env/{env_var_id}" - if team_id is not None: - url += f"?teamId={team_id}" - response = requests.delete(url, headers=get_vercel_headers(token)) - - if response.status_code != 200: - raise Exception(f"Error deleting environment variable: {response.text}") - - -def resolve_environment_targets(token, project_id, team_id, environment): - """ - Resolve environment string to actual target environments. - Handles standard environments and custom environment resolution. - - Args: - token (str): Vercel API token - project_id (str): Project ID - team_id (str): Team ID - environment (str): Environment specification - - Returns: - list: List of resolved environment targets - """ - # Handle "all" case - if environment == "all": - standard_envs = ["production", "preview", "development"] - custom_envs = get_project_custom_environments(token, project_id, team_id) - custom_env_slugs = [env["slug"] for env in custom_envs] - return standard_envs + custom_env_slugs - - # Handle standard environments - if environment in ["production", "preview", "development"]: - return [environment] - - # Check if it's a custom environment slug - custom_envs = get_project_custom_environments(token, project_id, team_id) - custom_env_slugs = [env["slug"] for env in custom_envs] - - if environment in custom_env_slugs: - return [environment] - - # If not found, raise an error - available_envs = ["production", "preview", "development"] + custom_env_slugs - raise Exception( - f"Environment '{environment}' not found. Available environments: {available_envs}" - ) - - def sync_vercel_secrets( secrets, credential_id, @@ -315,6 +297,7 @@ def sync_vercel_secrets( ): """ Sync secrets to a specific Vercel project environment. + For 'all' environments, creates separate variables for each environment to avoid cross-environment deletion issues. Args: secrets (list of tuple): List of (key, value, comment) tuples to sync @@ -339,12 +322,6 @@ def sync_vercel_secrets( environment not in ["all", "production", "preview", "development"] and environment not in custom_env_map ): - # If custom envs failed to load, give a more helpful error - if not custom_envs: - logger.warning( - "Custom environments could not be loaded - this may cause sync issues" - ) - available_envs = ["all", "production", "preview", "development"] + list( custom_env_map.keys() ) @@ -352,113 +329,111 @@ def sync_vercel_secrets( f"Environment '{environment}' not found. Available environments: {available_envs}" ) - logger.info(f"Syncing secrets to environment: {environment}") + logger.info(f"Syncing {len(secrets)} secrets to environment: {environment}") - # Get existing environment variables with proper scoping + # Determine target environments to sync to if environment == "all": - # For 'all', we need to be more surgical about which vars to delete - # Only delete vars that exist in the environments we're targeting - all_existing_vars = get_existing_env_vars(token, project_id, team_id) - - # Filter to only vars that target the environments we're syncing to - target_env_ids = set(["production", "preview", "development"]) - if custom_envs: - target_env_ids.update([env["id"] for env in custom_envs]) - - existing_env_vars = {} - for key, var in all_existing_vars.items(): - # Check if this var targets any of our sync environments - var_targets = set(var["target"]) - if var_targets.intersection(target_env_ids): - existing_env_vars[key] = var + target_environments = ["production", "preview", "development"] + list( + custom_env_map.keys() + ) + logger.info( + f"Syncing to all environments individually: {target_environments}" + ) else: - # For specific environments, filter appropriately + target_environments = [environment] + + all_success = True + messages = [] + + # Process each target environment separately + for target_env in target_environments: + logger.info(f"Processing environment: {target_env}") + + # Get existing environment variables for this specific environment existing_env_vars = get_existing_env_vars( - token, project_id, team_id, target_environment=environment + token, project_id, team_id, target_environment=target_env ) - # Prepare payload for bulk creation - payload = [] - for key, value, comment in secrets: - # Check if the environment variable exists and needs updating - if key in existing_env_vars: - existing_var = existing_env_vars[key] - if value != existing_var["value"] or comment != existing_var.get( - "comment" - ): - # Delete the existing variable so we can recreate it - delete_env_var(token, project_id, team_id, existing_var["id"]) - - # Create environment variable with proper targeting - env_var = { - "key": key, - "value": value, - "type": secret_type, - } + logger.info( + f"Found {len(existing_env_vars)} existing variables for environment: {target_env}" + ) - # Add comment if provided - if comment: - env_var["comment"] = comment + # Prepare payload for this specific environment + payload = [] + updated_count = 0 + + for key, value, comment in secrets: + # Check if the environment variable exists and needs updating + if key in existing_env_vars: + existing_var = existing_env_vars[key] + if value != existing_var["value"] or comment != existing_var.get( + "comment" + ): + logger.info(f"Updating variable in environment: {target_env}") + delete_env_var(token, project_id, team_id, existing_var["id"]) + updated_count += 1 + + # Create environment variable with proper targeting for this specific environment + env_var = { + "key": key, + "value": value, + "type": secret_type, + } - # Handle different environment targeting - if environment == "all": - # For 'all' environments, target standard environments and add custom environment IDs - env_var["target"] = ["production", "preview", "development"] - if custom_envs: - env_var["customEnvironmentIds"] = [env["id"] for env in custom_envs] - logger.info( - f"Targeting all environments with custom IDs: {[env['id'] for env in custom_envs]}" - ) - elif environment in custom_env_map: - # For custom environments, use customEnvironmentIds - env_var["customEnvironmentIds"] = [custom_env_map[environment]] - logger.info( - f"Using customEnvironmentIds for {environment}: {custom_env_map[environment]}" - ) - else: - # For standard environments, use target array - env_var["target"] = [environment] - logger.info(f"Using target for {environment}") + if comment: + env_var["comment"] = comment - payload.append(env_var) + # Always create single-environment variables + if target_env in custom_env_map: + # For custom environments, use customEnvironmentIds + env_var["customEnvironmentIds"] = [custom_env_map[target_env]] + else: + # For standard environments, use target array + env_var["target"] = [target_env] - # Handle deletion of variables not in the source - # Only delete vars that were in our filtered existing_env_vars - for key, env_var in existing_env_vars.items(): - if not any(s[0] == key for s in secrets): - logger.info( - f"Deleting environment variable '{key}' (ID: {env_var['id']})" - ) - delete_env_var(token, project_id, team_id, env_var["id"]) + payload.append(env_var) - # Bulk create environment variables - if payload: - logger.info( - f"Syncing {len(payload)} variables to environment: {environment}" - ) + # Handle deletion of variables not in the source for this specific environment + secrets_to_keep = {secret[0] for secret in secrets} + deleted_count = 0 - url = f"{VERCEL_API_BASE_URL}/v10/projects/{project_id}/env?upsert=true" - if team_id is not None: - url += f"&teamId={team_id}" + for key, env_var in existing_env_vars.items(): + if key not in secrets_to_keep: + logger.info( + f"Deleting unused variable from environment: {target_env}" + ) + delete_env_var(token, project_id, team_id, env_var["id"]) + deleted_count += 1 - response = requests.post( - url, headers=get_vercel_headers(token), json=payload - ) + # Bulk create environment variables for this environment + if payload: + logger.info( + f"Syncing {len(payload)} variables to environment: {target_env}" + ) - logger.info(f"API response: {response.status_code}") - if response.status_code >= 400: - logger.error(f"Response body: {response.text}") + url = f"{VERCEL_API_BASE_URL}/v10/projects/{project_id}/env?upsert=true" + if team_id is not None: + url += f"&teamId={team_id}" - if response.status_code not in [200, 201]: - error_msg = f"Failed to sync secrets: {response.text}" - logger.error(error_msg) - return False, {"message": error_msg} + response = requests.post( + url, headers=get_vercel_headers(token), json=payload + ) + + if response.status_code not in [200, 201]: + all_success = False + error_msg = ( + f"Failed to sync secrets to {target_env}: {response.text}" + ) + logger.error(error_msg) + messages.append(error_msg) + else: + success_msg = f"Successfully synced to {target_env}: {len(payload)} total, {updated_count} updated, {deleted_count} deleted" + logger.info(success_msg) + messages.append(success_msg) else: - success_msg = f"Successfully synced {len(payload)} secrets to environment: {environment}" - logger.info(success_msg) - return True, {"message": success_msg} - else: - return True, {"message": "No secrets to sync"} + messages.append(f"No secrets to sync for environment: {target_env}") + + return all_success, {"message": "\n".join(messages)} except Exception as e: error_msg = f"Failed to sync secrets: {str(e)}" From 6acb5784b493777729c074d745e7f78eee168c05 Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 15 Oct 2025 16:53:55 +0530 Subject: [PATCH 13/13] fix: correct sync status logic in EnvSyncStatus component --- frontend/components/syncing/EnvSyncStatus.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/components/syncing/EnvSyncStatus.tsx b/frontend/components/syncing/EnvSyncStatus.tsx index 6bd4bc306..74529a651 100644 --- a/frontend/components/syncing/EnvSyncStatus.tsx +++ b/frontend/components/syncing/EnvSyncStatus.tsx @@ -17,16 +17,16 @@ export const EnvSyncStatus = (props: { const syncStatus = () => { if ( syncs.some( - (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.Failed + (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.InProgress ) ) - return ApiEnvironmentSyncStatusChoices.Failed + return ApiEnvironmentSyncStatusChoices.InProgress else if ( syncs.some( - (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.InProgress + (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.Failed ) ) - return ApiEnvironmentSyncStatusChoices.InProgress + return ApiEnvironmentSyncStatusChoices.Failed else return ApiEnvironmentSyncStatusChoices.Completed }