mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 08:03:38 +00:00
* feat: rework git sync settings into a flat repo list and setup modal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: add the connect step for gitlab and github in git sync setup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: open the github app install page from the git sync setup next step Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: let git sync setup set the repository folder Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: add the configure sync step to git sync setup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: even out the git sync filter settings columns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: pin the filter input to the bottom of the filter list Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: frame the git sync path filters in a box with an empty hint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: use a multiselect for the git sync path filters Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: grey out the skipped connect step in git sync setup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: move the check job line to the bottom of the setup modal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hide the check job line once the repository check resolved Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: center the repository check loader in the setup modal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop the setup modal shifting when the check job id arrives Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: move the existing resource hint to the setup modal footer Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: always show the existing resource option when one exists Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: drop the card and collapse from the git sync filter settings Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: use a success alert for the pull from git notice Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: make initializing the repository a step of the git sync setup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: hide the push job lines once they succeed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: reword the push step intro Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: copy button on the cli snippet and a taller changes list Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: match the push step loading to the check step Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: stop the push step shifting when a job id arrives Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: let the changes list fill the push step Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: end the git sync setup on a done step instead of a modal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: delete a git sync repository from a row menu with a confirmation Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: lift the git sync done message off the notices Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: restore github app installation transfer between instances Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address regressions from the git sync setup rework Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the overlay z-index floor in step with the ai chat Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hold the push state in the setup dialog, not in the step it destroys Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: block every dismissal while setup work is in flight and keep sync reachable Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: regenerate the resource path when the repository changes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: configure the resource the connect step actually created Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a modal open when a drawer above it takes the click Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: snapshot the topmost surface for keyboard clicks too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: size the add repository buttons and explain git promotion Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * revert: drop the promotion-first action from the empty state Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: shrink the test connection result line Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: offer the fork sync and fork pull request toggles during setup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: group promotion repositories under their own label Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: offer the deploy branch pull request toggle during setup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: wait for the repository credential before saving the setup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: ignore a superseded credential load and drop the bindable default Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: key setup readiness to the repository it was loaded for Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: tie setup readiness to the attempt that asked for it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: link to where the access token is created Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: say plainly how each provider connects Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: sync resources, variables, schedules and triggers by default Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: use the accent colour for the token help link Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: move the CI/CD deploy hint next to the pull toggle Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep setup gated when the connection could not be read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: only explain pull-from-git where the workspace can turn it on Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: offer a save retry once the repository is initialized Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: say when pulling fell back to polling on the last setup step Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: clear the initialized flag when a new draft starts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
951 lines
28 KiB
TypeScript
951 lines
28 KiB
TypeScript
import { getContext, setContext } from 'svelte'
|
|
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
|
import { get } from 'svelte/store'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { apiErrorMessage } from '$lib/utils'
|
|
import { JobService, WorkspaceService, ResourceService } from '$lib/gen'
|
|
import type {
|
|
GitRepositorySettings as BackendGitRepositorySettings,
|
|
GitSyncObjectType
|
|
} from '$lib/gen'
|
|
import { jobManager } from '$lib/services/JobManager'
|
|
import hubPaths from '$lib/hubPaths.json'
|
|
import type { SettingsObject } from '$lib/git-sync'
|
|
|
|
export type GitSyncRepository = BackendGitRepositorySettings & {
|
|
settings: SettingsObject
|
|
exclude_types_override: GitSyncObjectType[]
|
|
legacyImported?: boolean
|
|
isUnsavedConnection?: boolean
|
|
collapsed?: boolean
|
|
// Repository detection state for new connections
|
|
detectionState?: 'idle' | 'loading' | 'no-wmill' | 'has-wmill' | 'error'
|
|
extractedSettings?: SettingsObject
|
|
detectionError?: string
|
|
// Job tracking for detection
|
|
detectionJobId?: string
|
|
detectionJobStatus?: 'running' | 'success' | 'failure'
|
|
// Internal tracking for resource path changes
|
|
_trackedPath?: string
|
|
// Cached target branch from git resource
|
|
_targetBranch?: string
|
|
// Detection timestamp to avoid race conditions
|
|
_detectionTimestamp?: number
|
|
}
|
|
|
|
export type GitSyncTestJob = {
|
|
jobId: string
|
|
status: 'running' | 'success' | 'failure' | undefined
|
|
error?: string
|
|
}
|
|
|
|
export type GitSyncSettings = {
|
|
repositories: GitSyncRepository[]
|
|
}
|
|
|
|
export type ModalState = {
|
|
push: { idx: number; repo: GitSyncRepository; open: boolean } | null
|
|
pull: { idx: number; repo: GitSyncRepository; open: boolean; settingsOnly?: boolean } | null
|
|
success: {
|
|
open: boolean
|
|
savedWithoutInit?: boolean
|
|
autoPullOn?: boolean
|
|
/** Why pulling fell back to polling, when it did. */
|
|
webhookError?: string
|
|
/** Whether this workspace is the one that would turn pulling on for this repository. */
|
|
ownsAutoPull?: boolean
|
|
} | null
|
|
}
|
|
|
|
export type ValidationState = {
|
|
isValid: boolean
|
|
isDuplicate: boolean
|
|
hasChanges: boolean
|
|
}
|
|
|
|
const GIT_SYNC_CONTEXT_KEY = Symbol('git-sync-context')
|
|
|
|
// Context implementation
|
|
export function createGitSyncContext(workspace: string) {
|
|
const repositories = $state<GitSyncRepository[]>([])
|
|
const initialRepositories = $state<GitSyncRepository[]>([])
|
|
const gitSyncTestJobs = $state<GitSyncTestJob[]>([])
|
|
let loading = $state(false)
|
|
const activeModals = $state<ModalState>({ push: null, pull: null, success: null })
|
|
|
|
// Legacy workspace-level settings state
|
|
const legacyWorkspaceIncludePath = $state<string[]>([])
|
|
const legacyWorkspaceIncludeType = $state<GitSyncObjectType[]>([])
|
|
|
|
// Derived state for legacy detection
|
|
const hasWorkspaceLevelSettings = $derived(
|
|
legacyWorkspaceIncludePath.length > 0 || legacyWorkspaceIncludeType.length > 0
|
|
)
|
|
|
|
// Watch for changes to git repository paths and reset detection state
|
|
$effect(() => {
|
|
repositories.forEach((repo) => {
|
|
if (repo.isUnsavedConnection) {
|
|
const currentPath = repo.git_repo_resource_path
|
|
|
|
if (repo._trackedPath && repo._trackedPath !== currentPath) {
|
|
// Clear cached branch when resource path changes
|
|
repo._targetBranch = undefined
|
|
|
|
// Reset detection state for any non-idle state (including errors)
|
|
if (repo.detectionState && repo.detectionState !== 'idle') {
|
|
// Cancel any running detection job by resetting immediately
|
|
_resetRepoDetectionState(repo)
|
|
}
|
|
}
|
|
|
|
repo._trackedPath = currentPath
|
|
}
|
|
})
|
|
})
|
|
|
|
const getValidationStates = () => {
|
|
return repositories.map((repo, idx) => ({
|
|
isValid: validateRepository(repo, idx),
|
|
isDuplicate: checkDuplicate(repo, idx),
|
|
hasChanges: checkChanges(repo, idx)
|
|
}))
|
|
}
|
|
|
|
const getHasAnyChanges = () => {
|
|
const validationStates = getValidationStates()
|
|
|
|
// Check if any individual repositories have changes
|
|
const individualChanges = validationStates.some((v) => v.hasChanges)
|
|
|
|
// Check if any legacy repos were imported
|
|
const anyLegacyImported = repositories.some((r) => r.legacyImported)
|
|
|
|
// Check if the set of repositories has changed (added/removed repos)
|
|
const repositorySetChanged = (() => {
|
|
if (loading) {
|
|
return false
|
|
}
|
|
|
|
if (!initialRepositories || initialRepositories.length === 0) {
|
|
return repositories.filter((_, i) => validationStates[i]?.isValid).length > 0
|
|
}
|
|
|
|
const initialValidPaths = new Set(
|
|
initialRepositories
|
|
.filter((r) => r.git_repo_resource_path && r.git_repo_resource_path.trim() !== '')
|
|
.map((r) => r.git_repo_resource_path)
|
|
)
|
|
const currentValidPaths = new Set(
|
|
repositories
|
|
.filter((_, i) => validationStates[i]?.isValid)
|
|
.map((r) => r.git_repo_resource_path)
|
|
)
|
|
|
|
// Check if sets are different (repos added or removed)
|
|
return (
|
|
initialValidPaths.size !== currentValidPaths.size ||
|
|
[...initialValidPaths].some((path) => !currentValidPaths.has(path)) ||
|
|
[...currentValidPaths].some((path) => !initialValidPaths.has(path))
|
|
)
|
|
})()
|
|
|
|
return individualChanges || anyLegacyImported || repositorySetChanged
|
|
}
|
|
|
|
const getAllRepositoriesValid = () => getValidationStates().every((v) => v.isValid)
|
|
const getHasUnsavedConnections = () => repositories.some((repo) => repo.isUnsavedConnection)
|
|
|
|
function validateRepository(repo: GitSyncRepository, idx: number): boolean {
|
|
if (!repo.git_repo_resource_path) return false
|
|
return !checkDuplicate(repo, idx)
|
|
}
|
|
|
|
function checkDuplicate(repo: GitSyncRepository, idx: number): boolean {
|
|
if (!repo.git_repo_resource_path) return false
|
|
const firstIdx = repositories.findIndex(
|
|
(r) => r.git_repo_resource_path === repo.git_repo_resource_path
|
|
)
|
|
return firstIdx !== -1 && firstIdx < idx
|
|
}
|
|
|
|
function checkChanges(repo: GitSyncRepository, idx: number): boolean {
|
|
const initial = initialRepositories[idx]
|
|
if (!initial) return true
|
|
|
|
// Legacy repositories always have "changes" because they need migration
|
|
if (repo.legacyImported) return true
|
|
|
|
return (
|
|
JSON.stringify(serializeRepository(repo)) !== JSON.stringify(serializeRepository(initial))
|
|
)
|
|
}
|
|
|
|
function serializeRepository(repo: GitSyncRepository) {
|
|
return {
|
|
git_repo_resource_path: repo.git_repo_resource_path,
|
|
script_path: repo.script_path,
|
|
use_individual_branch: repo.use_individual_branch,
|
|
group_by_folder: repo.group_by_folder,
|
|
settings: repo.settings,
|
|
exclude_types_override: repo.exclude_types_override,
|
|
auto_pull: repo.auto_pull,
|
|
promotion_open_prs: repo.promotion_open_prs,
|
|
fork_open_prs: repo.fork_open_prs
|
|
}
|
|
}
|
|
|
|
function addRepository() {
|
|
repositories.push({
|
|
git_repo_resource_path: '',
|
|
script_path: undefined,
|
|
use_individual_branch: false,
|
|
group_by_folder: false,
|
|
settings: {
|
|
include_path: ['f/**'],
|
|
exclude_path: [],
|
|
extra_include_path: [],
|
|
include_type: [
|
|
'script',
|
|
'flow',
|
|
'app',
|
|
'folder',
|
|
'resource',
|
|
'resourcetype',
|
|
'variable',
|
|
'schedule',
|
|
'trigger',
|
|
'workspacedependencies',
|
|
'datatablemigration'
|
|
]
|
|
},
|
|
exclude_types_override: [],
|
|
legacyImported: false,
|
|
isUnsavedConnection: true,
|
|
collapsed: false,
|
|
// New connections default to pulling changes from Git (webhook with a
|
|
// polling fallback), forks included. Existing repos load without
|
|
// auto_pull and stay off until an admin opts in, so upgrades never
|
|
// start auto-deploying.
|
|
auto_pull: { enabled: true, mode: 'auto', sync_forks: true },
|
|
fork_open_prs: true
|
|
})
|
|
gitSyncTestJobs.push({
|
|
jobId: '',
|
|
status: undefined
|
|
})
|
|
}
|
|
|
|
async function removeRepository(idx: number) {
|
|
const repo = repositories[idx]
|
|
if (!repo) return
|
|
|
|
// Check if this repository exists in the initial (saved) state
|
|
const existsInInitialState = initialRepositories.some(
|
|
(initialRepo) => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
|
|
)
|
|
|
|
// Only call backend API if repository exists in the saved state
|
|
if (existsInInitialState) {
|
|
await WorkspaceService.deleteGitSyncRepository({
|
|
workspace,
|
|
requestBody: {
|
|
git_repo_resource_path: `$res:${repo.git_repo_resource_path}`
|
|
}
|
|
})
|
|
|
|
// Update initial state to remove the deleted repository
|
|
const initialIdx = initialRepositories.findIndex(
|
|
(initialRepo) => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
|
|
)
|
|
if (initialIdx !== -1) {
|
|
initialRepositories.splice(initialIdx, 1)
|
|
}
|
|
}
|
|
|
|
// Remove from local state
|
|
repositories.splice(idx, 1)
|
|
gitSyncTestJobs.splice(idx, 1)
|
|
}
|
|
|
|
function getRepository(idx: number) {
|
|
return repositories[idx]
|
|
}
|
|
|
|
function showPushModal(idx: number) {
|
|
const repo = repositories[idx]
|
|
if (repo) {
|
|
activeModals.push = { idx, repo, open: true }
|
|
}
|
|
}
|
|
|
|
function showPullModal(idx: number, settingsOnly = false) {
|
|
const repo = repositories[idx]
|
|
if (repo) {
|
|
activeModals.pull = { idx, repo, open: true, settingsOnly }
|
|
}
|
|
}
|
|
|
|
function closeModal(type: 'push' | 'pull' | 'success') {
|
|
if (activeModals[type]) {
|
|
activeModals[type]!.open = false
|
|
}
|
|
setTimeout(() => {
|
|
activeModals[type] = null
|
|
}, 200)
|
|
}
|
|
|
|
function closePushModal() {
|
|
closeModal('push')
|
|
}
|
|
|
|
function closePullModal() {
|
|
closeModal('pull')
|
|
}
|
|
|
|
/** Only a sync repository in a non-fork workspace has a pull toggle of its own: promotion
|
|
* repositories have none, and a fork's pulling is its parent's to set. */
|
|
function ownsAutoPull(repo: GitSyncRepository): boolean {
|
|
const ws = get(workspaceStore)
|
|
const isFork =
|
|
(ws?.startsWith('wm-fork-') ?? false) ||
|
|
!!get(userWorkspaces)?.find((w) => w.id === ws)?.parent_workspace_id
|
|
return !repo.use_individual_branch && !isFork
|
|
}
|
|
|
|
function showSuccessModal(
|
|
savedWithoutInit?: boolean,
|
|
autoPullOn?: boolean,
|
|
ownsAutoPull?: boolean,
|
|
webhookError?: string
|
|
) {
|
|
activeModals.success = { open: true, savedWithoutInit, autoPullOn, ownsAutoPull, webhookError }
|
|
}
|
|
|
|
function closeSuccessModal() {
|
|
closeModal('success')
|
|
}
|
|
|
|
function getValidation(idx: number): ValidationState {
|
|
const states = getValidationStates()
|
|
return states[idx] || { isValid: false, isDuplicate: false, hasChanges: false }
|
|
}
|
|
|
|
async function detectRepository(idx: number) {
|
|
const repo = repositories[idx]
|
|
if (!repo || !repo.git_repo_resource_path) {
|
|
throw new Error('Repository not found or no resource path')
|
|
}
|
|
|
|
repo.detectionState = 'loading'
|
|
repo.detectionError = undefined
|
|
repo.detectionJobId = undefined
|
|
repo.detectionJobStatus = undefined
|
|
|
|
// Track the detection timestamp to avoid race conditions from old jobs. Stamped before
|
|
// the request, or a failure to even start the job reads as superseded and leaves the
|
|
// repository loading for good.
|
|
const detectionTimestamp = Date.now()
|
|
repo._detectionTimestamp = detectionTimestamp
|
|
|
|
try {
|
|
const jobId = await JobService.runScriptByPath({
|
|
workspace,
|
|
path: hubPaths.gitInitRepo,
|
|
requestBody: {
|
|
workspace_id: workspace,
|
|
repo_url_resource_path: repo.git_repo_resource_path,
|
|
dry_run: true,
|
|
pull: false,
|
|
only_wmill_yaml: true,
|
|
settings_json: JSON.stringify(repo.settings),
|
|
use_promotion_overrides: repo.use_individual_branch
|
|
},
|
|
skipPreprocessor: true
|
|
})
|
|
|
|
repo.detectionJobId = jobId
|
|
repo.detectionJobStatus = 'running'
|
|
|
|
// Use JobManager for polling - result will be the actual job response
|
|
await jobManager.runWithProgress(() => Promise.resolve(jobId), {
|
|
workspace,
|
|
timeout: 60000,
|
|
timeoutMessage: 'Detection job timed out after 60s',
|
|
onProgress: (status) => {
|
|
// Only update state if this detection is still current
|
|
if (repo._detectionTimestamp !== detectionTimestamp) {
|
|
return
|
|
}
|
|
|
|
repo.detectionJobStatus = status.status
|
|
|
|
// Process successful detection result
|
|
if (status.status === 'success' && status.result) {
|
|
const response = status.result as any
|
|
if (response.isInitialSetup) {
|
|
repo.detectionState = 'no-wmill'
|
|
} else {
|
|
repo.detectionState = 'has-wmill'
|
|
// Apply extracted settings from the git repository
|
|
if (response.local) {
|
|
repo.extractedSettings = response.local
|
|
// Auto-apply the extracted settings
|
|
repo.settings = {
|
|
...response.local,
|
|
exclude_path: response.local.exclude_path || [],
|
|
extra_include_path: response.local.extra_include_path || []
|
|
}
|
|
}
|
|
}
|
|
} else if (status.status === 'failure') {
|
|
repo.detectionState = 'error'
|
|
repo.detectionError = status.error || 'Detection failed'
|
|
}
|
|
}
|
|
})
|
|
} catch (error: any) {
|
|
// Only set error if this detection is still current
|
|
if (repo._detectionTimestamp !== detectionTimestamp) {
|
|
return
|
|
}
|
|
|
|
repo.detectionState = 'error'
|
|
repo.detectionError = apiErrorMessage(error) || 'Failed to detect repository'
|
|
repo.detectionJobStatus = 'failure'
|
|
}
|
|
}
|
|
|
|
// Settings management
|
|
async function loadSettings() {
|
|
loading = true
|
|
try {
|
|
const settings = await WorkspaceService.getSettings({ workspace })
|
|
|
|
if (settings.git_sync !== undefined && settings.git_sync !== null) {
|
|
// Detect workspace-level legacy settings (outside repositories)
|
|
const workspaceLegacyIncludePath: string[] = (settings.git_sync as any)?.include_path ?? []
|
|
const workspaceLegacyIncludeTypeRaw: GitSyncObjectType[] =
|
|
(settings.git_sync as any)?.include_type ?? []
|
|
const workspaceLegacyIncludeType: GitSyncObjectType[] = [...workspaceLegacyIncludeTypeRaw]
|
|
|
|
// Update legacy workspace state
|
|
legacyWorkspaceIncludePath.splice(
|
|
0,
|
|
legacyWorkspaceIncludePath.length,
|
|
...workspaceLegacyIncludePath
|
|
)
|
|
legacyWorkspaceIncludeType.splice(
|
|
0,
|
|
legacyWorkspaceIncludeType.length,
|
|
...workspaceLegacyIncludeType
|
|
)
|
|
|
|
if (settings.git_sync.repositories) {
|
|
repositories.splice(
|
|
0,
|
|
repositories.length,
|
|
...settings.git_sync.repositories.map((repo) => {
|
|
// Check if this is a legacy repo (no nested settings object)
|
|
const isRepoLegacy = !repo.settings
|
|
const repoExcludeTypesOverride = repo.exclude_types_override ?? []
|
|
|
|
// Determine default types - use workspace legacy or fallback
|
|
const defaultTypes: GitSyncObjectType[] =
|
|
workspaceLegacyIncludeType.length > 0
|
|
? [...workspaceLegacyIncludeType]
|
|
: [
|
|
'script',
|
|
'flow',
|
|
'app',
|
|
'folder',
|
|
'workspacedependencies',
|
|
'datatablemigration'
|
|
]
|
|
|
|
let repoSettings: SettingsObject
|
|
if (isRepoLegacy) {
|
|
// Legacy repo: inherit from workspace-level settings and apply exclude_types_override
|
|
const inheritedIncludeType = repo.settings?.include_type ?? [...defaultTypes]
|
|
const effectiveIncludeType =
|
|
repoExcludeTypesOverride.length > 0
|
|
? inheritedIncludeType.filter(
|
|
(type) => !repoExcludeTypesOverride.includes(type)
|
|
)
|
|
: inheritedIncludeType
|
|
|
|
repoSettings = {
|
|
include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath],
|
|
exclude_path: repo.settings?.exclude_path ?? [],
|
|
extra_include_path: repo.settings?.extra_include_path ?? [],
|
|
include_type: effectiveIncludeType
|
|
}
|
|
} else {
|
|
// New format: use repo's own settings
|
|
repoSettings = {
|
|
include_path: repo.settings?.include_path ?? ['f/**'],
|
|
exclude_path: repo.settings?.exclude_path ?? [],
|
|
extra_include_path: repo.settings?.extra_include_path ?? [],
|
|
include_type: repo.settings?.include_type ?? [
|
|
'script',
|
|
'flow',
|
|
'app',
|
|
'folder',
|
|
'workspacedependencies',
|
|
'datatablemigration'
|
|
]
|
|
}
|
|
}
|
|
|
|
return {
|
|
...repo,
|
|
git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''),
|
|
settings: repoSettings,
|
|
exclude_types_override: repoExcludeTypesOverride,
|
|
// Mark legacy repos for UI handling
|
|
legacyImported: isRepoLegacy
|
|
}
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
// Store initial state for change tracking
|
|
initialRepositories.splice(
|
|
0,
|
|
initialRepositories.length,
|
|
...repositories.map((repo) => ({ ...repo }))
|
|
)
|
|
} finally {
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
// Migration utility for legacy repositories
|
|
function migrateLegacyRepository(repo: GitSyncRepository): GitSyncRepository {
|
|
if (!repo.legacyImported) {
|
|
return repo // Already migrated or not legacy
|
|
}
|
|
|
|
// Create migrated repository - exclude_types_override should already be applied in settings.include_type
|
|
// from the loadSettings logic, so we just need to clear the override and mark as migrated
|
|
return {
|
|
...repo,
|
|
exclude_types_override: [], // Clear the override since it's now integrated into include_type
|
|
legacyImported: false // Mark as migrated
|
|
}
|
|
}
|
|
|
|
/** `announce` off for a caller that reports the save itself — the setup dialog ends on a
|
|
* step saying the same thing, and a modal over it would say it twice. */
|
|
async function saveRepository(idx: number, savedWithoutInit = false, announce = true) {
|
|
const repo = repositories[idx]
|
|
if (!repo || !validateRepository(repo, idx)) {
|
|
throw new Error('Cannot save invalid repository')
|
|
}
|
|
|
|
// Migrate legacy repository if needed
|
|
const repoToSave = repo.legacyImported ? migrateLegacyRepository(repo) : repo
|
|
|
|
// Use the new individual repository API instead of saving all repositories
|
|
await WorkspaceService.editGitSyncRepository({
|
|
workspace,
|
|
requestBody: {
|
|
git_repo_resource_path: `$res:${repoToSave.git_repo_resource_path}`,
|
|
repository: {
|
|
git_repo_resource_path: `$res:${repoToSave.git_repo_resource_path}`,
|
|
script_path: repoToSave.script_path,
|
|
use_individual_branch: repoToSave.use_individual_branch,
|
|
group_by_folder: repoToSave.group_by_folder,
|
|
settings: repoToSave.settings,
|
|
exclude_types_override: repoToSave.exclude_types_override,
|
|
auto_pull: repoToSave.auto_pull,
|
|
promotion_open_prs: repoToSave.promotion_open_prs,
|
|
fork_open_prs: repoToSave.fork_open_prs
|
|
}
|
|
}
|
|
})
|
|
|
|
// The server stamps the saving admin as who pulls run as; mirror it so the card
|
|
// names them without a reload.
|
|
if (repoToSave.auto_pull) {
|
|
repoToSave.auto_pull = {
|
|
...repoToSave.auto_pull,
|
|
enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined,
|
|
webhook_error: await readWebhookError(repoToSave)
|
|
}
|
|
}
|
|
|
|
// Update local state with migrated repository
|
|
repositories[idx] = repoToSave
|
|
initialRepositories[idx] = { ...repoToSave }
|
|
|
|
// Update local state
|
|
if (repoToSave.isUnsavedConnection) {
|
|
repoToSave.isUnsavedConnection = false
|
|
repoToSave.detectionState = undefined
|
|
repoToSave.extractedSettings = undefined
|
|
// Show success modal for new connections
|
|
if (announce) {
|
|
showSuccessModal(
|
|
savedWithoutInit,
|
|
repoToSave.auto_pull?.enabled === true,
|
|
ownsAutoPull(repoToSave),
|
|
repoToSave.auto_pull?.webhook_error
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The webhook is registered after the settings commit, best-effort, and a failure is
|
|
* reported only through the stored settings — so it stays invisible until they are read
|
|
* back. Without this, pulling reads as working when it has silently fallen back to
|
|
* polling (an app missing the webhook permission, an instance GitHub cannot reach). */
|
|
async function readWebhookError(repo: GitSyncRepository): Promise<string | undefined> {
|
|
if (!repo.auto_pull?.enabled) return undefined
|
|
try {
|
|
const settings = await WorkspaceService.getSettings({ workspace })
|
|
return settings.git_sync?.repositories?.find(
|
|
(r) => r.git_repo_resource_path.replace('$res:', '') === repo.git_repo_resource_path
|
|
)?.auto_pull?.webhook_error
|
|
} catch {
|
|
// Only costs the warning; the card shows it on the next load.
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// Helper functions for original functionality
|
|
|
|
function revertRepository(idx: number) {
|
|
const initial = initialRepositories[idx]
|
|
if (initial) {
|
|
repositories[idx] = JSON.parse(JSON.stringify(initial))
|
|
}
|
|
}
|
|
|
|
// Reset detection state for a repository
|
|
function resetDetectionState(idx: number) {
|
|
const repo = repositories[idx]
|
|
if (!repo || !repo.isUnsavedConnection) return
|
|
|
|
_resetRepoDetectionState(repo)
|
|
}
|
|
|
|
// Helper function to reset detection state on a repository object
|
|
function _resetRepoDetectionState(repo: GitSyncRepository) {
|
|
repo.detectionState = 'idle'
|
|
repo.extractedSettings = undefined
|
|
repo.detectionError = undefined
|
|
repo.detectionJobId = undefined
|
|
repo.detectionJobStatus = undefined
|
|
}
|
|
|
|
async function runTestJob(idx: number) {
|
|
const repo = repositories[idx]
|
|
if (!repo?.git_repo_resource_path) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const jobId = await JobService.runScriptByPath({
|
|
workspace,
|
|
path: hubPaths.gitSyncTest,
|
|
requestBody: {
|
|
repo_url_resource_path: repo.git_repo_resource_path,
|
|
init: repo.isUnsavedConnection || false
|
|
},
|
|
skipPreprocessor: true
|
|
})
|
|
|
|
gitSyncTestJobs[idx] = {
|
|
jobId: jobId,
|
|
status: 'running'
|
|
}
|
|
|
|
// Use JobManager for polling
|
|
await jobManager.runWithProgress(() => Promise.resolve(jobId), {
|
|
workspace,
|
|
timeout: 10000,
|
|
timeoutMessage: 'Git sync test job timed out after 10s',
|
|
onProgress: (status) => {
|
|
gitSyncTestJobs[idx].status =
|
|
status.status === 'success'
|
|
? 'success'
|
|
: status.status === 'failure'
|
|
? 'failure'
|
|
: 'running'
|
|
if (status.status === 'failure') {
|
|
gitSyncTestJobs[idx].error = status.error
|
|
}
|
|
}
|
|
})
|
|
} catch (error: any) {
|
|
// Initialize the job entry if it doesn't exist (e.g., job creation failed)
|
|
const errorMessage = apiErrorMessage(error) || 'Failed to run test job'
|
|
if (!gitSyncTestJobs[idx]) {
|
|
gitSyncTestJobs[idx] = {
|
|
jobId: '',
|
|
status: 'failure',
|
|
error: errorMessage
|
|
}
|
|
} else {
|
|
gitSyncTestJobs[idx].status = 'failure'
|
|
gitSyncTestJobs[idx].error = errorMessage
|
|
}
|
|
}
|
|
}
|
|
|
|
function getPrimarySyncRepository(): { repo: GitSyncRepository; idx: number } | null {
|
|
const idx = repositories.findIndex((r) => !r.use_individual_branch)
|
|
return idx !== -1 ? { repo: repositories[idx], idx } : null
|
|
}
|
|
|
|
function getPrimaryPromotionRepository(): { repo: GitSyncRepository; idx: number } | null {
|
|
const idx = repositories.findIndex((r) => r.use_individual_branch)
|
|
return idx !== -1 ? { repo: repositories[idx], idx } : null
|
|
}
|
|
|
|
function getSecondarySyncRepositories(): { repo: GitSyncRepository; idx: number }[] {
|
|
const result: { repo: GitSyncRepository; idx: number }[] = []
|
|
let foundFirst = false
|
|
repositories.forEach((repo, idx) => {
|
|
if (!repo.use_individual_branch) {
|
|
if (foundFirst) {
|
|
result.push({ repo, idx })
|
|
} else {
|
|
foundFirst = true
|
|
}
|
|
}
|
|
})
|
|
return result
|
|
}
|
|
|
|
function getSecondaryPromotionRepositories(): { repo: GitSyncRepository; idx: number }[] {
|
|
const result: { repo: GitSyncRepository; idx: number }[] = []
|
|
let foundFirst = false
|
|
repositories.forEach((repo, idx) => {
|
|
if (repo.use_individual_branch) {
|
|
if (foundFirst) {
|
|
result.push({ repo, idx })
|
|
} else {
|
|
foundFirst = true
|
|
}
|
|
}
|
|
})
|
|
return result
|
|
}
|
|
|
|
async function removeRepositoryByPath(resourcePath: string) {
|
|
const idx = repositories.findIndex((r) => r.git_repo_resource_path === resourcePath)
|
|
if (idx !== -1) {
|
|
await removeRepository(idx)
|
|
}
|
|
}
|
|
|
|
function addSyncRepository() {
|
|
if (!get(enterpriseLicense) && repositories && repositories.length >= 1) {
|
|
sendUserToast('Multiple repositories requires Enterprise Edition', true)
|
|
return
|
|
}
|
|
repositories.push({
|
|
git_repo_resource_path: '',
|
|
script_path: undefined,
|
|
use_individual_branch: false,
|
|
group_by_folder: false,
|
|
settings: {
|
|
include_path: ['f/**'],
|
|
exclude_path: [],
|
|
extra_include_path: [],
|
|
include_type: [
|
|
'script',
|
|
'flow',
|
|
'app',
|
|
'folder',
|
|
'resource',
|
|
'resourcetype',
|
|
'variable',
|
|
'schedule',
|
|
'trigger',
|
|
'workspacedependencies',
|
|
'datatablemigration'
|
|
]
|
|
},
|
|
exclude_types_override: [],
|
|
legacyImported: false,
|
|
isUnsavedConnection: true,
|
|
collapsed: false
|
|
// Pull-from-Git defaults are applied once the selected resource resolves, by
|
|
// whoever is configuring the connection (see applyNewConnectionDefaults): only
|
|
// app-backed repos (instant webhook delivery) default to auto-pull on; polling is
|
|
// opt-in for token repos.
|
|
})
|
|
gitSyncTestJobs.push({
|
|
jobId: '',
|
|
status: undefined
|
|
})
|
|
}
|
|
|
|
function addPromotionRepository() {
|
|
if (!get(enterpriseLicense)) {
|
|
sendUserToast('Promotion mode requires Enterprise Edition', true)
|
|
return
|
|
}
|
|
repositories.push({
|
|
git_repo_resource_path: '',
|
|
script_path: undefined,
|
|
use_individual_branch: true,
|
|
group_by_folder: false,
|
|
settings: {
|
|
include_path: ['f/**'],
|
|
exclude_path: [],
|
|
extra_include_path: [],
|
|
include_type: [
|
|
'script',
|
|
'flow',
|
|
'app',
|
|
'folder',
|
|
'resource',
|
|
'resourcetype',
|
|
'variable',
|
|
'schedule',
|
|
'trigger',
|
|
'workspacedependencies',
|
|
'datatablemigration'
|
|
]
|
|
},
|
|
exclude_types_override: [],
|
|
legacyImported: false,
|
|
isUnsavedConnection: true,
|
|
collapsed: false,
|
|
// New promotion repos default to opening the PR in-app when a deploy
|
|
// pushes its wm_deploy/** branch (app-backed repos only at runtime).
|
|
promotion_open_prs: true
|
|
})
|
|
gitSyncTestJobs.push({
|
|
jobId: '',
|
|
status: undefined
|
|
})
|
|
}
|
|
|
|
// Helper to get target branch from git resource
|
|
async function getTargetBranch(repo: GitSyncRepository): Promise<string | undefined> {
|
|
if (!repo.git_repo_resource_path) {
|
|
return undefined
|
|
}
|
|
|
|
if (repo._targetBranch) {
|
|
if (repo._targetBranch === '') return undefined
|
|
return repo._targetBranch
|
|
}
|
|
|
|
try {
|
|
const resource = await ResourceService.getResource({
|
|
workspace,
|
|
path: repo.git_repo_resource_path
|
|
})
|
|
|
|
// Extract branch from git resource value
|
|
const resourceValue = resource.value as any
|
|
const targetBranch = resourceValue?.branch
|
|
|
|
// Cache the result
|
|
repo._targetBranch = targetBranch
|
|
if (targetBranch === '') return undefined
|
|
return targetBranch
|
|
} catch (error) {
|
|
console.warn('Failed to fetch git resource for branch info:', error)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// Return context object
|
|
return {
|
|
// State (read-only access)
|
|
get repositories() {
|
|
return repositories
|
|
},
|
|
get loading() {
|
|
return loading
|
|
},
|
|
get activeModals() {
|
|
return activeModals
|
|
},
|
|
get gitSyncTestJobs() {
|
|
return gitSyncTestJobs
|
|
},
|
|
get initialRepositories() {
|
|
return initialRepositories
|
|
},
|
|
get legacyWorkspaceIncludePath() {
|
|
return legacyWorkspaceIncludePath
|
|
},
|
|
get legacyWorkspaceIncludeType() {
|
|
return legacyWorkspaceIncludeType
|
|
},
|
|
|
|
// Computed states - use getter functions that compute on access
|
|
get validationStates() {
|
|
return getValidationStates()
|
|
},
|
|
get hasAnyChanges() {
|
|
return getHasAnyChanges()
|
|
},
|
|
get allRepositoriesValid() {
|
|
return getAllRepositoriesValid()
|
|
},
|
|
get hasUnsavedConnections() {
|
|
return getHasUnsavedConnections()
|
|
},
|
|
get hasWorkspaceLevelSettings() {
|
|
return hasWorkspaceLevelSettings
|
|
},
|
|
|
|
// Methods
|
|
addRepository,
|
|
addSyncRepository,
|
|
addPromotionRepository,
|
|
removeRepository,
|
|
removeRepositoryByPath,
|
|
getRepository,
|
|
getValidation,
|
|
revertRepository,
|
|
runTestJob,
|
|
resetDetectionState,
|
|
detectRepository,
|
|
migrateLegacyRepository,
|
|
showPushModal,
|
|
showPullModal,
|
|
closePushModal,
|
|
closePullModal,
|
|
showSuccessModal,
|
|
closeSuccessModal,
|
|
loadSettings,
|
|
saveRepository,
|
|
|
|
// Repository categorization methods
|
|
getPrimarySyncRepository,
|
|
getPrimaryPromotionRepository,
|
|
getSecondarySyncRepositories,
|
|
getSecondaryPromotionRepositories,
|
|
|
|
// Helper methods
|
|
getTargetBranch
|
|
}
|
|
}
|
|
|
|
export type GitSyncContextType = ReturnType<typeof createGitSyncContext>
|
|
|
|
export function setGitSyncContext(workspace: string): GitSyncContextType {
|
|
const context = createGitSyncContext(workspace)
|
|
setContext(GIT_SYNC_CONTEXT_KEY, context)
|
|
return context
|
|
}
|
|
|
|
export function getGitSyncContext(): GitSyncContextType {
|
|
const context = getContext<GitSyncContextType>(GIT_SYNC_CONTEXT_KEY)
|
|
if (!context) {
|
|
throw new Error('Git sync context not found. Make sure to call setGitSyncContext first.')
|
|
}
|
|
return context
|
|
}
|