// #region GitUtils [C:3] [TYPE Module] [SEMANTICS git, utils, helper, normalize, parse, status] // @BRIEF Shared utility functions extracted from GitManager.svelte — status resolution, env/branch defaults, merge context parsing. /** * Normalize environment stage with legacy fallback. * Returns DEV/PREPROD/PROD. */ export function normalizeEnvStage(env: Record | null | undefined): string { if (env?.is_production) return 'PROD'; const stage = String(env?.stage || '').trim().toUpperCase(); if (stage === 'PROD' || stage === 'PREPROD') return stage; return 'DEV'; } /** * Resolve GitFlow stage from an environment branch name. */ export function resolveGitflowStageFromBranch(branch: string | null | undefined): string { const normalizedBranch = String(branch || '').trim().toLowerCase(); if (normalizedBranch === 'dev') return 'DEV'; if (normalizedBranch === 'preprod') return 'PREPROD'; if (normalizedBranch === 'prod') return 'PROD'; return ''; } /** * Return visual class for environment stage badges. */ export function stageBadgeClass(stage: string): string { if (stage === 'PROD') return 'bg-indigo-100 text-indigo-800 border-indigo-200'; if (stage === 'PREPROD') return 'bg-amber-100 text-amber-800 border-amber-200'; return 'bg-blue-100 text-blue-800 border-blue-200'; } /** * Resolve active environment id for current dashboard session. */ export function resolveCurrentEnvironmentId(envId: string | null | undefined): string | null { if (envId) return String(envId); if (typeof window === 'undefined') return null; return localStorage.getItem('selected_env_id'); } /** * Apply GitFlow defaults by current environment stage. */ export function applyGitflowStageDefaults(stage: string): { promoteFromBranch: string; promoteToBranch: string; preferredDeployTargetStage: string; } { const normalizedStage = String(stage || '').toUpperCase(); if (normalizedStage === 'DEV') { return { promoteFromBranch: 'dev', promoteToBranch: 'preprod', preferredDeployTargetStage: 'PREPROD' }; } if (normalizedStage === 'PREPROD') { return { promoteFromBranch: 'preprod', promoteToBranch: 'prod', preferredDeployTargetStage: 'PROD' }; } return { promoteFromBranch: 'prod', promoteToBranch: 'prod', preferredDeployTargetStage: '' }; } /** * Checks whether current dashboard reference is numeric ID. */ export function isNumericDashboardRef(dashboardId: string | number | null | undefined): boolean { return /^\d+$/.test(String(dashboardId || '').trim()); } /** * Return currently selected git server config. */ export function getSelectedConfig(configs: T[], selectedConfigId: string | null | undefined): T | null { return configs.find((item) => item.id === selectedConfigId) || null; } /** * Resolve default git config for current session. */ export function resolveDefaultConfig( configList: T[], selectedConfigId: string | null | undefined ): T | null { if (!Array.isArray(configList) || configList.length === 0) return null; const selected = configList.find((item) => item.id === selectedConfigId); if (selected) return selected; const explicitDefault = configList.find((item) => item?.is_default); if (explicitDefault) return explicitDefault; const connected = configList.find((item) => item?.status === 'CONNECTED'); return connected || configList[0]; } /** * Resolve lower-case provider label for auto-push checkbox. */ export function resolvePushProviderLabel( configs: T[], selectedConfigId: string | null | undefined, repositoryProvider: string ): string { const config = getSelectedConfig(configs, selectedConfigId) || resolveDefaultConfig(configs, selectedConfigId); const provider = String(config?.provider || repositoryProvider || '').trim().toLowerCase(); return provider || 'git'; } /** * Extract comparable host[:port] from URL string. */ export function extractHttpHost(urlValue: string | null | undefined): string { const normalized = String(urlValue || '').trim(); if (!normalized) return ''; try { const parsed = new URL(normalized); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; return String(parsed.host || '').toLowerCase(); } catch (_e) { return ''; } } /** * Build deterministic repository name from dashboard title/id. */ export function buildSuggestedRepoName(dashboardTitle: string, dashboardId: string | number): string { const source = (dashboardTitle || `dashboard-${dashboardId}`).toLowerCase(); const slug = source .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48); return `${slug || 'dashboard'}-${dashboardId}`; } /** * Attempt to parse a JSON object from a string. */ export function tryParseJsonObject(value: string): Record | null { const source = String(value || '').trim(); if (!source.startsWith('{') || !source.endsWith('}')) return null; try { const parsed = JSON.parse(source); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null; } catch (_e) { return null; } } /** * Extract unfinished merge context from a 409 error. */ export function extractUnfinishedMergeContext(error: Record | null | undefined): { message: string; repositoryPath: string; gitDir: string; currentBranch: string; mergeHead: string; mergeMessagePreview: string; nextSteps: string[]; commands: string[]; } | null { if (!error || Number(error?.status) !== 409) return null; const parsedMessage = tryParseJsonObject(error?.message as string); const detail = error?.detail && typeof error.detail === 'object' ? error.detail as Record : null; const payload = detail || parsedMessage; if (!payload || payload.error_code !== 'GIT_UNFINISHED_MERGE') return null; const commands = Array.isArray(payload.manual_commands) ? (payload.manual_commands as unknown[]).filter(Boolean).map((item) => String(item)) : []; const nextSteps = Array.isArray(payload.next_steps) ? (payload.next_steps as unknown[]).filter(Boolean).map((item) => String(item)) : []; return { message: String(payload.message || ''), repositoryPath: String(payload.repository_path || ''), gitDir: String(payload.git_dir || ''), currentBranch: String(payload.current_branch || ''), mergeHead: String(payload.merge_head || ''), mergeMessagePreview: String(payload.merge_message_preview || ''), nextSteps, commands, }; } /** * Resolve a git status object into a UI status token. * Shared by RepositoryDashboardGrid (batch status) and GitManagerModel (single status) * to guarantee identical token mapping across grid and modal. * * Token set: loading | no_repo | synced | changes | behind_remote | ahead_remote | diverged | error * * @RATIONALE Previously two independent resolvers existed (grid vs model), causing * the grid to show "Синхронизирован" while the modal showed "не привязан" for the * same dashboard. Single source of truth eliminates the drift. */ export function resolveGitStatusToken(status: Record | null | undefined): string { if (!status) return 'error'; const syncState = String(status?.sync_state || '').toUpperCase(); if (syncState === 'DIVERGED') return 'diverged'; if (syncState === 'BEHIND_REMOTE') return 'behind_remote'; if (syncState === 'AHEAD_REMOTE') return 'ahead_remote'; if (syncState === 'CHANGES') return 'changes'; if (syncState === 'SYNCED') return 'synced'; const syncStatus = String(status?.sync_status || '').toUpperCase(); if (syncStatus === 'NO_REPO') return 'no_repo'; if (syncStatus === 'ERROR') return 'error'; if (syncStatus === 'DIFF') return 'changes'; if (syncStatus === 'OK') return 'synced'; const aheadCount = Number(status?.ahead_count || 0); const behindCount = Number(status?.behind_count || 0); if (aheadCount > 0 && behindCount > 0) return 'diverged'; if (behindCount > 0) return 'behind_remote'; if (aheadCount > 0) return 'ahead_remote'; const hasChanges = Boolean(status?.is_dirty) || (Array.isArray(status?.untracked_files) && (status.untracked_files as unknown[]).length > 0) || (Array.isArray(status?.modified_files) && (status.modified_files as unknown[]).length > 0) || (Array.isArray(status?.staged_files) && (status.staged_files as unknown[]).length > 0); return hasChanges ? 'changes' : 'synced'; } // #endregion GitUtils