add GitHub app popover to ResourceEditor + refactor (#6079)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2025-06-27 04:04:46 -04:00
committed by GitHub
parent aaf6e9d693
commit 90d7338e83
4 changed files with 929 additions and 510 deletions
+17 -503
View File
@@ -1,29 +1,18 @@
<script lang="ts">
import {
OauthService,
GitSyncService,
type ResourceType,
type GetGlobalConnectedRepositoriesResponse
} from '$lib/gen'
import {
workspaceStore,
enterpriseLicense,
userWorkspaces,
userStore,
workspaceColor
} from '$lib/stores'
import { base } from '$lib/base'
import { OauthService, type ResourceType } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { emptySchema, emptyString } from '$lib/utils'
import SchemaForm from './SchemaForm.svelte'
import type SimpleEditor from './SimpleEditor.svelte'
import Toggle from './Toggle.svelte'
import TestConnection from './TestConnection.svelte'
import SupabaseIcon from './icons/SupabaseIcon.svelte'
import { sendUserToast } from '$lib/toast'
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, untrack } from 'svelte'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import { base } from '$lib/base'
import GitHubAppIntegration from './GitHubAppIntegration.svelte'
interface Props {
resourceType: string
@@ -50,109 +39,6 @@
let supabaseWizard = $state(false)
let loadingGithubInstallations = $state(false)
let githubInstallations: GetGlobalConnectedRepositoriesResponse = $state([])
let workspaceGithubInstallations: GetGlobalConnectedRepositoriesResponse = $state([])
let selectedGHAppAccountId: string | undefined = $state(undefined)
let selectedGHAppRepository: string | undefined = $state(undefined)
let githubInstallationUrl: string | undefined = $state(undefined)
let installationCheckInterval: number | undefined = undefined
let isCheckingInstallation = $state(false)
let importJwt = $state('')
let githubAppPopover: { open: () => void; close: () => void } | null = $state(null)
async function loadGithubInstallations() {
if (!$enterpriseLicense) return
try {
loadingGithubInstallations = true
// Reset for reactivity
githubInstallations = []
workspaceGithubInstallations = []
const installations = await GitSyncService.getGlobalConnectedRepositories()
githubInstallations = installations
workspaceGithubInstallations = githubInstallations.filter(
(_) => _.workspace_id === $workspaceStore
)
const state = encodeURIComponent(
JSON.stringify({
workspace_id: $workspaceStore,
base_url: window.location.origin + base
})
)
githubInstallationUrl = `https://github.com/apps/windmill-sync-helper/installations/new?state=${state}`
} catch (err) {
console.error(err)
sendUserToast('Failed to load GitHub installations', true)
githubInstallations = []
workspaceGithubInstallations = []
} finally {
loadingGithubInstallations = false
}
}
function startInstallationCheck() {
isCheckingInstallation = true
installationCheckInterval = window.setInterval(async () => {
const installations = await GitSyncService.getGlobalConnectedRepositories()
if (installations.length > 0) {
stopInstallationCheck()
githubInstallations = installations
workspaceGithubInstallations = githubInstallations.filter(
(_) => _.workspace_id === $workspaceStore
)
// Open the popover with a small delay as otherwise it doesn't open
setTimeout(() => {
githubAppPopover?.open()
}, 100)
}
}, 2000)
}
function stopInstallationCheck() {
if (installationCheckInterval) {
clearInterval(installationCheckInterval)
installationCheckInterval = undefined
}
isCheckingInstallation = false
}
// Clean up interval when component is destroyed
onDestroy(() => {
stopInstallationCheck()
})
function getRepositories(accountId: string) {
return githubInstallations.find((_) => _.account_id === accountId)?.repositories || []
}
async function addInstallationToWorkspace(
installation_id: number | undefined,
workspaceId: string | undefined
) {
if (!installation_id || !workspaceId || !$workspaceStore) {
sendUserToast('Installation or workspace invalid', true)
return
}
try {
await GitSyncService.installFromWorkspace({
workspace: $workspaceStore,
requestBody: {
source_workspace_id: workspaceId,
installation_id: installation_id
}
})
sendUserToast('Successfully added installation to workspace', false)
await loadGithubInstallations()
} catch (err) {
console.error(err)
sendUserToast('Failed to add installation to workspace', true)
}
}
async function isSupabaseAvailable() {
try {
supabaseWizard =
@@ -227,22 +113,6 @@
}
}
function applyRepositoryURL(close: (_: any) => void) {
if (!selectedGHAppRepository) return
rawCode = JSON.stringify(
{
...args,
url: selectedGHAppRepository,
is_github_app: true
},
null,
2
)
description = `Repository ${selectedGHAppRepository} with permissions fetched using Windmill Github App. ${description ?? ''}`
rawCodeEditor?.setCode(rawCode)
close(null)
}
let rawCodeEditor: SimpleEditor | undefined = $state(undefined)
let textFileContent: string | undefined = $state(undefined)
@@ -251,79 +121,6 @@
content: textFileContent
}
}
async function deleteInstallation(installation_id: number) {
if (!$workspaceStore) {
sendUserToast('Failed to delete installation', true)
return
}
try {
await GitSyncService.deleteFromWorkspace({
workspace: $workspaceStore,
installationId: installation_id
})
sendUserToast('Successfully deleted installation', false)
await loadGithubInstallations()
} catch (err) {
console.error(err)
sendUserToast('Failed to delete installation', true)
}
}
async function exportInstallation(installationId: number) {
if (!$workspaceStore) {
sendUserToast('Failed to export installation', true)
return
}
try {
const response = await GitSyncService.exportInstallation({
workspace: $workspaceStore,
installationId: installationId
})
if (!response.jwt_token) {
sendUserToast('Failed to export installation', true)
return
}
// Copy to clipboard
await navigator.clipboard.writeText(response.jwt_token)
sendUserToast(
'JWT token copied to clipboard. This token is sensitive and should be kept secret!',
false,
undefined,
undefined,
10000
)
} catch (error) {
console.error(error)
sendUserToast('Failed to export installation', true)
}
}
async function importInstallation(jwt: string) {
if (!$workspaceStore) {
sendUserToast('Failed to import installation', true)
return
}
try {
await GitSyncService.importInstallation({
workspace: $workspaceStore,
requestBody: { jwt_token: jwt }
})
importJwt = ''
sendUserToast('Installation imported successfully', false)
await loadGithubInstallations()
} catch (error) {
sendUserToast('Failed to import installation', true)
}
}
function handleInstallClick() {
if (githubInstallations.length === 0) {
if (!isCheckingInstallation) {
startInstallationCheck()
}
}
}
$effect(() => {
$workspaceStore && untrack(() => loadSchema())
})
@@ -339,19 +136,6 @@
$effect(() => {
resourceType == 'postgresql' && untrack(() => isSupabaseAvailable())
})
$effect(() => {
resourceType == 'git_repository' &&
$userStore?.is_admin &&
untrack(() => loadGithubInstallations())
})
let githubInstallationsNotInWorkspace = $derived(
githubInstallations.filter((installation) => {
return !workspaceGithubInstallations.some(
(workspaceInstallation) =>
workspaceInstallation.installation_id === installation.installation_id
)
})
)
</script>
{#if !notFound}
@@ -420,287 +204,17 @@
<div class="text-[#11181C] dark:text-[#EDEDED] font-semibold">Connect Supabase</div>
</a>
{/if}
{#if resourceType == 'git_repository' && $workspaceStore && $userStore?.is_admin}
{#if !loadingGithubInstallations}
<Button
color="light"
variant="contained"
size="xs"
on:click={loadGithubInstallations}
disabled={!$enterpriseLicense}
startIcon={{ icon: RotateCw }}
/>
{:else}
<Loader2 class="animate-spin w-10 h-4" />
{/if}
{#if githubInstallations.length > 0}
<Popover
documentationLink="https://www.windmill.dev/docs/integrations/git_repository#github-app"
bind:this={githubAppPopover}
floatingConfig={{
placement: 'bottom'
}}
disabled={!$enterpriseLicense || loadingGithubInstallations}
>
{#snippet trigger()}
<Button
color="none"
variant="border"
size="xs"
disabled={!$enterpriseLicense || loadingGithubInstallations}
startIcon={{
icon: loadingGithubInstallations ? Loader2 : Github,
classes: loadingGithubInstallations ? 'animate-spin' : ''
}}
nonCaptureEvent
>
{$enterpriseLicense ? 'GitHub App' : 'GitHub App (ee only)'}
</Button>
{/snippet}
{#snippet content({ close })}
<div class="block text-primary p-4">
<div class="flex flex-col gap-4 w-[600px]">
{#if workspaceGithubInstallations.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary">Select Repository</p>
<div class="flex flex-row gap-2 w-full">
<div class="flex flex-col gap-1 flex-1">
<p class="text-sm font-semibold text-secondary">Github Account ID</p>
<select bind:value={selectedGHAppAccountId}>
<option value="" disabled>Select GitHub Account ID</option>
{#each workspaceGithubInstallations as installation}
<option value={installation.account_id}
>{installation.account_id}</option
>
{/each}
</select>
</div>
{#if selectedGHAppAccountId}
<div class="flex flex-col gap-1 flex-1">
<p class="text-sm font-semibold text-secondary">Repository</p>
<div class="flex flex-row gap-2">
<select bind:value={selectedGHAppRepository}>
<option value="" disabled selected>Select Repository</option>
{#each getRepositories(selectedGHAppAccountId) as repository}
<option value={repository.url}>{repository.name}</option>
{/each}
</select>
</div>
</div>
{/if}
<div class="pt-[26px]">
<Button
size="xs"
color="blue"
buttonType="button"
disabled={!selectedGHAppRepository}
on:click={() => {
applyRepositoryURL(close)
}}
>
Apply
</Button>
</div>
</div>
</div>
{/if}
<div
class={`${
workspaceGithubInstallations.length > 0
? 'border-t border-gray-200 dark:border-gray-700'
: ''
} pt-4`}
>
<div class="flex flex-col gap-4">
<div class="flex">
<Button
color="none"
variant="border"
size="xs"
href={githubInstallationUrl}
startIcon={{ icon: Plus }}
target="_blank"
>
Add new installation
</Button>
</div>
{#if workspaceGithubInstallations.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary">Current installations:</p>
<div class="flex flex-col gap-1">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-tertiary">
<th class="pb-2 w-1/3">Org</th>
<th class="pb-2 w-1/6">Workspace</th>
<th class="pb-2 w-1/6">Repos</th>
<th class="pb-2 w-1/3"></th>
</tr>
</thead>
<tbody>
{#each workspaceGithubInstallations as installation}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">{installation.account_id}</td>
<td class="py-2">
{#if $workspaceColor}
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs"
style="background-color: {$workspaceColor}20; color: {$workspaceColor}"
>
{installation.workspace_id}
</span>
{:else}
<span class="text-xs text-tertiary"
>{installation.workspace_id}</span
>
{/if}
</td>
<td class="py-2 text-tertiary">
{installation.repositories.length} repos
</td>
<td class="py-2 text-right">
<div class="flex justify-end gap-1">
<Button
size="xs2"
color="blue"
title="Export installation to other instance"
startIcon={{ icon: Download }}
on:click={() =>
exportInstallation(installation.installation_id)}
>
Export
</Button>
<Button
size="xs2"
color="red"
title="Remove installation from workspace"
startIcon={{ icon: Minus }}
on:click={() =>
deleteInstallation(installation.installation_id)}
>
Remove
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if githubInstallationsNotInWorkspace.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary"
>Installations in other workspaces:</p
>
<div class="flex flex-col gap-1">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-tertiary">
<th class="pb-2 w-1/3">Org</th>
<th class="pb-2 w-1/6">Workspace</th>
<th class="pb-2 w-1/6">Repos</th>
<th class="pb-2 w-1/3"></th>
</tr>
</thead>
<tbody>
{#each githubInstallationsNotInWorkspace as installation}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">{installation.account_id}</td>
<td class="py-2">
{#if $userWorkspaces.find((w) => w.id === installation.workspace_id)?.color}
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs"
style="background-color: {$userWorkspaces.find(
(w) => w.id === installation.workspace_id
)?.color}20; color: {$userWorkspaces.find(
(w) => w.id === installation.workspace_id
)?.color}"
>
{installation.workspace_id}
</span>
{:else}
<span class="text-xs text-tertiary"
>{installation.workspace_id}</span
>
{/if}
</td>
<td class="py-2 text-tertiary">
{installation.repositories.length} repos
</td>
<td class="pl-8 py-2 text-right">
<Button
size="xs2"
color="blue"
title="Add installation to workspace"
startIcon={{ icon: Plus }}
on:click={() =>
addInstallationToWorkspace(
installation.installation_id,
installation.workspace_id
)}
>
Add to workspace
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
</div>
<div class="mt-4 flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary"
>Import installation from other instance:</p
>
<div class="flex gap-2">
<input
type="text"
placeholder="Paste JWT token here"
bind:value={importJwt}
class="flex-1"
/>
<Button
color="blue"
on:click={() => importInstallation(importJwt)}
disabled={!importJwt}
>
Import
</Button>
</div>
</div>
</div>
</div>
{/snippet}
</Popover>
{:else}
<Button
color="none"
variant="border"
size="xs"
disabled={!$enterpriseLicense || loadingGithubInstallations}
startIcon={{
icon: loadingGithubInstallations || isCheckingInstallation ? Loader2 : Github,
classes: loadingGithubInstallations || isCheckingInstallation ? 'animate-spin' : ''
}}
href={githubInstallationUrl}
target="_blank"
on:click={handleInstallClick}
>
{$enterpriseLicense
? isCheckingInstallation
? 'Waiting for installation...'
: 'Install GitHub App'
: 'GitHub App (ee only)'}
</Button>
{/if}
{/if}
<GitHubAppIntegration
{resourceType}
{args}
{description}
onArgsUpdate={(newArgs) => {
args = newArgs
rawCode = JSON.stringify(args, null, 2)
rawCodeEditor?.setCode(rawCode)
}}
onDescriptionUpdate={(newDescription) => (description = newDescription)}
/>
</div>
{:else}
<p class="italic text-tertiary text-xs mb-4"
@@ -0,0 +1,440 @@
<script lang="ts">
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<string, any>
description?: string
onArgsUpdate?: (args: Record<string, any>) => 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()
})
}
</script>
{#if showGitHubApp}
{#if !githubState.loadingGithubInstallations}
<Button
color="light"
variant="contained"
size="xs"
on:click={handleRefreshInstallations}
disabled={!$enterpriseLicense}
startIcon={{ icon: RotateCw }}
/>
{:else}
<Loader2 class="animate-spin w-10 h-4" />
{/if}
{#if showGitHubApp}
<Popover
documentationLink="https://www.windmill.dev/docs/integrations/git_repository#github-app"
bind:this={githubAppPopover}
floatingConfig={{
placement: 'bottom'
}}
disabled={!$enterpriseLicense || githubState.loadingGithubInstallations}
>
{#snippet trigger()}
<Button
color="none"
variant="border"
size="xs"
disabled={!$enterpriseLicense || githubState.loadingGithubInstallations}
startIcon={{
icon: githubState.loadingGithubInstallations ? Loader2 : Github,
classes: githubState.loadingGithubInstallations ? 'animate-spin' : ''
}}
nonCaptureEvent
>
{$enterpriseLicense ? 'GitHub App' : 'GitHub App (ee only)'}
</Button>
{/snippet}
{#snippet content({ close })}
<div class="block text-primary p-4">
<div class="flex flex-col gap-4 w-[600px]">
{#if githubState.workspaceGithubInstallations.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary">Select Repository</p>
<div class="flex flex-row gap-2 w-full">
<div class="flex flex-col gap-1 flex-1">
<p class="text-sm font-semibold text-secondary">GitHub Account ID</p>
<select bind:value={githubState.selectedGHAppAccountId}>
<option value="" disabled>Select GitHub Account ID</option>
{#each githubState.workspaceGithubInstallations as installation (`select-${installation.installation_id}-${installation.workspace_id}`)}
<option value={installation.account_id}>{installation.account_id}</option>
{/each}
</select>
</div>
{#if githubState.selectedGHAppAccountId}
<div class="flex flex-col gap-1 flex-1">
<p class="text-sm font-semibold text-secondary">Repository</p>
<div class="flex flex-row gap-2">
<select bind:value={githubState.selectedGHAppRepository}>
<option value="" disabled selected>Select Repository</option>
{#each getRepositories(githubState, githubState.selectedGHAppAccountId) as repository (repository.url)}
<option value={repository.url}>{repository.name}</option>
{/each}
</select>
</div>
</div>
{/if}
<div class="pt-[26px]">
<Button
size="xs"
color="blue"
buttonType="button"
disabled={!githubState.selectedGHAppRepository}
on:click={() => handleApplyRepositoryURL(close)}
>
Apply
</Button>
</div>
</div>
</div>
{/if}
<div
class={`${
githubState.workspaceGithubInstallations.length > 0
? 'border-t border-gray-200 dark:border-gray-700'
: ''
} pt-4`}
>
<div class="flex flex-col gap-4">
<div class="flex">
<Button
color="none"
variant="border"
size="xs"
href={githubState.githubInstallationUrl}
startIcon={{
icon: githubState.isCheckingInstallation ? Loader2 : Plus,
classes: githubState.isCheckingInstallation ? 'animate-spin' : ''
}}
target="_blank"
disabled={githubState.isCheckingInstallation}
on:click={() => {
if ($workspaceStore) {
startInstallationCheck(githubState, $workspaceStore, () =>
loadGithubInstallations(githubState, $workspaceStore!)
)
}
}}
>
{githubState.isCheckingInstallation
? 'Checking for new installations...'
: 'Add new installation'}
</Button>
</div>
{#if githubState.workspaceGithubInstallations.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary">Current installations:</p>
<div class="flex flex-col gap-1">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-tertiary">
<th class="pb-2 w-1/3">Org</th>
<th class="pb-2 w-1/6">Workspace</th>
<th class="pb-2 w-1/6">Repos</th>
<th class="pb-2 w-1/3"></th>
</tr>
</thead>
<tbody>
{#each githubState.workspaceGithubInstallations as installation (`current-${installation.installation_id}-${installation.workspace_id}`)}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">{installation.account_id}</td>
<td class="py-2">
<span class="text-xs text-tertiary"
>{installation.workspace_id}</span
>
</td>
<td class="py-2 text-tertiary">
{installation.repositories.length} repos
</td>
<td class="py-2 text-right">
<div class="flex justify-end gap-1">
<Button
size="xs2"
color="blue"
title="Export installation to other instance"
startIcon={{ icon: Download }}
on:click={() =>
handleExportInstallation(installation.installation_id)}
>
Export
</Button>
<Button
size="xs2"
color="red"
title="Remove installation from workspace"
startIcon={{ icon: Minus }}
on:click={() =>
handleDeleteInstallation(installation.installation_id)}
>
Remove
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{#if githubInstallationsNotInWorkspace.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary"
>Installations in other workspaces:</p
>
<div class="flex flex-col gap-1">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-tertiary">
<th class="pb-2 w-1/3">Org</th>
<th class="pb-2 w-1/6">Workspace</th>
<th class="pb-2 w-1/6">Repos</th>
<th class="pb-2 w-1/3"></th>
</tr>
</thead>
<tbody>
{#each githubInstallationsNotInWorkspace as installation (`other-${installation.installation_id}-${installation.workspace_id}`)}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">{installation.account_id}</td>
<td class="py-2">
<span class="text-xs text-tertiary"
>{installation.workspace_id}</span
>
</td>
<td class="py-2 text-tertiary">
{installation.repositories.length} repos
</td>
<td class="pl-8 py-2 text-right">
<Button
size="xs2"
color="blue"
title="Add installation to workspace"
startIcon={{ icon: Plus }}
on:click={() => {
if (installation.workspace_id) {
handleAddInstallation(
installation.installation_id,
installation.workspace_id
)
}
}}
>
Add to workspace
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
</div>
<div class="mt-4 flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary"
>Import installation from other instance:</p
>
<div class="flex gap-2">
<input
type="text"
placeholder="Paste JWT token here"
bind:value={githubState.importJwt}
class="flex-1"
/>
<Button
color="blue"
on:click={handleImportInstallation}
disabled={!githubState.importJwt}
>
Import
</Button>
</div>
</div>
</div>
</div>
{/snippet}
</Popover>
{:else}
<Button
color="none"
variant="border"
size="xs"
disabled={!$enterpriseLicense || githubState.loadingGithubInstallations}
startIcon={{
icon:
githubState.loadingGithubInstallations || githubState.isCheckingInstallation
? Loader2
: Github,
classes:
githubState.loadingGithubInstallations || githubState.isCheckingInstallation
? 'animate-spin'
: ''
}}
href={githubState.githubInstallationUrl}
target="_blank"
on:click={handleInstallClickWithPopover}
>
{$enterpriseLicense
? githubState.isCheckingInstallation
? 'Waiting for installation...'
: 'Install GitHub App'
: 'GitHub App (ee only)'}
</Button>
{/if}
{/if}
@@ -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 @@
<GfmMarkdown md={description} />
{/if}
<div class="flex w-full justify-between items-center mt-4">
<div></div>
{#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'}
<TestTriggerConnection kind={resourceToEdit?.resource_type} args={{ connection: args }} />
{:else}
<TestConnection resourceType={resourceToEdit?.resource_type} {args} />
{/if}
<div class="w-full flex gap-4 flex-row-reverse items-center mt-4">
<Toggle
on:change={(e) => switchTab(e.detail)}
options={{
right: 'As JSON'
}}
/>
{#if resourceToEdit?.resource_type === 'nats' || resourceToEdit?.resource_type === 'kafka'}
<TestTriggerConnection kind={resourceToEdit?.resource_type} args={{ connection: args }} />
{:else}
<TestConnection resourceType={resourceToEdit?.resource_type} {args} />
{/if}
{#if resource_type === 'git_repository' && $workspaceStore && $userStore?.is_admin}
<GitHubAppIntegration
resourceType={resource_type}
{args}
{description}
onArgsUpdate={(newArgs) => {
args = newArgs
// Update rawCode if in JSON view mode
if (viewJsonSchema) {
rawCode = JSON.stringify(args, null, 2)
}
}}
onDescriptionUpdate={(newDescription) => (description = newDescription)}
/>
{/if}
</div>
<div>
{#if loadingSchema}
+450
View File
@@ -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<void> {
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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<string, any>,
description: string,
onArgsUpdate: (newArgs: Record<string, any>) => 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)
}
}
}