- 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
219 lines
8.8 KiB
TypeScript
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
|