-
Notifications
You must be signed in to change notification settings - Fork 202
Switch to branch deployments in one-click deployments #2647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0f3aaba
d5436e1
2bb3f66
5aae015
f5b6f99
20ca407
2d3699c
6c22ff3
449ec78
ef1a4c9
7bc21f2
6e2724b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /** | ||
| * Parses environment variable parameter string. | ||
| * Supports both KEY and KEY=value formats. | ||
| * @param envParam - Comma-separated string of env vars (e.g., "KEY1,KEY2=value2") | ||
| * @returns Array of objects with key and value properties | ||
| */ | ||
| export function parseEnvParam(envParam: string | null): Array<{ key: string; value: string }> { | ||
| if (!envParam) return []; | ||
| return envParam.split(',').map((entry: string) => { | ||
| const trimmed = entry.trim(); | ||
| const eqIndex = trimmed.indexOf('='); | ||
| if (eqIndex === -1) { | ||
| return { key: trimmed, value: '' }; | ||
| } | ||
| return { | ||
| key: trimmed.substring(0, eqIndex), | ||
| value: trimmed.substring(eqIndex + 1) | ||
| }; | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,11 @@ export function getNestedRootDirectory(repository: string): string | null { | |
| return match ? match[1] : null; | ||
| } | ||
|
|
||
| export function getBranchFromUrl(repository: string): string | null { | ||
| const match = repository.match(/\/tree\/([^/?#]+)/); | ||
| return match ? decodeURIComponent(match[1]) : null; | ||
| } | ||
|
|
||
| export function getRepositoryInfo( | ||
| repository: string | ||
| ): { owner: string; name: string; url: string } | null { | ||
|
|
@@ -34,7 +39,6 @@ export async function getLatestTag(owner: string, name: string): Promise<string | |
|
|
||
| return null; | ||
| } catch (error) { | ||
| console.error('Failed to fetch tags from GitHub:', error); | ||
| return null; | ||
| } | ||
| } | ||
|
|
@@ -50,7 +54,117 @@ export async function getDefaultBranch(owner: string, name: string): Promise<str | |
| const repo = await repoResponse.json(); | ||
| return repo.default_branch || null; | ||
| } catch (error) { | ||
| console.error('Failed to fetch default branch from GitHub:', error); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function getBranches(owner: string, name: string): Promise<string[] | null> { | ||
| try { | ||
| const branches: string[] = []; | ||
| const perPage = 100; | ||
| let page = 1; | ||
|
|
||
| while (true) { | ||
| const response = await fetch( | ||
| `https://api.github.com/repos/${owner}/${name}/branches?per_page=${perPage}&page=${page}` | ||
| ); | ||
|
|
||
| if (!response.ok) { | ||
| return null; | ||
| } | ||
|
|
||
| const pageBranches = await response.json(); | ||
| if (!Array.isArray(pageBranches) || pageBranches.length === 0) { | ||
| break; | ||
| } | ||
|
|
||
| branches.push(...pageBranches.map((branch) => branch.name)); | ||
|
|
||
| if (pageBranches.length < perPage) { | ||
| break; | ||
| } | ||
|
|
||
| page += 1; | ||
| } | ||
|
|
||
| return Array.from(new Set(branches)); | ||
| } catch (error) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function validateBranch( | ||
| owner: string, | ||
| repo: string, | ||
| branch: string | ||
| ): Promise<boolean> { | ||
| try { | ||
| const response = await fetch( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are we making calls to the GitHub API directly from our console? We should avoid adding business logic on the client, also we have to account for future support of other VCS providers, which the BE already has foundations for. |
||
| `https://api.github.com/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}` | ||
| ); | ||
| return response.ok; | ||
| } catch (error) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| export interface LoadBranchesResult { | ||
| branches: string[]; | ||
| selectedBranch: string; | ||
| } | ||
|
|
||
| export interface LoadBranchesError { | ||
| type: 'repository_missing' | 'load_failed' | 'empty_branches'; | ||
| message: string; | ||
| } | ||
|
|
||
| /** | ||
| * Loads branches from a GitHub repository and selects the appropriate branch. | ||
| * Selection priority: branchParam (if valid) > defaultBranch > first branch. | ||
| */ | ||
| export async function loadAndSelectBranch( | ||
| owner: string, | ||
| name: string, | ||
| branchParam: string | null = null | ||
| ): Promise<LoadBranchesResult | LoadBranchesError> { | ||
| if (!owner || !name) { | ||
| return { | ||
| type: 'repository_missing', | ||
| message: 'Repository information is missing. Please check the repository URL.' | ||
| }; | ||
| } | ||
|
|
||
| try { | ||
| const [branchList, defaultBranch, isBranchValid] = await Promise.all([ | ||
| getBranches(owner, name), | ||
| getDefaultBranch(owner, name), | ||
| branchParam ? validateBranch(owner, name, branchParam) : Promise.resolve(false) | ||
| ]); | ||
|
|
||
| if (!branchList || branchList.length === 0) { | ||
| return { | ||
| type: 'empty_branches', | ||
| message: | ||
| 'Failed to load branches from repository. Please check the repository URL or try again.' | ||
| }; | ||
| } | ||
|
|
||
| const selectedBranch = | ||
| branchParam && isBranchValid | ||
| ? branchParam | ||
| : defaultBranch && branchList.includes(defaultBranch) | ||
| ? defaultBranch | ||
| : branchList[0]; | ||
|
|
||
| return { | ||
| branches: branchList, | ||
| selectedBranch | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| type: 'load_failed', | ||
| message: | ||
| 'Failed to load branches from repository. Please check the repository URL or try again.' | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,7 +17,7 @@ | |
| import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; | ||
| import { iconPath } from '$lib/stores/app'; | ||
| import type { PageData } from './$types'; | ||
| import { getLatestTag } from '$lib/helpers/github'; | ||
| import { loadAndSelectBranch } from '$lib/helpers/github'; | ||
| import { writable } from 'svelte/store'; | ||
| import Link from '$lib/elements/link.svelte'; | ||
|
|
@@ -42,8 +42,9 @@ | |
| let selectedScopes = $state<string[]>([]); | ||
| let rootDir = $state(data.repository?.rootDirectory); | ||
| let variables = $state<Array<{ key: string; value: string; secret: boolean }>>([]); | ||
| let latestTag = $state(null); | ||
| let branches = $state<string[]>([]); | ||
| let selectedBranch = $state<string>(''); | ||
| let loadingBranches = $state(false); | ||
| const specificationOptions = $derived( | ||
| data.specificationsList?.specifications?.map((size) => ({ | ||
|
|
@@ -63,8 +64,8 @@ | |
| })) || [] | ||
| ); | ||
| onMount(() => { | ||
| const runtimeParam = data.runtime || page.url.searchParams.get('runtime') || 'node-18.0'; | ||
| onMount(async () => { | ||
| const runtimeParam = data.runtime || page.url.searchParams.get('runtime') || Runtime.Node22; | ||
| runtime = runtimeParam as Runtime; | ||
| entrypoint = page.url.searchParams.get('entrypoint') || ''; | ||
|
|
@@ -76,23 +77,65 @@ | |
| specification = specificationOptions[0].value; | ||
| } | ||
| if (data.envKeys.length > 0) { | ||
| variables = data.envKeys.map((key) => ({ key, value: '', secret: false })); | ||
| // Initialize environment variables from query params (with prefilled values if provided) | ||
| if (data.envVars.length > 0) { | ||
| variables = data.envVars.map((env) => ({ | ||
| key: env.key, | ||
| value: env.value, | ||
| secret: false | ||
| })); | ||
| } | ||
| getLatestTag(data.repository.owner, data.repository.name).then( | ||
| (tagName) => (latestTag = tagName) | ||
| ); | ||
| // Load branches and set default branch | ||
| if (data.repository?.owner && data.repository?.name) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is coupling us very hard to GitHub, this logic has to be part of Utopia/VCS if its not already there, which is also very likely. |
||
| loadingBranches = true; | ||
| try { | ||
| const branchParam = page.url.searchParams.get('branch'); | ||
| const result = await loadAndSelectBranch( | ||
| data.repository.owner, | ||
| data.repository.name, | ||
| branchParam | ||
| ); | ||
| if ('branches' in result) { | ||
| branches = result.branches; | ||
| selectedBranch = result.selectedBranch; | ||
| } else { | ||
| addNotification({ | ||
| type: 'error', | ||
| message: result.message | ||
| }); | ||
| } | ||
| } catch (error) { | ||
| addNotification({ | ||
| type: 'error', | ||
| message: | ||
| 'Failed to load branches from repository. Please check the repository URL or try again.' | ||
| }); | ||
| } finally { | ||
| loadingBranches = false; | ||
| } | ||
| } else { | ||
| // Repository info is missing | ||
| addNotification({ | ||
| type: 'error', | ||
| message: 'Repository information is missing. Please check the repository URL.' | ||
| }); | ||
| } | ||
| }); | ||
| async function create() { | ||
| if (!selectedBranch || branches.length === 0) { | ||
| addNotification({ | ||
| type: 'error', | ||
| message: 'Please wait for branches to load or check the repository URL.' | ||
| }); | ||
| return; | ||
| } | ||
| $isSubmitting = true; | ||
| try { | ||
| if (!latestTag) { | ||
| latestTag = await getLatestTag(data.repository.owner, data.repository.name); | ||
| } | ||
| // Create function with configuration | ||
| const func = await sdk | ||
| .forProject(page.params.region, page.params.project) | ||
|
|
@@ -126,16 +169,16 @@ | |
| await Promise.all(promises); | ||
| // Create deployment from GitHub repository using the latest tag | ||
| // Create deployment from GitHub repository using the selected branch | ||
| await sdk | ||
| .forProject(page.params.region, page.params.project) | ||
| .functions.createTemplateDeployment({ | ||
| functionId: func.$id, | ||
| repository: data.repository.name, | ||
| owner: data.repository.owner, | ||
| rootDirectory: rootDir || '.', | ||
| type: Type.Tag, | ||
| reference: latestTag ?? '1.0.0', | ||
| type: Type.Branch, | ||
| reference: selectedBranch, | ||
| activate: true | ||
| }); | ||
|
|
@@ -220,6 +263,22 @@ | |
| </Layout.Stack> | ||
| </Fieldset> | ||
|
|
||
| <Fieldset legend="Git configuration"> | ||
| <Layout.Stack gap="m"> | ||
| <Input.Select | ||
| id="branch" | ||
| label="Branch" | ||
| required | ||
| placeholder={loadingBranches ? 'Loading branches...' : 'Select branch'} | ||
| bind:value={selectedBranch} | ||
| disabled={loadingBranches} | ||
| options={branches.map((branch) => ({ | ||
| value: branch, | ||
| label: branch | ||
| }))} /> | ||
| </Layout.Stack> | ||
| </Fieldset> | ||
|
|
||
| <Fieldset legend="Build configuration"> | ||
| <Layout.Stack gap="m"> | ||
| <Input.Text | ||
|
|
@@ -243,7 +302,7 @@ | |
| </Layout.Stack> | ||
| </Fieldset> | ||
|
|
||
| {#if data.envKeys.length > 0} | ||
| {#if data.envVars.length > 0} | ||
| <Fieldset legend="Environment variables"> | ||
| <Layout.Stack gap="m"> | ||
| {#each variables as variable, i} | ||
|
|
@@ -276,7 +335,12 @@ | |
| fullWidthMobile | ||
| submissionLoader | ||
| forceShowLoader={$isSubmitting} | ||
| disabled={!name || !runtime || !specification || $isSubmitting}> | ||
| disabled={!name || | ||
| !runtime || | ||
| !specification || | ||
| !selectedBranch || | ||
| branches.length === 0 || | ||
| $isSubmitting}> | ||
| Deploy function | ||
| </Button> | ||
| </Layout.Stack> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ItzNotABug Is this a standard practice in the console? Feels like a very specific case for having it as a global helper.