feat: automatically align Git repository origin host with configured server URL to prevent mismatches

This commit is contained in:
2026-03-08 11:28:00 +03:00
parent 603256eeaf
commit c7e9b5b6c5
6 changed files with 261 additions and 6 deletions

View File

@@ -27,7 +27,7 @@ from pathlib import Path
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
from src.core.logger import logger, belief_scope from src.core.logger import logger, belief_scope
from src.models.git import GitProvider from src.models.git import GitProvider
from src.models.git import GitRepository from src.models.git import GitRepository, GitServerConfig
from src.models.config import AppConfigRecord from src.models.config import AppConfigRecord
from src.core.database import SessionLocal from src.core.database import SessionLocal
@@ -569,6 +569,154 @@ class GitService:
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}") logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
# [/DEF:commit_changes:Function] # [/DEF:commit_changes:Function]
# [DEF:_extract_http_host:Function]
# @PURPOSE: Extract normalized host[:port] from HTTP(S) URL.
# @PRE: url_value may be empty.
# @POST: Returns lowercase host token or None.
# @RETURN: Optional[str]
def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]:
normalized = str(url_value or "").strip()
if not normalized:
return None
try:
parsed = urlparse(normalized)
except Exception:
return None
if parsed.scheme not in {"http", "https"}:
return None
host = parsed.hostname
if not host:
return None
if parsed.port:
return f"{host.lower()}:{parsed.port}"
return host.lower()
# [/DEF:_extract_http_host:Function]
# [DEF:_strip_url_credentials:Function]
# @PURPOSE: Remove credentials from URL while preserving scheme/host/path.
# @PRE: url_value may contain credentials.
# @POST: Returns URL without username/password.
# @RETURN: str
def _strip_url_credentials(self, url_value: str) -> str:
normalized = str(url_value or "").strip()
if not normalized:
return normalized
try:
parsed = urlparse(normalized)
except Exception:
return normalized
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
return normalized
host = parsed.hostname
if parsed.port:
host = f"{host}:{parsed.port}"
return parsed._replace(netloc=host).geturl()
# [/DEF:_strip_url_credentials:Function]
# [DEF:_replace_host_in_url:Function]
# @PURPOSE: Replace source URL host with host from configured server URL.
# @PRE: source_url and config_url are HTTP(S) URLs.
# @POST: Returns source URL with updated host (credentials preserved) or None.
# @RETURN: Optional[str]
def _replace_host_in_url(self, source_url: Optional[str], config_url: Optional[str]) -> Optional[str]:
source = str(source_url or "").strip()
config = str(config_url or "").strip()
if not source or not config:
return None
try:
source_parsed = urlparse(source)
config_parsed = urlparse(config)
except Exception:
return None
if source_parsed.scheme not in {"http", "https"}:
return None
if config_parsed.scheme not in {"http", "https"}:
return None
if not source_parsed.hostname or not config_parsed.hostname:
return None
target_host = config_parsed.hostname
if config_parsed.port:
target_host = f"{target_host}:{config_parsed.port}"
auth_part = ""
if source_parsed.username:
auth_part = quote(source_parsed.username, safe="")
if source_parsed.password is not None:
auth_part = f"{auth_part}:{quote(source_parsed.password, safe='')}"
auth_part = f"{auth_part}@"
new_netloc = f"{auth_part}{target_host}"
return source_parsed._replace(netloc=new_netloc).geturl()
# [/DEF:_replace_host_in_url:Function]
# [DEF:_align_origin_host_with_config:Function]
# @PURPOSE: Auto-align local origin host to configured Git server host when they drift.
# @PRE: origin remote exists.
# @POST: origin URL host updated and DB binding normalized when mismatch detected.
# @RETURN: Optional[str]
def _align_origin_host_with_config(
self,
dashboard_id: int,
origin,
config_url: Optional[str],
current_origin_url: Optional[str],
binding_remote_url: Optional[str],
) -> Optional[str]:
config_host = self._extract_http_host(config_url)
source_origin_url = str(current_origin_url or "").strip() or str(binding_remote_url or "").strip()
origin_host = self._extract_http_host(source_origin_url)
if not config_host or not origin_host:
return None
if config_host == origin_host:
return None
aligned_url = self._replace_host_in_url(source_origin_url, config_url)
if not aligned_url:
return None
logger.warning(
"[_align_origin_host_with_config][Action] Host mismatch for dashboard %s: config_host=%s origin_host=%s, applying origin.set_url",
dashboard_id,
config_host,
origin_host,
)
try:
origin.set_url(aligned_url)
except Exception as e:
logger.warning(
"[_align_origin_host_with_config][Coherence:Failed] Failed to set origin URL for dashboard %s: %s",
dashboard_id,
e,
)
return None
try:
session = SessionLocal()
try:
db_repo = (
session.query(GitRepository)
.filter(GitRepository.dashboard_id == int(dashboard_id))
.first()
)
if db_repo:
db_repo.remote_url = self._strip_url_credentials(aligned_url)
session.commit()
finally:
session.close()
except Exception as e:
logger.warning(
"[_align_origin_host_with_config][Action] Failed to persist aligned remote_url for dashboard %s: %s",
dashboard_id,
e,
)
return aligned_url
# [/DEF:_align_origin_host_with_config:Function]
# [DEF:push_changes:Function] # [DEF:push_changes:Function]
# @PURPOSE: Push local commits to remote. # @PURPOSE: Push local commits to remote.
# @PRE: Repository exists and has an 'origin' remote. # @PRE: Repository exists and has an 'origin' remote.
@@ -588,6 +736,63 @@ class GitService:
logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}") logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured") raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
# Emit diagnostic context to verify config-url vs repository-origin mismatch.
try:
origin_urls = list(origin.urls)
except Exception:
origin_urls = []
binding_remote_url = None
binding_config_id = None
binding_config_url = None
try:
session = SessionLocal()
try:
db_repo = (
session.query(GitRepository)
.filter(GitRepository.dashboard_id == int(dashboard_id))
.first()
)
if db_repo:
binding_remote_url = db_repo.remote_url
binding_config_id = db_repo.config_id
db_config = (
session.query(GitServerConfig)
.filter(GitServerConfig.id == db_repo.config_id)
.first()
)
if db_config:
binding_config_url = db_config.url
finally:
session.close()
except Exception as diag_error:
logger.warning(
"[push_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s",
dashboard_id,
diag_error,
)
realigned_origin_url = self._align_origin_host_with_config(
dashboard_id=dashboard_id,
origin=origin,
config_url=binding_config_url,
current_origin_url=(origin_urls[0] if origin_urls else None),
binding_remote_url=binding_remote_url,
)
try:
origin_urls = list(origin.urls)
except Exception:
origin_urls = []
logger.info(
"[push_changes][Action] Push diagnostics dashboard=%s config_id=%s config_url=%s binding_remote_url=%s origin_urls=%s origin_realigned=%s",
dashboard_id,
binding_config_id,
binding_config_url,
binding_remote_url,
origin_urls,
bool(realigned_origin_url),
)
# Check if current branch has an upstream # Check if current branch has an upstream
try: try:
current_branch = repo.active_branch current_branch = repo.active_branch

