mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user