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
|
||||
|
||||
Reference in New Issue
Block a user