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 8a16dbfa26
commit 0a108f7db5
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 src.core.logger import logger, belief_scope
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.core.database import SessionLocal
@@ -569,6 +569,154 @@ class GitService:
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
# [/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]
# @PURPOSE: Push local commits to 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}")
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
try:
current_branch = repo.active_branch

View File

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

View File

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

View File

@@ -61,6 +61,8 @@
let isPushing = $state(false);
let autoPushAfterCommit = $state(true);
let repositoryProvider = $state('');
let repositoryBindingRemoteUrl = $state('');
let repositoryConfigUrl = $state('');
// [/SECTION]
const hasWorkspaceChanges = $derived.by(() => {
@@ -441,6 +443,24 @@
}
// [/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]
/**
* @purpose Build deterministic repository name from dashboard title/id.
@@ -544,6 +564,18 @@
}
// [/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 () => {
try {
configs = await gitService.getConfigs();
@@ -559,9 +591,11 @@
try {
const binding = await gitService.getRepositoryBinding(dashboardId, envId);
repositoryProvider = binding?.provider || '';
repositoryBindingRemoteUrl = binding?.remote_url || '';
if (binding?.config_id) selectedConfigId = String(binding.config_id);
} catch (_e) {
repositoryProvider = '';
repositoryBindingRemoteUrl = '';
}
}
});
@@ -641,6 +675,15 @@
</div>
{:else}
<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 items-center gap-2">
<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 {
title = "",
class: className = "",
subtitle,
actions,
subtitle = null,
actions = null,
...rest
} = $props();
// [/SECTION: PROPS]

View File

@@ -85,4 +85,11 @@
## 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.
- 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