feat: git sync now accepts path filters and type filters (#3189)

* feat: git sync now accepts path filters and type filters

* fix git sync for folders

* UI nits

* Add folder by default in migration

* fix openapi
This commit is contained in:
Guillaume Bouvignies
2024-02-09 18:51:44 +01:00
committed by GitHub
parent 2a7c6667e2
commit 126773122a
12 changed files with 1108 additions and 835 deletions

1
backend/Cargo.lock generated
View File

@@ -9293,6 +9293,7 @@ dependencies = [
name = "windmill-git-sync"
version = "1.265.3"
dependencies = [
"regex",
"rsmq_async",
"serde",
"serde_json",

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,13 @@
-- Add up migration script here
UPDATE workspace_settings SET git_sync = (
CASE
WHEN git_sync is null THEN null
WHEN git_sync = '[]'::jsonb THEN null
ELSE jsonb_build_object(
'repositories', git_sync,
'include_path', '["f/**"]'::jsonb,
'include_type', '["script", "flow", "app", "folder"]'::jsonb
)
END
);

File diff suppressed because it is too large Load Diff

View File

@@ -1195,9 +1195,7 @@ paths:
large_file_storage:
$ref: "#/components/schemas/LargeFileStorage"
git_sync:
type: array
items:
$ref: "#/components/schemas/WorkspaceGitSync"
$ref: "#/components/schemas/WorkspaceGitSyncSettings"
default_app:
type: string
required:
@@ -1540,9 +1538,7 @@ paths:
type: object
properties:
git_sync_settings:
type: array
items:
$ref: "#/components/schemas/WorkspaceGitSync"
$ref: "#/components/schemas/WorkspaceGitSyncSettings"
responses:
"200":
@@ -9508,7 +9504,28 @@ components:
- useSSL
- pathStyle
WorkspaceGitSync:
WorkspaceGitSyncSettings:
type: object
properties:
include_path:
type: array
items:
type: string
include_type:
type: array
items:
type: string
enum:
- script
- flow
- app
- folder
repositories:
type: array
items:
$ref: "#/components/schemas/GitRepositorySettings"
GitRepositorySettings:
type: object
properties:
script_path:

View File

@@ -212,7 +212,7 @@ async fn create_folder(
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}/folder.meta.*", ng.name) },
DeployedObject::Folder { path: format!("f/{}", ng.name) },
Some(format!("Folder '{}' created", ng.name)),
rsmq,
true,
@@ -500,7 +500,7 @@ async fn delete_folder(
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}/folder.meta.*", name) },
DeployedObject::Folder { path: format!("f/{}", name) },
Some(format!("Folder '{}' deleted", name)),
rsmq,
true,

View File

@@ -88,7 +88,7 @@ async fn add_granular_acl(
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}/folder.meta.*", path) },
DeployedObject::Folder { path: format!("f/{}", path) },
Some(format!("Folder '{}' changed permissions", path)),
rsmq,
true,
@@ -201,7 +201,7 @@ async fn remove_granular_acl(
&authed.username,
&db,
&w_id,
DeployedObject::Folder { path: format!("f/{}/folder.meta.*", path) },
DeployedObject::Folder { path: format!("f/{}", path) },
Some(format!("Folder '{}' changed permissions", path)),
rsmq,
true,

View File

@@ -45,7 +45,7 @@ use windmill_common::schedule::Schedule;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::build_crypt;
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::workspaces::WorkspaceGitRepo;
use windmill_common::workspaces::WorkspaceGitSyncSettings;
use windmill_common::{
error::{to_anyhow, Error, JsonResult, Result},
flows::Flow,
@@ -165,7 +165,7 @@ pub struct WorkspaceSettings {
pub error_handler_extra_args: Option<serde_json::Value>,
pub error_handler_muted_on_cancel: Option<bool>,
pub large_file_storage: Option<serde_json::Value>, // effectively: DatasetsStorage
pub git_sync: Option<serde_json::Value>, // effectively: WorkspaceGitRepo
pub git_sync: Option<serde_json::Value>, // effectively: WorkspaceGitSyncSettings
pub default_app: Option<String>,
}
@@ -1143,7 +1143,7 @@ async fn edit_large_file_storage_config(
#[derive(Deserialize)]
struct EditGitSyncConfig {
git_sync_settings: Option<Vec<WorkspaceGitRepo>>,
git_sync_settings: Option<WorkspaceGitSyncSettings>,
}
async fn edit_git_sync_config(
@@ -1177,7 +1177,7 @@ async fn edit_git_sync_config(
.await?;
if let Some(git_sync_settings) = new_config.git_sync_settings {
let serialized_config = serde_json::to_value::<Vec<WorkspaceGitRepo>>(git_sync_settings)
let serialized_config = serde_json::to_value::<WorkspaceGitSyncSettings>(git_sync_settings)
.map_err(|err| Error::InternalErr(err.to_string()))?;
sqlx::query!(

View File

@@ -1,7 +1,23 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct WorkspaceGitSyncSettings {
pub include_path: Vec<String>,
pub include_type: Vec<ObjectType>,
pub repositories: Vec<GitRepositorySettings>,
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum ObjectType {
Script,
Flow,
App,
Folder,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct WorkspaceGitRepo {
pub struct GitRepositorySettings {
pub script_path: String,
pub git_repo_resource_path: String,
pub use_individual_branch: Option<bool>,

View File

@@ -20,4 +20,5 @@ serde_json.workspace = true
tracing.workspace = true
windmill-common = { workspace = true, features = ["axum"] }
windmill-queue.workspace = true
rsmq_async.workspace = true
rsmq_async.workspace = true
regex = "1.10.3"

View File

@@ -11,8 +11,9 @@ use std::collections::HashMap;
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_common::users::SUPERADMIN_SYNC_EMAIL;
use windmill_common::workspaces::WorkspaceGitRepo;
use windmill_common::workspaces::{ObjectType, WorkspaceGitSyncSettings};
use regex::Regex;
use serde_json::json;
use windmill_common::error::{Error, Result};
use windmill_common::jobs::JobPayload;
@@ -59,23 +60,44 @@ pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send
rsmq: Option<R>,
skip_db_insert: bool,
) -> Result<()> {
let exclude_path_prefix = "u/";
let obj_path = if obj.get_path().starts_with(exclude_path_prefix) {
None
} else {
let workspace_git_repo_setting = sqlx::query_scalar!(
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
w_id
)
.fetch_one(db)
.await
.map_err(|err| {
Error::BadRequest(format!(
"No workspace settings found for workspace ID: {} - Error: {}",
w_id,
err.to_string()
))
})?;
let workspace_git_sync_settings = workspace_git_repo_setting
.map(|conf| serde_json::from_value::<WorkspaceGitSyncSettings>(conf).ok())
.flatten()
.unwrap_or_default();
let obj_path = if one_regexp_match(
obj.get_path(),
workspace_git_sync_settings.include_path.clone(),
) {
Some(obj.get_path())
} else {
None
};
let obj_parent_path = if obj
.get_parent_path()
.unwrap_or(exclude_path_prefix.to_string())
.starts_with(exclude_path_prefix)
.map(|p| one_regexp_match(p.as_str(), workspace_git_sync_settings.include_path))
.unwrap_or(false)
{
None
} else {
obj.get_parent_path()
} else {
None
};
let skip_git_sync = if obj_path.is_none() && obj_parent_path.is_none() {
let mut skip_git_sync = if obj_path.is_none() && obj_parent_path.is_none() {
tracing::debug!(
"Ignoring {} from git sync as it's in a private user folder",
obj.get_path()
@@ -85,28 +107,31 @@ pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send
false
};
let workspace_git_repo_setting = sqlx::query_scalar!(
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
w_id
)
.fetch_optional(db)
.await?;
skip_git_sync = skip_git_sync
|| match obj {
DeployedObject::Script { .. } => !workspace_git_sync_settings
.include_type
.iter()
.any(|element| *element == ObjectType::Script),
DeployedObject::Flow { .. } => !workspace_git_sync_settings
.include_type
.iter()
.any(|element| *element == ObjectType::Flow),
DeployedObject::App { .. } => !workspace_git_sync_settings
.include_type
.iter()
.any(|element| *element == ObjectType::App),
DeployedObject::Folder { .. } => !workspace_git_sync_settings
.include_type
.iter()
.any(|element| *element == ObjectType::Folder),
};
if workspace_git_repo_setting.is_none() {
return Err(Error::InternalErr(
"No workspace settings found for workspace ID".to_string(),
));
}
let workspace_git_repos = workspace_git_repo_setting
.unwrap()
.map(|conf| serde_json::from_value::<Vec<WorkspaceGitRepo>>(conf).ok())
.flatten()
.unwrap_or_default();
tracing::debug!("Skipping git sync for {:?} -> {}", obj_path, skip_git_sync);
let mut git_sync_job_uuids: Vec<Uuid> = vec![];
if !skip_git_sync {
for workspace_git_repo in workspace_git_repos {
for workspace_git_repo in workspace_git_sync_settings.repositories {
let mut args: HashMap<String, serde_json::Value> = HashMap::new();
args.insert(
"repo_url_resource_path".to_string(),
@@ -236,3 +261,43 @@ pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send
return Ok(());
}
fn one_regexp_match(path: &str, regexps: Vec<String>) -> bool {
let has_match = regexps
.iter()
.map(|regexp| transform_regexp(regexp.to_owned()))
.filter(|regexp| regexp.is_some())
.map(|regexp| regexp.unwrap())
.any(|regexp| regexp.is_match(path));
tracing::debug!(
"{} matches the set of regexps {:?} -> {}",
path,
regexps,
has_match
);
return has_match;
}
fn transform_regexp(user_regexp: String) -> Option<Regex> {
// this is annoying b/c we want to replace ** with [a-zA-Z0-9_.*/]* AND THEN * with [a-zA-Z0-9_.*]* - but b/c the
// first replacement string contains itself '*', we can't do it naively. So, the hack is to use a different
// character in the replacement string, instead of '*' we use here '%', and finally we replace all '%' with '*'
let mut regexp = user_regexp
.replace("**", "[a-zA-Z0-9_.%/]%")
.replace("*", "[a-zA-Z0-9_.%]%");
regexp = regexp.replace("%", "*");
// Then we add ^ at the beginning and '$' at the end to match the entire string, not just a substring
regexp = if regexp.starts_with("^") {
regexp
} else {
format!("^{}", regexp)
};
regexp = if regexp.ends_with("$") {
regexp
} else {
format!("{}$", regexp)
};
let compiled_regexp = Regex::new(regexp.as_str()).ok();
tracing::debug!("Compiled regexp: {:?}", compiled_regexp);
return compiled_regexp;
}

View File

@@ -33,13 +33,14 @@
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { setQueryWithoutLoad, emptyString, tryEvery } from '$lib/utils'
import { Scroll, Slack, XCircle, RotateCw, CheckCircle2 } from 'lucide-svelte'
import { Scroll, Slack, XCircle, RotateCw, CheckCircle2, X, Plus } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import TestOpenaiKey from '$lib/components/copilot/TestOpenaiKey.svelte'
import Portal from 'svelte-portal'
import { fade } from 'svelte/transition'
let s3FileViewer: S3FilePicker
@@ -64,10 +65,19 @@
publicResource: boolean | undefined
}
let gitSyncSettings: {
script_path: string
git_repo_resource_path: string
use_individual_branch: boolean
}[]
include_path: string[]
repositories: {
script_path: string
git_repo_resource_path: string
use_individual_branch: boolean
}[]
include_type: {
scripts: boolean
flows: boolean
apps: boolean
folders: boolean
}
}
let gitSyncTestJobs: {
jobId: string | undefined
status: 'running' | 'success' | 'failure' | undefined
@@ -208,7 +218,7 @@
async function editWindmillGitSyncSettings(): Promise<void> {
let alreadySeenResource: string[] = []
let finalSettings = gitSyncSettings.map((elmt) => {
let repositories = gitSyncSettings.repositories.map((elmt) => {
alreadySeenResource.push(elmt.git_repo_resource_path)
return {
script_path: elmt.script_path,
@@ -216,15 +226,38 @@
use_individual_branch: elmt.use_individual_branch
}
})
let include_path = gitSyncSettings.include_path.filter((elmt) => {
return !emptyString(elmt)
})
let include_type: ('script' | 'flow' | 'app' | 'folder')[] = []
if (gitSyncSettings.include_type.scripts) {
include_type.push('script')
}
if (gitSyncSettings.include_type.flows) {
include_type.push('flow')
}
if (gitSyncSettings.include_type.apps) {
include_type.push('app')
}
if (gitSyncSettings.include_type.folders) {
include_type.push('folder')
}
if (alreadySeenResource.some((res, index) => alreadySeenResource.indexOf(res) !== index)) {
sendUserToast('Same Git resource used more than once', true)
return
}
if (finalSettings.length > 0) {
if (repositories.length > 0 || include_path.length > 1 || include_path[0] !== 'f/**') {
await WorkspaceService.editWorkspaceGitSyncConfig({
workspace: $workspaceStore!,
requestBody: {
git_sync_settings: finalSettings
git_sync_settings: {
repositories: repositories,
include_path: include_path,
include_type: include_type
}
}
})
sendUserToast('Workspace Git sync settings updated')
@@ -310,28 +343,45 @@
publicResource: undefined
}
}
if (
settings.git_sync !== undefined &&
settings.git_sync !== null &&
settings.git_sync.length > 0
) {
gitSyncSettings = settings.git_sync.map((settings) => {
return {
git_repo_resource_path: settings.git_repo_resource_path.replace('$res:', ''),
script_path: settings.script_path,
use_individual_branch: settings.use_individual_branch ?? false
if (settings.git_sync !== undefined && settings.git_sync !== null) {
gitSyncTestJobs = []
gitSyncSettings = {
include_path:
settings.git_sync.include_path?.length ?? 0 > 0
? settings.git_sync.include_path ?? []
: ['f/**'],
repositories: (settings.git_sync.repositories ?? []).map((settings) => {
gitSyncTestJobs.push({
jobId: undefined,
status: undefined
})
return {
git_repo_resource_path: settings.git_repo_resource_path.replace('$res:', ''),
script_path: settings.script_path,
use_individual_branch: settings.use_individual_branch ?? false
}
}),
include_type: {
scripts: (settings.git_sync.include_type?.indexOf('script') ?? -1) >= 0,
flows: (settings.git_sync.include_type?.indexOf('flow') ?? -1) >= 0,
apps: (settings.git_sync.include_type?.indexOf('app') ?? -1) >= 0,
folders: (settings.git_sync.include_type?.indexOf('folder') ?? -1) >= 0
}
})
gitSyncTestJobs = settings.git_sync.map((settings) => {
return {
jobId: undefined,
status: undefined
}
})
}
} else {
gitSyncSettings = []
gitSyncSettings = {
include_path: ['f/**'],
repositories: [],
include_type: {
scripts: true,
flows: true,
apps: true,
folders: true
}
}
gitSyncTestJobs = []
}
console.log(gitSyncSettings)
// check openai_client_credentials_oauth
usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({
@@ -381,15 +431,15 @@
}
async function runGitSyncTestJob(settingsIdx: number) {
let gitSyncSettingsElmt = gitSyncSettings[settingsIdx]
if (emptyString(gitSyncSettingsElmt.script_path)) {
let gitSyncRepository = gitSyncSettings.repositories[settingsIdx]
if (emptyString(gitSyncRepository.script_path)) {
return
}
let jobId = await JobService.runScriptByPath({
workspace: $workspaceStore!,
path: 'hub/7925/git-repo-test-read-write-windmill',
requestBody: {
repo_url_resource_path: gitSyncSettingsElmt.git_repo_resource_path.replace('$res:', '')
repo_url_resource_path: gitSyncRepository.git_repo_resource_path.replace('$res:', '')
}
})
gitSyncTestJobs[settingsIdx] = {
@@ -852,28 +902,19 @@
scripts, flows and apps to the repository on each deploy.
</div>
</div>
<br />
{#if !$enterpriseLicense}
<Alert type="warning" title="Syncing workspace to Git is an EE feature">
Automatically saving scripts to a Git repository on each deploy is a Windmill EE feature.
</Alert>
<div class="mb-1" />
{/if}
<Alert
type="info"
title="Scripts, flows and apps in the user private folders will be ignored"
>
All scripts, flows and apps located in the workspace will be pushed to the Git repository,
except the ones that are saved in private user folders (i.e. where the path starts with
`u/`, use those with `f/` instead).
<br />
Filtering out certain sensitive folders from the sync will be available soon.
</Alert>
{#if $enterpriseLicense}
<div class="flex mt-5 mb-5 gap-1">
<Button
color="blue"
disabled={gitSyncSettings?.some((elmt) => emptyString(elmt.git_repo_resource_path))}
disabled={gitSyncSettings.repositories.some((elmt) =>
emptyString(elmt.git_repo_resource_path)
)}
on:click={() => {
editWindmillGitSyncSettings()
console.log('Saving git sync settings', gitSyncSettings)
@@ -881,32 +922,111 @@
>
</div>
{/if}
{#if Array.isArray(gitSyncSettings)}
{#each gitSyncSettings as gitSyncSettingsElmt, idx}
<div class="flex mt-5 mb-1 gap-1 items-center text-xs">
<h6>Repository #{idx + 1}</h6>
<div class="flex flex-wrap gap-20">
<div class="max-w-md w-full">
{#if Array.isArray(gitSyncSettings.include_path)}
<h4 class="flex gap-2 mb-4"
>Filter on path<Tooltip>
Only scripts, flows and apps with their path matching one of those filters will be
synced to the Git repositories below. The filters allow '*'' and '**' characters,
with '*'' matching any character allowed in paths until the next slash (/) and '**'
matching anything including slashes.
<br />By default everything in folders will be synced.
</Tooltip></h4
>
{#each gitSyncSettings.include_path as gitSyncRegexpPath, idx}
<div class="flex mt-1 items-center">
<input type="text" bind:value={gitSyncRegexpPath} id="arg-input-array" />
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
aria-label="Clear"
on:click={() => {
gitSyncSettings.include_path.splice(idx, 1)
gitSyncSettings.include_path = [...gitSyncSettings.include_path]
}}
>
<X size={14} />
</button>
</div>
{/each}
{/if}
<div class="flex mt-2">
<Button
variant="border"
color="light"
size="xs"
startIcon={{ icon: XCircle }}
iconOnly={true}
btnClasses="mt-1"
on:click={() => {
gitSyncSettings.splice(idx, 1)
gitSyncSettings = [...gitSyncSettings]
gitSyncSettings.include_path = [...gitSyncSettings.include_path, '']
}}
id="git-sync-add-path-filter"
startIcon={{ icon: Plus }}
>
Add filter
</Button>
</div>
</div>
<div class="max-w-md w-full">
<h4 class="flex gap-2 mb-4"
>Filter on type<Tooltip>
On top of the filter path above, you can include only certain type of object to be
synced with the Git repository.
<br />By default everything is synced.
</Tooltip></h4
>
<div class="flex flex-col gap-1 mt-1">
<Toggle
bind:checked={gitSyncSettings.include_type.scripts}
options={{ right: 'Scripts' }}
/>
<Toggle
bind:checked={gitSyncSettings.include_type.flows}
options={{ right: 'Flows' }}
/>
<Toggle bind:checked={gitSyncSettings.include_type.apps} options={{ right: 'Apps' }} />
<Toggle
bind:checked={gitSyncSettings.include_type.folders}
options={{ right: 'Folders' }}
/>
</div>
</div>
</div>
<h4 class="flex gap-2 mt-5 mb-5"
>Repositories to sync<Tooltip>
The changes will be deployed to all the repositories set below.
</Tooltip></h4
>
{#if Array.isArray(gitSyncSettings.repositories)}
{#each gitSyncSettings.repositories as gitSyncRepository, idx}
<div class="flex mt-5 mb-1 gap-1 items-center text-xs">
<h6>Repository #{idx + 1}</h6>
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
aria-label="Clear"
on:click={() => {
gitSyncSettings.repositories.splice(idx, 1)
gitSyncSettings.repositories = [...gitSyncSettings.repositories]
}}
>
<X size={14} />
</button>
</div>
<div class="flex mt-5 mb-1 gap-1">
{#key gitSyncSettingsElmt}
{#key gitSyncRepository}
<ResourcePicker
resourceType="git_repository"
initialValue={gitSyncSettingsElmt.git_repo_resource_path}
initialValue={gitSyncRepository.git_repo_resource_path}
on:change={(ev) => {
gitSyncSettingsElmt.git_repo_resource_path = ev.detail
gitSyncRepository.git_repo_resource_path = ev.detail
}}
/>
<Button
disabled={emptyString(gitSyncSettingsElmt.script_path)}
disabled={emptyString(gitSyncRepository.script_path)}
btnClasses="w-32 text-center"
color="dark"
on:click={() => runGitSyncTestJob(idx)}
@@ -915,7 +1035,7 @@
{/key}
</div>
<div class="flex mb-5 text-normal text-2xs gap-1">
{#if gitSyncSettings.filter((settings) => settings.git_repo_resource_path === gitSyncSettingsElmt.git_repo_resource_path).length > 1}
{#if gitSyncSettings.repositories.filter((settings) => settings.git_repo_resource_path === gitSyncRepository.git_repo_resource_path).length > 1}
<span class="text-red-700">Using the same resource twice is not allowed.</span>
{/if}
{#if gitSyncTestJobs[idx].status !== undefined}
@@ -939,12 +1059,12 @@
<div class="flex mt-5 mb-1 gap-1">
{#if gitSyncSettings}
<Toggle
disabled={emptyString(gitSyncSettingsElmt?.git_repo_resource_path)}
bind:checked={gitSyncSettingsElmt.use_individual_branch}
disabled={emptyString(gitSyncRepository.git_repo_resource_path)}
bind:checked={gitSyncRepository.use_individual_branch}
options={{
right: 'Create one branch per deployed script/flow/app',
right: 'Create one branch per deployed object',
rightTooltip:
"If set, Windmill will create a unique branch per script/flow/app being pushed, prefixed with 'wm_deploy/'."
"If set, Windmill will create a unique branch per object being pushed based on its path, prefixed with 'wm_deploy/'."
}}
/>
{/if}
@@ -956,11 +1076,13 @@
<Button
color="none"
variant="border"
size="xs"
btnClasses="mt-1"
on:click={() => {
gitSyncSettings = [
...gitSyncSettings,
gitSyncSettings.repositories = [
...gitSyncSettings.repositories,
{
script_path: 'hub/7953/sync-script-to-git-repo-windmill',
script_path: 'hub/7954/sync-script-to-git-repo-windmill',
git_repo_resource_path: '',
use_individual_branch: false
}
@@ -972,22 +1094,12 @@
status: undefined
}
]
}}>Add connection</Button
>
<Button
color="none"
variant="border"
on:click={() => {
gitSyncSettings = [...gitSyncSettings.slice(0, -1)]
gitSyncTestJobs = [
...gitSyncTestJobs,
{
jobId: undefined,
status: undefined
}
]
}}>Delete connection</Button
}}
id="git-sync-add-connection"
startIcon={{ icon: Plus }}
>
Add connection
</Button>
</div>
<div class="bg-surface-disabled p-4 rounded-md flex flex-col gap-1">