Files
ss-tools/frontend/src/services/git-utils.ts
busya 8c10632494 feat(semantic): curator-driven protocol hardening — decision memory + relation repair
- Add @RATIONALE/@REJECTED to 103+ C4/C5 contracts across backend core, services, API routes, and frontend models
- Fix 109 unresolved @RELATION edges (Auth.*, SupersetClient.*, AgentChat.*, ADR cross-refs)
- Add 13 @ingroup tags for DSA/HCA attention grouping
- Repair 29 stale graph edges via index rebuild
- Update .kilo agent prompts and skills for GRACE-Poly v2.6 compliance
- Git integration: merge routes, branch lifecycle, remote providers, UX components
- 0 broken anchor pairs, index rebuilt with 0 parse warnings
2026-07-02 08:53:19 +03:00

219 lines
8.8 KiB
TypeScript

// #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<string, unknown> | 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<T extends { id: string }>(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<T extends { id: string; is_default?: boolean; status?: string }>(
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<T extends { id: string; provider?: string; is_default?: boolean; status?: string }>(
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<string, unknown> | 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<string, unknown> : null;
} catch (_e) {
return null;
}
}
/**
* Extract unfinished merge context from a 409 error.
*/
export function extractUnfinishedMergeContext(error: Record<string, unknown> | 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<string, unknown> : 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<string, unknown> | 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