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 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
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
let {
|
let {
|
||||||
dashboardId,
|
dashboardId,
|
||||||
envId = null,
|
envId = null,
|
||||||
currentBranch = 'main',
|
currentBranch = $bindable('main'),
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// [/SECTION]
|
// [/SECTION]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`}>
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user