feat: automatically align Git repository origin host with configured server URL to prevent mismatches
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
let {
|
||||
dashboardId,
|
||||
envId = null,
|
||||
currentBranch = 'main',
|
||||
currentBranch = $bindable('main'),
|
||||
} = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)}`}>
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
let {
|
||||
title = "",
|
||||
class: className = "",
|
||||
subtitle,
|
||||
actions,
|
||||
subtitle = null,
|
||||
actions = null,
|
||||
...rest
|
||||
} = $props();
|
||||
// [/SECTION: PROPS]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user