View File

@@ -22,7 +22,7 @@
let { let {
dashboardId, dashboardId,
envId = null, envId = null,
currentBranch = 'main', currentBranch = $bindable('main'),
} = $props(); } = $props();
// [/SECTION] // [/SECTION]

View File

@@ -19,7 +19,7 @@
// [/SECTION] // [/SECTION]
// [SECTION: PROPS] // [SECTION: PROPS]
let { dashboardId, envId = null, show = false, preferredTargetStage = "" } = $props(); let { dashboardId, envId = null, show = $bindable(false), preferredTargetStage = "" } = $props();
// [/SECTION] // [/SECTION]

View File

@@ -61,6 +61,8 @@
let isPushing = $state(false); let isPushing = $state(false);
let autoPushAfterCommit = $state(true); let autoPushAfterCommit = $state(true);
let repositoryProvider = $state(''); let repositoryProvider = $state('');
let repositoryBindingRemoteUrl = $state('');
let repositoryConfigUrl = $state('');
// [/SECTION] // [/SECTION]
const hasWorkspaceChanges = $derived.by(() => { const hasWorkspaceChanges = $derived.by(() => {
@@ -441,6 +443,24 @@
} }
// [/DEF:resolvePushProviderLabel:Function] // [/DEF:resolvePushProviderLabel:Function]
// [DEF:extractHttpHost:Function]
/**
* @purpose Extract comparable host[:port] from URL string.
* @post Returns lowercase host token or empty string.
*/
function extractHttpHost(urlValue) {
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 '';
}
}
// [/DEF:extractHttpHost:Function]
// [DEF:buildSuggestedRepoName:Function] // [DEF:buildSuggestedRepoName:Function]
/** /**
* @purpose Build deterministic repository name from dashboard title/id. * @purpose Build deterministic repository name from dashboard title/id.
@@ -544,6 +564,18 @@
} }
// [/DEF:handleBackdropClick:Function] // [/DEF:handleBackdropClick:Function]
const originHost = $derived.by(() => extractHttpHost(repositoryBindingRemoteUrl));
const configHost = $derived.by(() => extractHttpHost(repositoryConfigUrl));
const hasOriginConfigMismatch = $derived.by(() => {
if (!originHost || !configHost) return false;
return originHost !== configHost;
});
$effect(() => {
const selectedConfig = getSelectedConfig() || resolveDefaultConfig(configs);
repositoryConfigUrl = selectedConfig?.url || '';
});
onMount(async () => { onMount(async () => {
try { try {
configs = await gitService.getConfigs(); configs = await gitService.getConfigs();
@@ -559,9 +591,11 @@
try { try {
const binding = await gitService.getRepositoryBinding(dashboardId, envId); const binding = await gitService.getRepositoryBinding(dashboardId, envId);
repositoryProvider = binding?.provider || ''; repositoryProvider = binding?.provider || '';
repositoryBindingRemoteUrl = binding?.remote_url || '';
if (binding?.config_id) selectedConfigId = String(binding.config_id); if (binding?.config_id) selectedConfigId = String(binding.config_id);
} catch (_e) { } catch (_e) {
repositoryProvider = ''; repositoryProvider = '';
repositoryBindingRemoteUrl = '';
} }
} }
}); });
@@ -641,6 +675,15 @@
</div> </div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
{#if hasOriginConfigMismatch}
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
<div class="font-semibold">Git server mismatch detected</div>
<div class="mt-1">
Configured server host: <code>{configHost}</code>, repository origin host: <code>{originHost}</code>.
Next push will auto-realign origin host to configured server.
</div>
</div>
{/if}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"> <div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold ${stageBadgeClass(currentEnvStage)}`}> <span class={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold ${stageBadgeClass(currentEnvStage)}`}>

View File

@@ -15,8 +15,8 @@
let { let {
title = "", title = "",
class: className = "", class: className = "",
subtitle, subtitle = null,
actions, actions = null,
...rest ...rest
} = $props(); } = $props();
// [/SECTION: PROPS] // [/SECTION: PROPS]

View File

@@ -86,3 +86,10 @@
## Implementation Strategy ## Implementation Strategy
- MVP: Complete US1, US2, and US3 (Configure, Branch, Sync). This allows local-only versioning if remote is optional, or full sync if remote is configured. - MVP: Complete US1, US2, and US3 (Configure, Branch, Sync). This allows local-only versioning if remote is optional, or full sync if remote is configured.
- Incremental: Add Deployment (US4) and History (US5) after core sync workflow is stable. - Incremental: Add Deployment (US4) and History (US5) after core sync workflow is stable.
## Phase 9: Operational Debug (2026-03-08)
**Goal**: Validate mismatch between configured Git server URL and repository origin URL during push flow.
- [x] D001 Diagnose source-of-truth mismatch for server URL in push flow
- [x] D002 Validate diagnosis with backend push diagnostics logs
- [x] D003 Apply behavioral fix only after diagnosis confirmation