mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
feat: open gitlab merge requests and post diff previews on them
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1xHmkxuxYb1GYvth1BS75
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb071efd7c
commit
900d482721
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT elem->'credential'\n FROM workspace_settings, jsonb_array_elements(git_sync->'repositories') AS elem\n WHERE workspace_id = $1 AND elem->>'git_repo_resource_path' = $2\n ",
|
||||
"query": "\n SELECT elem->'credential'\n FROM workspace_settings, jsonb_array_elements(git_sync->'repositories') AS elem\n WHERE workspace_id = $1 AND elem->>'git_repo_resource_path' IN ($2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,6 +11,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
@@ -19,5 +20,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "55a272a0050115f90b4fd0de1350ecdbda3fe30cc50d68dc5dfd357113f412ac"
|
||||
"hash": "48055203c97499ab4fc4dcbe9271d3f9e80375b52748c458854a4883a6ecc8f6"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_settings\n SET git_sync = jsonb_set(git_sync, '{repositories}',\n COALESCE((SELECT jsonb_agg(\n CASE WHEN elem->>'git_repo_resource_path' = $2\n THEN CASE WHEN $3::jsonb = 'null'::jsonb\n THEN elem - 'credential'\n ELSE jsonb_set(elem, '{credential}', $3) END\n ELSE elem END)\n FROM jsonb_array_elements(git_sync->'repositories') AS elem), '[]'::jsonb)\n )\n WHERE workspace_id = $1\n AND jsonb_typeof(git_sync->'repositories') = 'array'\n AND EXISTS (\n SELECT 1 FROM jsonb_array_elements(git_sync->'repositories') AS e\n WHERE e->>'git_repo_resource_path' = $2\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "81e7f902c40d72937b494cd9f3f8c9fe4348d179add37361353fc9a02d96f2fa"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_settings\n SET git_sync = jsonb_set(git_sync, '{repositories}',\n COALESCE((SELECT jsonb_agg(\n CASE WHEN elem->>'git_repo_resource_path' IN ($2, $3)\n THEN CASE WHEN $4::jsonb = 'null'::jsonb\n THEN elem - 'credential'\n ELSE jsonb_set(elem, '{credential}', $4) END\n ELSE elem END)\n FROM jsonb_array_elements(git_sync->'repositories') AS elem), '[]'::jsonb)\n )\n WHERE workspace_id = $1\n AND jsonb_typeof(git_sync->'repositories') = 'array'\n AND EXISTS (\n SELECT 1 FROM jsonb_array_elements(git_sync->'repositories') AS e\n WHERE e->>'git_repo_resource_path' IN ($2, $3)\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bbb8331348216892e4714e4338c496c9448cffba5786b51e748bec0165a6519c"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
9ac2ffdee130fcff7ed45d137d8606ae983da837
|
||||
f31743e1c131fddd4bc151153eedc0621dc3a478
|
||||
|
||||
@@ -2877,6 +2877,48 @@ paths:
|
||||
"200":
|
||||
description: Successfully imported the installation
|
||||
|
||||
/w/{workspace}/git_sync/gitlab/projects:
|
||||
post:
|
||||
tags:
|
||||
- Git Sync
|
||||
summary: List the GitLab projects a token can sync
|
||||
description: >-
|
||||
Lists the projects the supplied GitLab token can push to, so a git
|
||||
repository resource can be filled in without hand-writing a project
|
||||
path. The token is used for this call only and is never stored.
|
||||
Requires workspace admin.
|
||||
operationId: listGitlabProjects
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
base_url:
|
||||
type: string
|
||||
description: The GitLab instance, e.g. https://gitlab.com
|
||||
token:
|
||||
type: string
|
||||
description: A group access token or group service account personal access token
|
||||
search:
|
||||
type: string
|
||||
description: Narrow the list to projects matching this text
|
||||
required:
|
||||
- base_url
|
||||
- token
|
||||
responses:
|
||||
"200":
|
||||
description: the projects the token can sync
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/GitlabProject"
|
||||
|
||||
/w/{workspace}/github_app/ghes_installation_callback:
|
||||
post:
|
||||
summary: GHES installation callback
|
||||
@@ -34020,6 +34062,21 @@ components:
|
||||
- rotatable
|
||||
- checked_at
|
||||
|
||||
GitlabProject:
|
||||
type: object
|
||||
description: a GitLab project a token can sync, as the resource form needs it
|
||||
properties:
|
||||
path_with_namespace:
|
||||
type: string
|
||||
description: nested group path plus project name, which is also GitLab's project id
|
||||
http_url_to_repo:
|
||||
type: string
|
||||
default_branch:
|
||||
type: string
|
||||
required:
|
||||
- path_with_namespace
|
||||
- http_url_to_repo
|
||||
|
||||
AutoPullMode:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -10,6 +10,11 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn workspaced_git_sync_service() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
|
||||
@@ -1162,18 +1162,18 @@ async fn maybe_open_git_sync_deploy_pr(
|
||||
};
|
||||
|
||||
// Base = the tracked branch (resource branch, else the repo default). Also
|
||||
// acts as the app-backed gate: PR creation needs the installation token.
|
||||
let base = match windmill_common::git_sync_ee::get_app_repo_head_for_autopull(
|
||||
// acts as the gate: PR creation needs a credential the server itself holds.
|
||||
let base = match windmill_common::git_sync_ee::managed_pr_base_branch(
|
||||
db,
|
||||
workspace_id,
|
||||
&repo_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some((branch, _))) => branch,
|
||||
Ok(Some(branch)) => branch,
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"git sync PR: repo {repo_path} in {workspace_id} has a PR-on-deploy toggle set but is not GitHub-App-backed; skipping (connect the repo through the GitHub App, or use the open-pr-on-commit workflow)"
|
||||
"git sync PR: repo {repo_path} in {workspace_id} has a PR-on-deploy toggle set but the server holds no credential for it; skipping (connect the repo through the GitHub App or a GitLab token, or use the open-pr-on-commit workflow)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1461,7 +1461,7 @@ async fn maybe_post_git_sync_check(
|
||||
(
|
||||
"neutral",
|
||||
"Could not compute the deploy diff".to_string(),
|
||||
"Windmill could not fetch this PR's head or enough history from GitHub to compute its merge with the base. Push again to re-run this check."
|
||||
"Windmill could not fetch this PR's head, or enough history, to compute its merge with the base. Push again to re-run this check."
|
||||
.to_string(),
|
||||
)
|
||||
} else if pr_check_error.is_some() {
|
||||
@@ -1520,11 +1520,23 @@ async fn maybe_post_git_sync_check(
|
||||
Some(url) => format!("{summary}\n\n[See the job in Windmill]({url})"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
// GitLab has no way to address a commit status by id: the name it was
|
||||
// posted under, together with the commit, is what identifies it.
|
||||
let check_run = windmill_common::git_sync_ee::CheckRun {
|
||||
id: check.check_run_id,
|
||||
head_sha: check.head_sha.clone().unwrap_or_default(),
|
||||
name: if is_deploy {
|
||||
windmill_common::git_sync_ee::CHECK_NAME_DEPLOY
|
||||
} else {
|
||||
windmill_common::git_sync_ee::CHECK_NAME_DIFF
|
||||
}
|
||||
.to_string(),
|
||||
};
|
||||
if let Err(e) = windmill_common::git_sync_ee::update_check_run(
|
||||
db,
|
||||
workspace_id,
|
||||
&check.repo_url,
|
||||
check.check_run_id,
|
||||
&check_run,
|
||||
conclusion,
|
||||
&title,
|
||||
&check_summary,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Git sync with GitLab
|
||||
|
||||
GitLab has no equivalent of a GitHub App, so there is nothing to install and no
|
||||
consent screen. What Windmill needs instead is one credential you create in
|
||||
GitLab and paste once. With it, a GitLab repository gets the same managed
|
||||
features an app-backed GitHub repository has: instant pull over a webhook, merge
|
||||
requests opened on deploy, and a diff preview posted onto the merge request.
|
||||
|
||||
## The credential
|
||||
|
||||
Create a **group access token** on the group that owns the project (Settings →
|
||||
Access tokens), or a **group service account** and a personal access token for
|
||||
it. Either one is a bot identity that outlives the person who created it, which
|
||||
is what you want for a credential the instance uses unattended.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Scope | `api` |
|
||||
| Role | Developer to push deploy branches; **Maintainer** to also manage the webhook and open merge requests |
|
||||
| Expiry | Required for a group access token; a group service account PAT can be non-expiring on self-managed (see below) |
|
||||
|
||||
The `api` scope is what makes the token rotatable, so Windmill can renew it
|
||||
before it expires. A `write_repository`-only token can still push, but Windmill
|
||||
cannot inspect or renew it and reports that in the workspace's git sync settings.
|
||||
|
||||
## Connecting a repository
|
||||
|
||||
In the resource form for a `git_repository` resource, use the **GitLab** button:
|
||||
paste the instance URL and the token, pick a project from the list, and Windmill
|
||||
stores the whole remote URL, credential included, in a **secret variable** and
|
||||
points the resource at it (`"url": "$var:u/you/gitlab_group_project_url"`).
|
||||
|
||||
The variable indirection is what makes renewal possible: when Windmill rotates
|
||||
the token it rewrites that one variable, and everything referencing it keeps
|
||||
working. A URL pasted directly into the resource also syncs, but nothing can
|
||||
renew it.
|
||||
|
||||
## Expiry and renewal
|
||||
|
||||
Windmill reads `expires_at` from the token itself and shows it on the repository
|
||||
in the workspace's git sync settings. Within three weeks of expiry it rotates the
|
||||
token through GitLab's own `POST /personal_access_tokens/self/rotate`, writes the
|
||||
replacement back to the variable, and verifies it. Only the token can rotate
|
||||
itself, so a token without `api` (or `self_rotate`) is a permanent warning rather
|
||||
than something Windmill can fix.
|
||||
|
||||
Rotation is deliberately never retried. GitLab revokes the old token the instant
|
||||
it issues the replacement, and presenting an already-rotated token to `/rotate`
|
||||
again is treated as reuse: it revokes **the whole token family, including the
|
||||
live replacement**. So a rotation that succeeded at GitLab but failed to persist
|
||||
is surfaced as an error to act on, not retried.
|
||||
|
||||
Non-expiring tokens are possible only for a **group service account PAT** on
|
||||
self-managed, with `require_personal_access_token_expiry` turned off in the
|
||||
instance's application settings. A group access token is always rejected without
|
||||
an `expires_at`.
|
||||
|
||||
## What each managed feature needs
|
||||
|
||||
| Feature | Needs |
|
||||
| --- | --- |
|
||||
| Instant pull | A project hook Windmill creates, so Maintainer; and a Windmill base URL GitLab can reach |
|
||||
| Merge requests on deploy | Developer, plus the `api` scope |
|
||||
| Diff preview on a merge request | The project hook, plus permission to post commit statuses and merge request notes |
|
||||
|
||||
Instant pull falls back to checking the tracked branch about every minute when
|
||||
the hook cannot be created or delivered, so nothing silently stops syncing.
|
||||
|
||||
## Self-managed differences
|
||||
|
||||
**Webhooks to a private network are blocked by default.** GitLab refuses to
|
||||
create a hook pointing at a private or local address until an administrator
|
||||
enables *Allow requests to the local network from webhooks and integrations*
|
||||
(Admin → Settings → Network → Outbound requests,
|
||||
`allow_local_requests_from_web_hooks_and_services`). A Windmill instance on the
|
||||
same private network as GitLab needs this; without it, hook creation fails with a
|
||||
"blocked" error and the repository keeps polling.
|
||||
|
||||
Everything else is identical: Windmill talks to `<your-gitlab>/api/v4` and needs
|
||||
no inbound access of its own beyond the hook deliveries.
|
||||
|
||||
## Commit statuses create a pipeline
|
||||
|
||||
GitLab has no separate check-run concept. Windmill's `Windmill diff` and
|
||||
`Windmill` statuses are **commit statuses**, and posting one creates an `external`
|
||||
pipeline on the project. Two consequences:
|
||||
|
||||
- A project with *Pipelines must succeed* set will not let a merge request merge
|
||||
while a Windmill status is still running, and will block it if the status
|
||||
failed. Windmill therefore always drives a status it created to a terminal
|
||||
state, and reports an informational result (for example "3 changes to deploy")
|
||||
as success rather than leaving it pending.
|
||||
- `allow_failure` is ignored on the commit-status endpoint, so a Windmill status
|
||||
cannot be made advisory. If you do not want it gating merges, turn the diff
|
||||
preview off rather than expecting it to be non-blocking.
|
||||
|
||||
A commit status carries only a name, a 255-character description and a link, so
|
||||
the diff itself goes in a merge request note that Windmill keeps up to date, and
|
||||
the status links to the job.
|
||||
@@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userStore } 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)
|
||||
|
||||
let show = $derived(
|
||||
resourceType === 'git_repository' &&
|
||||
!!$workspaceStore &&
|
||||
($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}
|
||||
@@ -19,6 +19,7 @@
|
||||
import GfmMarkdown from './GfmMarkdown.svelte'
|
||||
import TestTriggerConnection from './triggers/TestTriggerConnection.svelte'
|
||||
import GitHubAppIntegration from './GitHubAppIntegration.svelte'
|
||||
import GitLabIntegration from './GitLabIntegration.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import ResourceGen from './copilot/ResourceGen.svelte'
|
||||
import SyncResourceTypes from './SyncResourceTypes.svelte'
|
||||
@@ -248,12 +249,28 @@
|
||||
{description}
|
||||
onArgsUpdate={(newArgs) => {
|
||||
args = newArgs
|
||||
if (viewJsonSchema) {
|
||||
// The raw editor is also what a workspace missing the resource type
|
||||
// gets, and it holds its own copy of the value: without this the
|
||||
// picker fills in a URL nothing on screen ever shows.
|
||||
if (viewJsonSchema || !resourceSchema) {
|
||||
rawCode = JSON.stringify(args, null, 2)
|
||||
}
|
||||
}}
|
||||
onDescriptionUpdate={(newDescription) => (description = newDescription)}
|
||||
/>
|
||||
<GitLabIntegration
|
||||
resourceType={resource_type}
|
||||
{args}
|
||||
onArgsUpdate={(newArgs) => {
|
||||
args = newArgs
|
||||
// The raw editor is also what a workspace missing the resource type
|
||||
// gets, and it holds its own copy of the value: without this the
|
||||
// picker fills in a URL nothing on screen ever shows.
|
||||
if (viewJsonSchema || !resourceSchema) {
|
||||
rawCode = JSON.stringify(args, null, 2)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -151,6 +151,13 @@
|
||||
let loadingResourceInfo = $state(false)
|
||||
// Only GitHub App-backed repos can register webhooks; PAT repos poll only.
|
||||
let isGithubApp = $state(false)
|
||||
// Whether Windmill itself holds a credential for the repository, which is
|
||||
// what the managed features (webhooks, pull requests, commit checks) need.
|
||||
// A GitHub App installation qualifies, and so does a pasted GitLab token the
|
||||
// server has introspected and found healthy.
|
||||
let hasManagedCredential = $derived(
|
||||
isGithubApp || (repo?.credential != null && !repo.credential.error)
|
||||
)
|
||||
|
||||
const MS_PER_DAY = 86_400_000
|
||||
|
||||
@@ -270,7 +277,7 @@
|
||||
if (
|
||||
repoMode === 'sync' &&
|
||||
repo.isUnsavedConnection &&
|
||||
isGithubApp &&
|
||||
hasManagedCredential &&
|
||||
!isFork &&
|
||||
$enterpriseLicense &&
|
||||
repo.auto_pull === undefined
|
||||
@@ -284,7 +291,7 @@
|
||||
if (
|
||||
repoMode === 'promotion' &&
|
||||
repo.isUnsavedConnection &&
|
||||
isGithubApp &&
|
||||
hasManagedCredential &&
|
||||
$enterpriseLicense &&
|
||||
repo.promotion_open_prs === undefined
|
||||
) {
|
||||
@@ -754,7 +761,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if repoMode === 'promotion' && isGithubApp}
|
||||
{#if repoMode === 'promotion' && hasManagedCredential}
|
||||
<div class="mt-2">
|
||||
<!-- Locked while the dev promotion toggle's save is in flight: this
|
||||
toggle is revealed by that save, and an edit made mid-save would be
|
||||
@@ -783,7 +790,12 @@
|
||||
href="https://www.windmill.dev/docs/integrations/git_repository#github-app"
|
||||
target="_blank"
|
||||
class="text-blue-500 hover:underline">GitHub App</a
|
||||
> and Windmill opens them automatically.
|
||||
>, or give a GitLab repository a
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/integrations/git_repository"
|
||||
target="_blank"
|
||||
class="text-blue-500 hover:underline">group access token</a
|
||||
>, and Windmill opens them automatically.
|
||||
</div>
|
||||
{/if}
|
||||
{#if repoMode === 'sync' && isFork}
|
||||
@@ -798,7 +810,7 @@
|
||||
fork is pushed to the fork's own
|
||||
<span class="font-mono">wm-fork/…</span> branch instead of the tracked branch.
|
||||
</div>
|
||||
{#if isGithubApp}
|
||||
{#if hasManagedCredential}
|
||||
<div class="mt-2">
|
||||
<Toggle
|
||||
checked={repo.fork_open_prs ?? false}
|
||||
@@ -823,11 +835,12 @@
|
||||
target="_blank"
|
||||
class="text-blue-500 hover:underline font-mono">open-pr-on-fork-commit</a
|
||||
>
|
||||
workflow in the repository. Recommended: connect the repository through the
|
||||
workflow in the repository. Recommended: connect the repository through the GitHub
|
||||
App, or give a GitLab repository a
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/integrations/git_repository#github-app"
|
||||
href="https://www.windmill.dev/docs/integrations/git_repository"
|
||||
target="_blank"
|
||||
class="text-blue-500 hover:underline">GitHub App</a
|
||||
class="text-blue-500 hover:underline">group access token</a
|
||||
> and Windmill opens them automatically.
|
||||
</div>
|
||||
{/if}
|
||||
@@ -889,7 +902,7 @@
|
||||
options={{
|
||||
right: 'Automatically deploy changes from Git',
|
||||
rightTooltip:
|
||||
'Windmill deploys new commits from the tracked branch into this workspace. Repositories connected through the GitHub App sync instantly via webhooks with a polling fallback; token-based repositories are checked about every minute.'
|
||||
'Windmill deploys new commits from the tracked branch into this workspace. Repositories Windmill holds a credential for sync instantly via webhooks with a polling fallback; other token-based repositories are checked about every minute.'
|
||||
}}
|
||||
on:change={(e) => setAutoPullEnabled(e.detail)}
|
||||
>
|
||||
@@ -911,7 +924,7 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isGithubApp && !loadingResourceInfo}
|
||||
{#if !hasManagedCredential && !loadingResourceInfo}
|
||||
<div class="mt-2">
|
||||
<Alert type="info" title="Instant pull recommended" size="xs">
|
||||
Pull for this repository checks the tracked branch about every minute; longer
|
||||
@@ -921,25 +934,30 @@
|
||||
href="https://www.windmill.dev/docs/integrations/git_repository#github-app"
|
||||
target="_blank"
|
||||
class="text-blue-500 hover:underline">GitHub App</a
|
||||
>, or give a GitLab repository a
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/integrations/git_repository"
|
||||
target="_blank"
|
||||
class="text-blue-500 hover:underline">group access token</a
|
||||
>
|
||||
(which also lets Windmill manage pull requests), or push changes into Windmill
|
||||
(either also lets Windmill manage pull requests), or push changes into Windmill
|
||||
with the
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/advanced/git_sync#github-actions"
|
||||
target="_blank"
|
||||
class="text-blue-500 hover:underline">sync GitHub workflow</a
|
||||
>. If you already push changes with a GitHub Action, keep either the Action or
|
||||
automatic pull, not both, so they don't fight over deploys.
|
||||
>. If you already push changes from CI, keep either that or automatic pull,
|
||||
not both, so they don't fight over deploys.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
{#if repo.auto_pull?.enabled}
|
||||
{@const viaWebhook = repo.auto_pull?.webhook_id != null}
|
||||
{#if isGithubApp}
|
||||
{#if hasManagedCredential}
|
||||
<div class="mt-2">
|
||||
<Alert type="info" title="Already pulling with a GitHub Action?" size="xs">
|
||||
If you previously set up a GitHub Action to push changes into Windmill,
|
||||
remove it now so the two don't fight over deploys.
|
||||
<Alert type="info" title="Already pulling with a CI job?" size="xs">
|
||||
If you previously set up a CI job to push changes into Windmill, remove it
|
||||
now so the two don't fight over deploys.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -965,7 +983,7 @@
|
||||
: 'Checking the tracked branch about every minute. New commits deploy automatically.'}
|
||||
{/if}
|
||||
</div>
|
||||
{#if isGithubApp && repo.auto_pull?.webhook_error}
|
||||
{#if hasManagedCredential && repo.auto_pull?.webhook_error}
|
||||
<div class="mt-2">
|
||||
<Alert type="warning" title="Falling back to polling" size="xs">
|
||||
{repo.auto_pull.webhook_error}
|
||||
|
||||
Reference in New Issue
Block a user