Files
windmill/frontend/src/lib/components/GitHubAppIntegration.svelte
T
Guilhem 94e5c21e25 glm/fix-prop-picker-position (#6991)
* fix button fill container

* fix popover overflow

* Update script picker to new guidelines

* fix expand sublow button

* fix scroll

* fix popover dark mode

* fix refresh button

* nit

* fix popover oferflow

* nit
2025-10-29 21:03:12 +00:00

440 lines
14 KiB
Svelte

<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,
addInstallationToWorkspace,
deleteInstallation,
exportInstallation,
importInstallation,
applyRepositoryURL,
handleInstallClick,
type GitHubAppState
} from '$lib/githubApp'
import RepositorySelector from './RepositorySelector.svelte'
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 || $userStore?.is_super_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
variant="default"
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}
disabled={!$enterpriseLicense || githubState.loadingGithubInstallations}
contentClasses="overflow-auto"
>
{#snippet trigger()}
<Button
variant="default"
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}
{@const selectedInstallation = githubState.workspaceGithubInstallations.find(
(inst) => inst.account_id === githubState.selectedGHAppAccountId
)}
{#if selectedInstallation}
<div class="flex flex-col gap-1 flex-1">
<p class="text-sm font-semibold text-secondary">Repository</p>
<RepositorySelector
bind:selectedRepository={githubState.selectedGHAppRepository}
accountId={githubState.selectedGHAppAccountId}
initialRepositories={selectedInstallation.repositories}
totalCount={selectedInstallation.total_count}
perPage={selectedInstallation.per_page}
/>
</div>
{/if}
{/if}
<div class="pt-[26px]">
<Button
size="xs"
variant="accent"
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
variant="default"
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-primary">
<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-primary">{installation.workspace_id}</span
>
</td>
<td class="py-2 text-primary">
{installation.repositories.length} repos
</td>
<td class="py-2 text-right">
<div class="flex justify-end gap-1">
<Button
size="xs2"
variant="accent"
title="Export installation to other instance"
startIcon={{ icon: Download }}
on:click={() =>
handleExportInstallation(installation.installation_id)}
>
Export
</Button>
<Button
size="xs2"
variant="default"
destructive
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-primary">
<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-primary">{installation.workspace_id}</span
>
</td>
<td class="py-2 text-primary">
{installation.repositories.length} repos
</td>
<td class="pl-8 py-2 text-right">
<Button
size="xs2"
variant="accent"
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
variant="accent"
on:click={handleImportInstallation}
disabled={!githubState.importJwt}
>
Import
</Button>
</div>
</div>
</div>
</div>
{/snippet}
</Popover>
{:else}
<Button
variant="default"
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}