Files
windmill/frontend/src/lib/components/GitLabIntegration.svelte
T

225 lines
7.1 KiB
Svelte

<script lang="ts">
import { workspaceStore, userStore, enterpriseLicense } from '$lib/stores'
import { GitSyncService, VariableService, type GitlabProject } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
import { Alert } from './common'
import TextInput from './text_input/TextInput.svelte'
import Select from './select/Select.svelte'
import { GitBranch, Loader2 } from 'lucide-svelte'
interface Props {
resourceType: string
args?: Record<string, any>
onArgsUpdate?: (args: Record<string, any>) => void
}
let { resourceType, args = {}, onArgsUpdate }: Props = $props()
let baseUrl = $state('https://gitlab.com')
let token = $state('')
let search = $state('')
let projects: GitlabProject[] = $state([])
let selectedProject: string | undefined = $state(undefined)
let variablePath = $state('')
let loading = $state(false)
let applying = $state(false)
let listError: string | undefined = $state(undefined)
// The project listing is served by an enterprise-only route, so on a build
// without it the button would open a form whose first request 404s.
let show = $derived(
resourceType === 'git_repository' &&
!!$workspaceStore &&
!!$enterpriseLicense &&
($userStore?.is_admin || $userStore?.is_super_admin)
)
let project = $derived(projects.find((p) => p.path_with_namespace === selectedProject))
// A path the user has not overridden tracks the selected project, so picking a
// different one does not silently overwrite the first project's variable.
let suggestedVariablePath = $derived(
project
? `u/${$userStore?.username ?? 'admin'}/gitlab_${project.path_with_namespace
.replace(/[^a-zA-Z0-9]+/g, '_')
.toLowerCase()}_url`
: ''
)
let variablePathTouched = $state(false)
$effect(() => {
if (!variablePathTouched) {
variablePath = suggestedVariablePath
}
})
async function listProjects() {
if (!$workspaceStore) return
loading = true
listError = undefined
try {
projects = await GitSyncService.listGitlabProjects({
workspace: $workspaceStore,
requestBody: { base_url: baseUrl, token, search: search || undefined }
})
selectedProject = projects[0]?.path_with_namespace
if (projects.length === 0) {
listError = 'The token can see no project with at least the Developer role'
}
} catch (err) {
listError = err?.body ?? err?.message ?? String(err)
projects = []
selectedProject = undefined
} finally {
loading = false
}
}
// The credential travels in the remote URL, and the whole URL lives in one
// secret variable: that is the shape Windmill can rewrite when it renews the
// token, and it keeps the credential out of the resource itself.
function repositoryUrl(p: GitlabProject): string {
const url = new URL(p.http_url_to_repo)
url.username = 'oauth2'
url.password = token
return url.toString()
}
async function apply(close: (_: any) => void) {
if (!$workspaceStore || !project) return
applying = true
try {
const value = repositoryUrl(project)
const exists = await VariableService.existsVariable({
workspace: $workspaceStore,
path: variablePath
})
if (exists) {
await VariableService.updateVariable({
workspace: $workspaceStore,
path: variablePath,
requestBody: { value, is_secret: true }
})
} else {
await VariableService.createVariable({
workspace: $workspaceStore,
requestBody: {
path: variablePath,
value,
is_secret: true,
description: `Git remote for ${project.path_with_namespace}, including its GitLab token`
}
})
}
onArgsUpdate?.({
...args,
url: `$var:${variablePath}`,
is_github_app: false,
branch: args.branch || project.default_branch || undefined
})
token = ''
sendUserToast(`Repository URL stored in the secret variable ${variablePath}`)
close(null)
} catch (err) {
sendUserToast(`Could not store the repository URL: ${err?.body ?? err?.message}`, true)
} finally {
applying = false
}
}
</script>
{#if show}
<Popover
documentationLink="https://www.windmill.dev/docs/integrations/git_repository"
contentClasses="overflow-auto"
>
{#snippet trigger()}
<Button variant="default" unifiedSize="xs" startIcon={{ icon: GitBranch }} nonCaptureEvent>
GitLab
</Button>
{/snippet}
{#snippet content({ close })}
<div class="block text-primary p-4">
<div class="flex flex-col gap-4 w-[600px]">
<div class="flex flex-col gap-y-1">
<div class="text-xs font-semibold text-emphasis">GitLab instance</div>
<TextInput bind:value={baseUrl} size="sm" />
</div>
<div class="flex flex-col gap-y-1">
<div class="text-xs font-semibold text-emphasis">Group access token</div>
<div class="text-xs font-normal text-secondary">
Create it in the group that owns the project, with the api scope and at least the
Developer role. Maintainer also lets Windmill manage the webhook and merge requests.
</div>
<TextInput bind:value={token} size="sm" inputProps={{ type: 'password' }} />
<div class="text-2xs font-normal text-hint">
Used to list projects now, then stored in a secret variable
</div>
</div>
<div class="flex flex-col gap-y-1">
<div class="text-xs font-semibold text-emphasis">Filter projects</div>
<TextInput bind:value={search} size="sm" inputProps={{ placeholder: 'Optional' }} />
</div>
<div>
<Button
variant="default"
unifiedSize="sm"
disabled={!token || !baseUrl || loading}
startIcon={{
icon: loading ? Loader2 : GitBranch,
classes: loading ? 'animate-spin' : ''
}}
onclick={listProjects}
>
List projects
</Button>
</div>
{#if listError}
<Alert type="error" title="Could not list projects" size="xs">{listError}</Alert>
{/if}
{#if projects.length > 0}
<div class="flex flex-col gap-y-1">
<div class="text-xs font-semibold text-emphasis">Project</div>
<Select
items={projects.map((p) => ({
label: p.path_with_namespace,
value: p.path_with_namespace
}))}
bind:value={selectedProject}
clearable={false}
/>
</div>
<div class="flex flex-col gap-y-1">
<div class="text-xs font-semibold text-emphasis">Secret variable</div>
<div class="text-xs font-normal text-secondary">
Where the repository URL and its token are kept. Windmill rewrites this variable
when it renews the token, so every consumer keeps working.
</div>
<TextInput
bind:value={variablePath}
size="sm"
inputProps={{ oninput: () => (variablePathTouched = true) }}
/>
</div>
<div class="flex justify-end">
<Button
variant="accent"
unifiedSize="sm"
disabled={!project || !variablePath || applying}
startIcon={{
icon: applying ? Loader2 : GitBranch,
classes: applying ? 'animate-spin' : ''
}}
onclick={() => apply(close)}
>
Use this project
</Button>
</div>
{/if}
</div>
</div>
{/snippet}
</Popover>
{/if}