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([]) const initialRepositories = $state([]) const gitSyncTestJobs = $state([]) let loading = $state(false) const activeModals = $state({ push: null, pull: null, success: null }) // Legacy workspace-level settings state const legacyWorkspaceIncludePath = $state([]) const legacyWorkspaceIncludeType = $state([]) // 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 { 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 { 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 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(GIT_SYNC_CONTEXT_KEY) if (!context) { throw new Error('Git sync context not found. Make sure to call setGitSyncContext first.') } return context }