Skip to content
Open
20 changes: 20 additions & 0 deletions src/lib/helpers/env.ts
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 }> {
Copy link
Member

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.

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)
};
});
}
118 changes: 116 additions & 2 deletions src/lib/helpers/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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(
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Up @@ -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';
Expand All @@ -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) => ({
Expand All @@ -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') || '';
Expand All @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The 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)
Expand Down Expand Up @@ -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
});
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -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>
Expand Down
Loading