From 90d7338e837912bb0ee5d019efd562ae8b3192a3 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Fri, 27 Jun 2025 04:04:46 -0400 Subject: [PATCH] add GitHub app popover to ResourceEditor + refactor (#6079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add GitHub App functionality to ResourceEditor and extract reusable component - Extract GitHub App logic from ApiConnectForm into reusable GitHubAppIntegration component - Add GitHub App functionality to ResourceEditor for consistent experience across workflows - Create githubApp.ts service layer with comprehensive error handling and state management - Maintain all existing functionality while improving code reusability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Fix code formatting for GitHub App integration files Apply Prettier formatting to newly created and modified components to ensure consistent code style across the GitHub App integration implementation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * linter * Update frontend/src/lib/githubApp.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update frontend/src/lib/components/GitHubAppIntegration.svelte Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- .../src/lib/components/ApiConnectForm.svelte | 520 +----------------- .../components/GitHubAppIntegration.svelte | 440 +++++++++++++++ .../src/lib/components/ResourceEditor.svelte | 29 +- frontend/src/lib/githubApp.ts | 450 +++++++++++++++ 4 files changed, 929 insertions(+), 510 deletions(-) create mode 100644 frontend/src/lib/components/GitHubAppIntegration.svelte create mode 100644 frontend/src/lib/githubApp.ts diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 80e452bbf3..3d3f3cda25 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -1,29 +1,18 @@ {#if !notFound} @@ -420,287 +204,17 @@
Connect Supabase
{/if} - {#if resourceType == 'git_repository' && $workspaceStore && $userStore?.is_admin} - {#if !loadingGithubInstallations} - - {/snippet} - {#snippet content({ close })} -
-
- {#if workspaceGithubInstallations.length > 0} -
-

Select Repository

-
-
-

Github Account ID

- -
- {#if selectedGHAppAccountId} -
-

Repository

-
- -
-
- {/if} -
- -
-
-
- {/if} - -
0 - ? 'border-t border-gray-200 dark:border-gray-700' - : '' - } pt-4`} - > -
-
- -
- {#if workspaceGithubInstallations.length > 0} -
-

Current installations:

-
- - - - - - - - - - - {#each workspaceGithubInstallations as installation} - - - - - - - {/each} - -
OrgWorkspaceRepos
{installation.account_id} - {#if $workspaceColor} - - {installation.workspace_id} - - {:else} - {installation.workspace_id} - {/if} - - {installation.repositories.length} repos - -
- - -
-
-
-
- {/if} - {#if githubInstallationsNotInWorkspace.length > 0} -
-

Installations in other workspaces:

-
- - - - - - - - - - - {#each githubInstallationsNotInWorkspace as installation} - - - - - - - {/each} - -
OrgWorkspaceRepos
{installation.account_id} - {#if $userWorkspaces.find((w) => w.id === installation.workspace_id)?.color} - - {installation.workspace_id} - - {:else} - {installation.workspace_id} - {/if} - - {installation.repositories.length} repos - - -
-
-
- {/if} -
-
- -
-

Import installation from other instance:

-
- - -
-
-
-
- {/snippet} - - {:else} - - {/if} - {/if} + { + args = newArgs + rawCode = JSON.stringify(args, null, 2) + rawCodeEditor?.setCode(rawCode) + }} + onDescriptionUpdate={(newDescription) => (description = newDescription)} + /> {:else}

+ import { workspaceStore, enterpriseLicense, userStore } from '$lib/stores' + import Popover from './meltComponents/Popover.svelte' + import Button from './common/button/Button.svelte' + import { Loader2, Github, RotateCw, Plus, Minus, Download } from 'lucide-svelte' + import { onDestroy } from 'svelte' + import { + createGitHubAppState, + loadGithubInstallations, + startInstallationCheck, + stopInstallationCheck, + getRepositories, + addInstallationToWorkspace, + deleteInstallation, + exportInstallation, + importInstallation, + applyRepositoryURL, + handleInstallClick, + type GitHubAppState + } from '$lib/githubApp' + + interface Props { + resourceType: string + args?: Record + description?: string + onArgsUpdate?: (args: Record) => void + onDescriptionUpdate?: (description: string) => void + } + + let { + resourceType, + args = {}, + description = '', + onArgsUpdate, + onDescriptionUpdate + }: Props = $props() + + // GitHub App state using the service utilities + let githubState: GitHubAppState = $state(createGitHubAppState()) + let githubAppPopover: { open: () => void; close: () => void } | null = $state(null) + + // Filter and deduplicate installations not in current workspace + let githubInstallationsNotInWorkspace = $derived( + githubState.githubInstallations + .filter( + (installation) => + !githubState.workspaceGithubInstallations.some( + (workspaceInstallation) => + workspaceInstallation.installation_id === installation.installation_id + ) + ) + .filter( + (installation, index, array) => + array.findIndex((item) => item.installation_id === installation.installation_id) === index + ) + ) + + let showGitHubApp = $derived( + resourceType === 'git_repository' && $workspaceStore && $userStore?.is_admin + ) + + // Load GitHub installations when conditions are met + $effect(() => { + if (showGitHubApp && $enterpriseLicense && $workspaceStore) { + loadGithubInstallations(githubState, $workspaceStore).catch((error) => { + console.error('Failed to load GitHub installations:', error) + }) + } + }) + + // Clean up interval when component is destroyed + onDestroy(() => { + stopInstallationCheck(githubState) + }) + + // Extracted event handlers for better maintainability + function handleApplyRepositoryURL(close: (_: any) => void) { + try { + applyRepositoryURL( + githubState, + args, + description, + (newArgs) => { + if (onArgsUpdate) { + onArgsUpdate(newArgs) + } + }, + (newDescription) => { + if (onDescriptionUpdate) { + onDescriptionUpdate(newDescription) + } + } + ) + close(null) + } catch (error) { + console.error('Failed to apply repository URL:', error) + } + } + + async function handleDeleteInstallation(installationId: number) { + if (!$workspaceStore) return + + try { + await deleteInstallation($workspaceStore, installationId, () => + loadGithubInstallations(githubState, $workspaceStore!) + ) + } catch (error) { + console.error('Failed to delete installation:', error) + } + } + + async function handleAddInstallation(installationId: number, workspaceId: string) { + if (!$workspaceStore) return + + try { + await addInstallationToWorkspace($workspaceStore, installationId, workspaceId, () => + loadGithubInstallations(githubState, $workspaceStore!) + ) + } catch (error) { + console.error('Failed to add installation:', error) + } + } + + async function handleExportInstallation(installationId: number) { + if (!$workspaceStore) return + + try { + await exportInstallation($workspaceStore, installationId) + } catch (error) { + console.error('Failed to export installation:', error) + } + } + + async function handleImportInstallation() { + if (!$workspaceStore) return + + try { + await importInstallation($workspaceStore, githubState.importJwt, () => { + githubState.importJwt = '' + loadGithubInstallations(githubState, $workspaceStore!) + }) + } catch (error) { + console.error('Failed to import installation:', error) + } + } + + function handleRefreshInstallations() { + if (!$workspaceStore) return + + loadGithubInstallations(githubState, $workspaceStore).catch((error) => { + console.error('Failed to refresh installations:', error) + }) + } + + function handleInstallClickWithPopover() { + if (!$workspaceStore) return + + handleInstallClick(githubState, $workspaceStore, () => { + githubAppPopover?.open() + }) + } + + +{#if showGitHubApp} + {#if !githubState.loadingGithubInstallations} + + {/snippet} + {#snippet content({ close })} +

+
+ {#if githubState.workspaceGithubInstallations.length > 0} +
+

Select Repository

+
+
+

GitHub Account ID

+ +
+ {#if githubState.selectedGHAppAccountId} +
+

Repository

+
+ +
+
+ {/if} +
+ +
+
+
+ {/if} + +
0 + ? 'border-t border-gray-200 dark:border-gray-700' + : '' + } pt-4`} + > +
+
+ +
+ {#if githubState.workspaceGithubInstallations.length > 0} +
+

Current installations:

+
+ + + + + + + + + + + {#each githubState.workspaceGithubInstallations as installation (`current-${installation.installation_id}-${installation.workspace_id}`)} + + + + + + + {/each} + +
OrgWorkspaceRepos
{installation.account_id} + {installation.workspace_id} + + {installation.repositories.length} repos + +
+ + +
+
+
+
+ {/if} + {#if githubInstallationsNotInWorkspace.length > 0} +
+

Installations in other workspaces:

+
+ + + + + + + + + + + {#each githubInstallationsNotInWorkspace as installation (`other-${installation.installation_id}-${installation.workspace_id}`)} + + + + + + + {/each} + +
OrgWorkspaceRepos
{installation.account_id} + {installation.workspace_id} + + {installation.repositories.length} repos + + +
+
+
+ {/if} +
+
+ +
+

Import installation from other instance:

+
+ + +
+
+
+
+ {/snippet} + + {:else} + + {/if} +{/if} diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index f2bc9c7ab9..7b71759eb8 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -21,6 +21,7 @@ import GfmMarkdown from './GfmMarkdown.svelte' import TestTriggerConnection from './triggers/TestTriggerConnection.svelte' import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted' + import GitHubAppIntegration from './GitHubAppIntegration.svelte' interface Props { canSave?: boolean @@ -247,19 +248,33 @@ {/if} -
-
- {#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'} - - {:else} - - {/if} +
switchTab(e.detail)} options={{ right: 'As JSON' }} /> + {#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'} + + {:else} + + {/if} + {#if resource_type === 'git_repository' && $workspaceStore && $userStore?.is_admin} + { + args = newArgs + // Update rawCode if in JSON view mode + if (viewJsonSchema) { + rawCode = JSON.stringify(args, null, 2) + } + }} + onDescriptionUpdate={(newDescription) => (description = newDescription)} + /> + {/if}
{#if loadingSchema} diff --git a/frontend/src/lib/githubApp.ts b/frontend/src/lib/githubApp.ts new file mode 100644 index 0000000000..74ce0438ed --- /dev/null +++ b/frontend/src/lib/githubApp.ts @@ -0,0 +1,450 @@ +import { GitSyncService, type GetGlobalConnectedRepositoriesResponse } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { base } from '$lib/base' + +export interface GitHubAppState { + loadingGithubInstallations: boolean + githubInstallations: GetGlobalConnectedRepositoriesResponse + workspaceGithubInstallations: GetGlobalConnectedRepositoriesResponse + selectedGHAppAccountId: string | undefined + selectedGHAppRepository: string | undefined + githubInstallationUrl: string | undefined + installationCheckInterval: number | undefined + isCheckingInstallation: boolean + importJwt: string +} + +export interface GitHubRepository { + name: string + url: string +} + +export interface GitHubAppError extends Error { + code: 'VALIDATION_ERROR' | 'NETWORK_ERROR' | 'AUTH_ERROR' | 'UNKNOWN_ERROR' + details?: unknown +} + +/** + * Creates a standardized GitHub App error + */ +function createGitHubAppError( + message: string, + code: GitHubAppError['code'], + details?: unknown +): GitHubAppError { + const error = new Error(message) as GitHubAppError + error.code = code + error.details = details + return error +} + +/** + * Validates JWT token format + */ +function validateJwtToken(token: string): boolean { + if (!token || typeof token !== 'string') return false + + // Basic JWT structure validation (header.payload.signature) + const parts = token.trim().split('.') + if (parts.length !== 3) return false + + // Check if each part is valid base64 + try { + parts.forEach((part) => { + if (!part) throw new Error('Empty JWT part') + // Add padding if needed for base64 decoding + const padded = part + '='.repeat((4 - (part.length % 4)) % 4) + atob(padded.replace(/-/g, '+').replace(/_/g, '/')) + }) + return true + } catch { + return false + } +} + +/** + * Handles errors consistently across GitHub App operations + */ +function handleGitHubAppError(error: unknown, operation: string): GitHubAppError { + console.error(`GitHub App ${operation} failed:`, error) + + // Check if it's already a GitHubAppError by checking for the code property + if (error && typeof error === 'object' && 'code' in error && 'message' in error) { + return error as GitHubAppError + } + + if (error instanceof Error) { + if (error.message.includes('401') || error.message.includes('403')) { + return createGitHubAppError(`Authentication failed during ${operation}`, 'AUTH_ERROR', error) + } + if (error.message.includes('network') || error.message.includes('fetch')) { + return createGitHubAppError(`Network error during ${operation}`, 'NETWORK_ERROR', error) + } + } + + return createGitHubAppError(`Unknown error during ${operation}`, 'UNKNOWN_ERROR', error) +} + +export function createGitHubAppState(): GitHubAppState { + return { + loadingGithubInstallations: false, + githubInstallations: [], + workspaceGithubInstallations: [], + selectedGHAppAccountId: undefined, + selectedGHAppRepository: undefined, + githubInstallationUrl: undefined, + installationCheckInterval: undefined, + isCheckingInstallation: false, + importJwt: '' + } +} + +/** + * Loads GitHub installations for the current workspace + */ +export async function loadGithubInstallations( + state: GitHubAppState, + currentWorkspace: string +): Promise { + if (!currentWorkspace) { + throw createGitHubAppError('Workspace is required', 'VALIDATION_ERROR') + } + + try { + state.loadingGithubInstallations = true + + const installations = await GitSyncService.getGlobalConnectedRepositories() + const workspaceInstallations = installations.filter( + (installation) => installation.workspace_id === currentWorkspace + ) + + // Update state in a way that ensures Svelte 5 reactivity + state.githubInstallations = [...installations] + state.workspaceGithubInstallations = [...workspaceInstallations] + + const stateParam = encodeURIComponent( + JSON.stringify({ + workspace_id: currentWorkspace, + base_url: window.location.origin + base + }) + ) + + state.githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${stateParam}` + } catch (err) { + const githubError = handleGitHubAppError(err, 'load installations') + sendUserToast(`Failed to load GitHub installations: ${githubError.message}`, true) + + // Reset state on error + state.githubInstallations = [] + state.workspaceGithubInstallations = [] + + throw githubError + } finally { + state.loadingGithubInstallations = false + } +} + +/** + * Starts polling for new GitHub installations + */ +export function startInstallationCheck( + state: GitHubAppState, + currentWorkspace: string, + onInstallationFound?: () => void +): void { + if (!currentWorkspace) { + throw createGitHubAppError('Workspace is required', 'VALIDATION_ERROR') + } + + // Stop any existing check first + stopInstallationCheck(state) + + // Remember initial count to detect new installations + const initialInstallationCount = state.githubInstallations.length + + state.isCheckingInstallation = true + let pollCount = 0 + const maxPolls = 150 // 5 minutes (150 * 2 seconds) + + state.installationCheckInterval = window.setInterval(async () => { + pollCount++ + + // Stop polling after timeout + if (pollCount >= maxPolls) { + stopInstallationCheck(state) + return + } + + try { + const installations = await GitSyncService.getGlobalConnectedRepositories() + // Check if we have MORE installations than when we started + if (installations.length > initialInstallationCount) { + stopInstallationCheck(state) + state.githubInstallations = [...installations] + state.workspaceGithubInstallations = [ + ...installations.filter((installation) => installation.workspace_id === currentWorkspace) + ] + // Call callback with delay to allow popover to open + if (onInstallationFound) { + setTimeout(onInstallationFound, 100) + } + } + } catch (error) { + const githubError = handleGitHubAppError(error, 'check installations') + console.error('Installation check failed:', githubError) + // Continue polling despite errors + } + }, 2000) +} + +export function stopInstallationCheck(state: GitHubAppState): void { + if (state.installationCheckInterval) { + clearInterval(state.installationCheckInterval) + state.installationCheckInterval = undefined + } + state.isCheckingInstallation = false +} + +/** + * Gets repositories for a specific GitHub account + */ +export function getRepositories(state: GitHubAppState, accountId: string): GitHubRepository[] { + if (!accountId) return [] + + return ( + state.githubInstallations.find((installation) => installation.account_id === accountId) + ?.repositories || [] + ) +} + +/** + * Adds a GitHub installation to the current workspace + */ +export async function addInstallationToWorkspace( + currentWorkspace: string, + installationId: number, + sourceWorkspaceId: string, + onSuccess?: () => void +): Promise { + // Input validation + if (!currentWorkspace) { + throw createGitHubAppError('Current workspace is required', 'VALIDATION_ERROR') + } + if (!installationId || installationId <= 0) { + throw createGitHubAppError('Valid installation ID is required', 'VALIDATION_ERROR') + } + if (!sourceWorkspaceId) { + throw createGitHubAppError('Source workspace ID is required', 'VALIDATION_ERROR') + } + + try { + await GitSyncService.installFromWorkspace({ + workspace: currentWorkspace, + requestBody: { + source_workspace_id: sourceWorkspaceId, + installation_id: installationId + } + }) + sendUserToast('Successfully added installation to workspace', false) + onSuccess?.() + } catch (err) { + const githubError = handleGitHubAppError(err, 'add installation to workspace') + sendUserToast(`Failed to add installation: ${githubError.message}`, true) + throw githubError + } +} + +/** + * Deletes a GitHub installation from the current workspace + */ +export async function deleteInstallation( + currentWorkspace: string, + installationId: number, + onSuccess?: () => void +): Promise { + // Input validation + if (!currentWorkspace) { + throw createGitHubAppError('Workspace is required', 'VALIDATION_ERROR') + } + if (!installationId || installationId <= 0) { + throw createGitHubAppError('Valid installation ID is required', 'VALIDATION_ERROR') + } + + try { + await GitSyncService.deleteFromWorkspace({ + workspace: currentWorkspace, + installationId: installationId + }) + sendUserToast('Successfully deleted installation', false) + onSuccess?.() + } catch (err) { + const githubError = handleGitHubAppError(err, 'delete installation') + sendUserToast(`Failed to delete installation: ${githubError.message}`, true) + throw githubError + } +} + +/** + * Exports a GitHub installation as a JWT token + */ +export async function exportInstallation( + currentWorkspace: string, + installationId: number +): Promise { + // Input validation + if (!currentWorkspace) { + throw createGitHubAppError('Workspace is required', 'VALIDATION_ERROR') + } + if (!installationId || installationId <= 0) { + throw createGitHubAppError('Valid installation ID is required', 'VALIDATION_ERROR') + } + + try { + const response = await GitSyncService.exportInstallation({ + workspace: currentWorkspace, + installationId: installationId + }) + + if (!response.jwt_token) { + throw createGitHubAppError('No JWT token received from server', 'UNKNOWN_ERROR') + } + + const jwtToken = response.jwt_token + + // Copy to clipboard with fallback for unsecure contexts + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(jwtToken) + sendUserToast( + 'JWT token copied to clipboard. This token is sensitive and should be kept secret!', + false, + undefined, + undefined, + 10000 + ) + } else { + // Fallback: show the token in the toast for manual copying + sendUserToast( + `JWT token (copy manually): ${jwtToken}`, + false, + [ + { + label: 'Copy', + callback: () => { + // Try to copy using the older execCommand method as fallback + const textArea = document.createElement('textarea') + textArea.value = jwtToken + document.body.appendChild(textArea) + textArea.select() + try { + document.execCommand('copy') + sendUserToast('JWT token copied to clipboard!', false) + } catch (err) { + console.error('Failed to copy to clipboard:', err) + sendUserToast('Could not copy to clipboard. Please copy manually.', true) + } + document.body.removeChild(textArea) + } + } + ], + undefined, + 15000 + ) + } + } catch (err) { + const githubError = handleGitHubAppError(err, 'export installation') + sendUserToast(`Failed to export installation: ${githubError.message}`, true) + throw githubError + } +} + +/** + * Imports a GitHub installation using a JWT token + */ +export async function importInstallation( + currentWorkspace: string, + jwt: string, + onSuccess?: () => void +): Promise { + // Input validation + if (!currentWorkspace) { + throw createGitHubAppError('Workspace is required', 'VALIDATION_ERROR') + } + if (!jwt || !validateJwtToken(jwt)) { + throw createGitHubAppError('Valid JWT token is required', 'VALIDATION_ERROR') + } + + try { + await GitSyncService.importInstallation({ + workspace: currentWorkspace, + requestBody: { jwt_token: jwt.trim() } + }) + sendUserToast('Installation imported successfully', false) + onSuccess?.() + } catch (err) { + const githubError = handleGitHubAppError(err, 'import installation') + sendUserToast(`Failed to import installation: ${githubError.message}`, true) + throw githubError + } +} + +/** + * Applies the selected repository URL to the form arguments + */ +export function applyRepositoryURL( + state: GitHubAppState, + args: Record, + description: string, + onArgsUpdate: (newArgs: Record) => void, + onDescriptionUpdate: (newDescription: string) => void +): void { + if (!state.selectedGHAppRepository) { + throw createGitHubAppError('No repository selected', 'VALIDATION_ERROR') + } + + // Validate args object + if (!args || typeof args !== 'object') { + throw createGitHubAppError('Invalid arguments object', 'VALIDATION_ERROR') + } + + const newArgs = { + ...args, + url: state.selectedGHAppRepository, + is_github_app: true + } + + // Check if description already contains GitHub App text to avoid duplication + const githubAppText = `Repository ${state.selectedGHAppRepository} with permissions fetched using Windmill Github App.` + const existingDescription = description ?? '' + + const newDescription = existingDescription.includes( + 'with permissions fetched using Windmill Github App' + ) + ? existingDescription.replace( + /Repository [^ ]+ with permissions fetched using Windmill Github App\. ?/, + githubAppText + ' ' + ) + : `${githubAppText} ${existingDescription}`.trim() + + try { + onArgsUpdate(newArgs) + onDescriptionUpdate(newDescription) + } catch (err) { + const githubError = handleGitHubAppError(err, 'apply repository URL') + throw githubError + } +} + +/** + * Handles the install button click + */ +export function handleInstallClick( + state: GitHubAppState, + currentWorkspace: string, + onInstallationFound?: () => void +): void { + if (state.githubInstallations.length === 0) { + if (!state.isCheckingInstallation) { + startInstallationCheck(state, currentWorkspace, onInstallationFound) + } + } +}