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:
hugocasa
2026-09-03 11:05:58 +02:00
co-authored by Claude Opus 5
parent bb071efd7c
commit 900d482721
11 changed files with 475 additions and 44 deletions
@@ -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"
}
@@ -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"
}
@@ -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
View File
@@ -1 +1 @@
9ac2ffdee130fcff7ed45d137d8606ae983da837
f31743e1c131fddd4bc151153eedc0621dc3a478
+57
View File
@@ -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:
+5
View File
@@ -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,
+99
View File
@@ -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}