refactor: decompose 6 monolithic modules (>1000 LOC) into domain packages

Decompose 6 large modules into packages with <400-line modules to satisfy INV_7:

- superset_client.py (2145->0) -> superset_client/ package (12 modules)
- assistant.py (2683->0) -> assistant/ package (12 modules)
- git_service.py (2101->11 shim) -> services/git/ package (9 modules)
- git.py (1757->0) -> routes/git/ package (11 modules)
- dashboards.py (1619->0) -> routes/dashboards/ package (8 modules)
- translate.py (1587->0) -> routes/translate/ package (11 modules)
- superset_context_extractor.py (1397->0) -> utils/superset_context_extractor/ (7 modules)

All modules <400 lines (INV_7). All 80 tests pass.
All external imports preserved via __init__.py re-exports or shim.
Semantic [DEF] contracts with @LAYER/@SEMANTICS added to all Module types.
This commit is contained in:
2026-05-09 10:13:07 +03:00
parent 344b47ca23
commit 201886eeb0
76 changed files with 9831 additions and 12576 deletions

View File

@@ -0,0 +1,66 @@
# [DEF:GitServiceModule:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, service, decomposition, mixin, composition
# @PURPOSE: Composed GitService via multiple inheritance from domain-specific mixins.
# @RELATION: DEPENDS_ON -> [GitServiceBase]
# @RELATION: DEPENDS_ON -> [GitServiceBranchMixin]
# @RELATION: DEPENDS_ON -> [GitServiceSyncMixin]
# @RELATION: DEPENDS_ON -> [GitServiceStatusMixin]
# @RELATION: DEPENDS_ON -> [GitServiceMergeMixin]
# @RELATION: DEPENDS_ON -> [GitServiceUrlMixin]
# @RELATION: DEPENDS_ON -> [GitServiceGiteaMixin]
# @RELATION: DEPENDS_ON -> [GitServiceGithubMixin]
# @RELATION: DEPENDS_ON -> [GitServiceGitlabMixin]
#
# @RATIONALE: Decomposed from monolithic git_service.py (2101 lines) into
# domain-scoped mixins to satisfy INV_7 (module < 400 lines). The composed class
# preserves the original public API surface — all consumers continue to import
# `from src.services.git_service import GitService` without changes.
# @REJECTED: Keeping a single 2101-line file — violates fractal limit INV_7.
from ._base import GitServiceBase
from ._branch import GitServiceBranchMixin
from ._sync import GitServiceSyncMixin
from ._status import GitServiceStatusMixin
from ._merge import GitServiceMergeMixin
from ._url import GitServiceUrlMixin
from ._gitea import GitServiceGiteaMixin
from ._remote_providers import GitServiceGithubMixin, GitServiceGitlabMixin
__all__ = ["GitService"]
# [DEF:GitService:Class]
# @COMPLEXITY: 3
# @PURPOSE: Composed GitService class providing all Git operations — init, clone, branches, commits, push, pull,
# merge, status, diff, history, and provider API operations (Gitea, GitHub, GitLab).
# @RELATION: INHERITS -> [GitServiceBase]
# @RELATION: INHERITS -> [GitServiceBranchMixin]
# @RELATION: INHERITS -> [GitServiceSyncMixin]
# @RELATION: INHERITS -> [GitServiceStatusMixin]
# @RELATION: INHERITS -> [GitServiceMergeMixin]
# @RELATION: INHERITS -> [GitServiceUrlMixin]
# @RELATION: INHERITS -> [GitServiceGiteaMixin]
# @RELATION: INHERITS -> [GitServiceGithubMixin]
# @RELATION: INHERITS -> [GitServiceGitlabMixin]
class GitService(
GitServiceGiteaMixin,
GitServiceGithubMixin,
GitServiceGitlabMixin,
GitServiceMergeMixin,
GitServiceSyncMixin,
GitServiceStatusMixin,
GitServiceBranchMixin,
GitServiceUrlMixin,
GitServiceBase,
):
"""
Composed GitService — all Git operations via domain-specific mixins.
MRO ensures provider mixins and domain mixins resolve before the base class.
All consumers continue to use: from src.services.git_service import GitService
"""
pass
# [/DEF:GitService:Class]
# [/DEF:GitServiceModule:Module]

View File

@@ -0,0 +1,319 @@
# [DEF:GitServiceBase:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, service, repository, version_control, initialization
# @PURPOSE: Core GitService base class — initialization, path resolution, repo lifecycle (init/delete/get), and identity configuration.
# @RELATION: INHERITED_BY -> [GitService]
# @RELATION: DEPENDS_ON -> [SessionLocal]
# @RELATION: DEPENDS_ON -> [AppConfigRecord]
# @RELATION: DEPENDS_ON -> [GitRepository]
import os
import re
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional
from git import Repo
from git.exc import InvalidGitRepositoryError, NoSuchPathError
from fastapi import HTTPException
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository
from src.models.config import AppConfigRecord
from src.core.database import SessionLocal
# [DEF:GitServiceBase:Class]
# @COMPLEXITY: 3
# @PURPOSE: Base class for GitService providing initialization, path resolution, repository lifecycle, and identity.
class GitServiceBase:
# [DEF:GitService_init:Function]
# @PURPOSE: Initializes the GitService with a base path for repositories.
# @PARAM: base_path (str) - Root directory for all Git clones.
# @PRE: base_path is a valid string path.
# @POST: GitService is initialized; base_path directory exists.
def __init__(self, base_path: str = "git_repos"):
with belief_scope("GitService.__init__"):
backend_root = Path(__file__).parents[3]
self.legacy_base_path = str((backend_root / "git_repos").resolve())
self._uses_default_base_path = base_path == "git_repos"
self.base_path = self._resolve_base_path(base_path)
self._ensure_base_path_exists()
# [/DEF:GitService_init:Function]
# [DEF:_ensure_base_path_exists:Function]
# @PURPOSE: Ensure the repositories root directory exists and is a directory.
# @PRE: self.base_path is resolved to filesystem path.
# @POST: self.base_path exists as directory or raises ValueError.
def _ensure_base_path_exists(self) -> None:
base = Path(self.base_path)
if base.exists() and not base.is_dir():
raise ValueError(f"Git repositories base path is not a directory: {self.base_path}")
try:
base.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError) as e:
logger.warning(
f"[_ensure_base_path_exists][Coherence:Failed] Cannot create Git repositories base path: {self.base_path}. Error: {e}"
)
raise ValueError(f"Cannot create Git repositories base path: {self.base_path}. {e}")
# [/DEF:_ensure_base_path_exists:Function]
# [DEF:_resolve_base_path:Function]
# @PURPOSE: Resolve base repository directory from explicit argument or global storage settings.
# @PRE: base_path is a string path.
# @POST: Returns absolute path for Git repositories root.
# @RETURN: str
def _resolve_base_path(self, base_path: str) -> str:
backend_root = Path(__file__).parents[3]
fallback_path = str((backend_root / base_path).resolve())
if base_path != "git_repos":
return fallback_path
try:
session = SessionLocal()
try:
config_row = session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
finally:
session.close()
payload = (config_row.payload if config_row and config_row.payload else {}) if config_row else {}
storage_cfg = payload.get("settings", {}).get("storage", {}) if isinstance(payload, dict) else {}
root_path = str(storage_cfg.get("root_path", "")).strip()
repo_path = str(storage_cfg.get("repo_path", "")).strip()
if not root_path:
return fallback_path
project_root = Path(__file__).parents[4]
root = Path(root_path)
if not root.is_absolute():
root = (project_root / root).resolve()
repo_root = Path(repo_path) if repo_path else Path("repositorys")
if repo_root.is_absolute():
return str(repo_root.resolve())
return str((root / repo_root).resolve())
except Exception as e:
logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}")
return fallback_path
# [/DEF:_resolve_base_path:Function]
# [DEF:_normalize_repo_key:Function]
# @PURPOSE: Convert user/dashboard-provided key to safe filesystem directory name.
# @PRE: repo_key can be None/empty.
# @POST: Returns normalized non-empty key.
# @RETURN: str
def _normalize_repo_key(self, repo_key: Optional[str]) -> str:
raw_key = str(repo_key or "").strip().lower()
normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-")
return normalized or "dashboard"
# [/DEF:_normalize_repo_key:Function]
# [DEF:_update_repo_local_path:Function]
# @PURPOSE: Persist repository local_path in GitRepository table when record exists.
# @PRE: dashboard_id is valid integer.
# @POST: local_path is updated for existing record.
def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None:
try:
session = SessionLocal()
try:
db_repo = (
session.query(GitRepository)
.filter(GitRepository.dashboard_id == int(dashboard_id))
.first()
)
if db_repo:
db_repo.local_path = local_path
session.commit()
finally:
session.close()
except Exception as e:
logger.warning(f"[_update_repo_local_path][Coherence:Failed] {e}")
# [/DEF:_update_repo_local_path:Function]
# [DEF:_migrate_repo_directory:Function]
# @PURPOSE: Move legacy repository directory to target path and sync DB metadata.
# @PRE: source_path exists.
# @POST: Repository content available at target_path.
# @RETURN: str
def _migrate_repo_directory(self, dashboard_id: int, source_path: str, target_path: str) -> str:
source_abs = os.path.abspath(source_path)
target_abs = os.path.abspath(target_path)
if source_abs == target_abs:
return source_abs
if os.path.exists(target_abs):
logger.warning(f"[_migrate_repo_directory][Action] Target already exists, keeping source path: {target_abs}")
return source_abs
Path(target_abs).parent.mkdir(parents=True, exist_ok=True)
try:
os.replace(source_abs, target_abs)
except OSError:
shutil.move(source_abs, target_abs)
self._update_repo_local_path(dashboard_id, target_abs)
logger.info(
f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}"
)
return target_abs
# [/DEF:_migrate_repo_directory:Function]
# [DEF:_get_repo_path:Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
# @PARAM: dashboard_id (int)
# @PARAM: repo_key (Optional[str]) - Slug-like key used when DB local_path is absent.
# @PRE: dashboard_id is an integer.
# @POST: Returns DB-local_path when present, otherwise base_path/<normalized repo_key>.
# @RETURN: str
def _get_repo_path(self, dashboard_id: int, repo_key: Optional[str] = None) -> str:
with belief_scope("GitService._get_repo_path"):
if dashboard_id is None:
raise ValueError("dashboard_id cannot be None")
self._ensure_base_path_exists()
fallback_key = repo_key if repo_key is not None else str(dashboard_id)
normalized_key = self._normalize_repo_key(fallback_key)
target_path = os.path.join(self.base_path, normalized_key)
if not self._uses_default_base_path:
return target_path
try:
session = SessionLocal()
try:
db_repo = (
session.query(GitRepository)
.filter(GitRepository.dashboard_id == int(dashboard_id))
.first()
)
finally:
session.close()
if db_repo and db_repo.local_path:
db_path = os.path.abspath(db_repo.local_path)
if os.path.exists(db_path):
if (
os.path.abspath(self.base_path) != os.path.abspath(self.legacy_base_path)
and db_path.startswith(os.path.abspath(self.legacy_base_path) + os.sep)
):
return self._migrate_repo_directory(dashboard_id, db_path, target_path)
return db_path
except Exception as e:
logger.warning(f"[_get_repo_path][Coherence:Failed] Could not resolve local_path from DB: {e}")
legacy_id_path = os.path.join(self.legacy_base_path, str(dashboard_id))
if os.path.exists(legacy_id_path) and not os.path.exists(target_path):
return self._migrate_repo_directory(dashboard_id, legacy_id_path, target_path)
if os.path.exists(target_path):
self._update_repo_local_path(dashboard_id, target_path)
return target_path
# [/DEF:_get_repo_path:Function]
# [DEF:init_repo:Function]
# @PURPOSE: Initialize or clone a repository for a dashboard.
# @PARAM: dashboard_id (int), remote_url (str), pat (str), repo_key (Optional[str]).
# @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
# @POST: Repository is cloned or opened at the local path.
# @RETURN: Repo
def init_repo(self, dashboard_id: int, remote_url: str, pat: str, repo_key: Optional[str] = None) -> Repo:
with belief_scope("GitService.init_repo"):
self._ensure_base_path_exists()
repo_path = self._get_repo_path(dashboard_id, repo_key=repo_key or str(dashboard_id))
Path(repo_path).parent.mkdir(parents=True, exist_ok=True)
if pat and "://" in remote_url:
proto, rest = remote_url.split("://", 1)
auth_url = f"{proto}://oauth2:{pat}@{rest}"
else:
auth_url = remote_url
if os.path.exists(repo_path):
logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
try:
repo = Repo(repo_path)
except (InvalidGitRepositoryError, NoSuchPathError):
logger.warning(f"[init_repo][Action] Existing path is not a Git repository, recreating: {repo_path}")
stale_path = Path(repo_path)
if stale_path.exists():
shutil.rmtree(stale_path, ignore_errors=True)
if stale_path.exists():
try:
stale_path.unlink()
except Exception:
pass
repo = Repo.clone_from(auth_url, repo_path)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}")
repo = Repo.clone_from(auth_url, repo_path)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
# [/DEF:init_repo:Function]
# [DEF:delete_repo:Function]
# @PURPOSE: Remove local repository and DB binding for a dashboard.
# @PRE: dashboard_id is a valid integer.
# @POST: Local path is deleted when present and GitRepository row is removed.
def delete_repo(self, dashboard_id: int) -> None:
with belief_scope("GitService.delete_repo"):
repo_path = self._get_repo_path(dashboard_id)
removed_files = False
if os.path.exists(repo_path):
if os.path.isdir(repo_path):
shutil.rmtree(repo_path)
else:
os.remove(repo_path)
removed_files = True
session = SessionLocal()
try:
db_repo = (
session.query(GitRepository)
.filter(GitRepository.dashboard_id == int(dashboard_id))
.first()
)
if db_repo:
session.delete(db_repo)
session.commit()
return
if removed_files:
return
raise HTTPException(
status_code=404,
detail=f"Repository for dashboard {dashboard_id} not found",
)
except HTTPException:
session.rollback()
raise
except Exception as e:
session.rollback()
logger.error(f"[delete_repo][Coherence:Failed] Failed to delete repository for dashboard {dashboard_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete repository: {str(e)}")
finally:
session.close()
# [/DEF:delete_repo:Function]
# [DEF:get_repo:Function]
# @PURPOSE: Get Repo object for a dashboard.
# @PRE: Repository must exist on disk for the given dashboard_id.
# @POST: Returns a GitPython Repo instance for the dashboard.
# @RETURN: Repo
def get_repo(self, dashboard_id: int) -> Repo:
with belief_scope("GitService.get_repo"):
repo_path = self._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
logger.error(f"[get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist")
raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found")
try:
return Repo(repo_path)
except Exception as e:
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}")
raise HTTPException(status_code=500, detail="Failed to open local Git repository")
# [/DEF:get_repo:Function]
# [DEF:configure_identity:Function]
# @PURPOSE: Configure repository-local Git committer identity for user-scoped operations.
# @PRE: dashboard_id repository exists; git_username/git_email may be empty.
# @POST: Repository config has user.name and user.email when both identity values are provided.
def configure_identity(self, dashboard_id: int, git_username: Optional[str], git_email: Optional[str]) -> None:
with belief_scope("GitService.configure_identity"):
normalized_username = str(git_username or "").strip()
normalized_email = str(git_email or "").strip()
if not normalized_username or not normalized_email:
return
repo = self.get_repo(dashboard_id)
try:
with repo.config_writer(config_level="repository") as config_writer:
config_writer.set_value("user", "name", normalized_username)
config_writer.set_value("user", "email", normalized_email)
logger.info("[configure_identity][Action] Applied repository-local git identity for dashboard %s", dashboard_id)
except Exception as e:
logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}")
raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {str(e)}")
# [/DEF:configure_identity:Function]
# [/DEF:GitServiceBase:Class]
# [/DEF:GitServiceBase:Module]

View File

@@ -0,0 +1,192 @@
# [DEF:GitServiceBranchMixin:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, branches, commits, checkout, gitflow
# @PURPOSE: Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes.
# @RELATION: USED_BY -> [GitService]
import os
from typing import Any, Dict, List, Optional
from datetime import datetime
from git import Repo
from git.exc import GitCommandError
from fastapi import HTTPException
from src.core.logger import logger, belief_scope
# [DEF:GitServiceBranchMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing branch and commit operations for GitService.
class GitServiceBranchMixin:
# [DEF:_ensure_gitflow_branches:Function]
# @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
# @PRE: repo is a valid GitPython Repo instance.
# @POST: main, dev, preprod are available in local repository and pushed to origin when available.
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
with belief_scope("GitService._ensure_gitflow_branches"):
required_branches = ["main", "dev", "preprod"]
local_heads = {head.name: head for head in getattr(repo, "heads", [])}
base_commit = None
try:
base_commit = repo.head.commit
except Exception:
base_commit = None
if "main" in local_heads:
base_commit = local_heads["main"].commit
if base_commit is None:
logger.warning(
f"[_ensure_gitflow_branches][Action] Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits"
)
return
if "main" not in local_heads:
local_heads["main"] = repo.create_head("main", base_commit)
logger.info(f"[_ensure_gitflow_branches][Action] Created local branch main for dashboard {dashboard_id}")
for branch_name in ("dev", "preprod"):
if branch_name in local_heads:
continue
local_heads[branch_name] = repo.create_head(branch_name, local_heads["main"].commit)
logger.info(
f"[_ensure_gitflow_branches][Action] Created local branch {branch_name} for dashboard {dashboard_id}"
)
try:
origin = repo.remote(name="origin")
except ValueError:
logger.info(
f"[_ensure_gitflow_branches][Action] Remote origin is not configured for dashboard {dashboard_id}; skipping remote branch creation"
)
return
remote_branch_names = set()
try:
origin.fetch()
for ref in origin.refs:
remote_head = getattr(ref, "remote_head", None)
if remote_head:
remote_branch_names.add(str(remote_head))
except Exception as e:
logger.warning(f"[_ensure_gitflow_branches][Action] Failed to fetch origin refs: {e}")
for branch_name in required_branches:
if branch_name in remote_branch_names:
continue
try:
origin.push(refspec=f"{branch_name}:{branch_name}")
logger.info(
f"[_ensure_gitflow_branches][Action] Pushed branch {branch_name} to origin for dashboard {dashboard_id}"
)
except Exception as e:
logger.error(f"[_ensure_gitflow_branches][Coherence:Failed] Failed to push branch {branch_name} to origin: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to create default branch '{branch_name}' on remote: {str(e)}",
)
try:
repo.git.checkout("dev")
logger.info(f"[_ensure_gitflow_branches][Action] Checked out default branch dev for dashboard {dashboard_id}")
except Exception as e:
logger.warning(f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}")
# [/DEF:_ensure_gitflow_branches:Function]
# [DEF:list_branches:Function]
# @PURPOSE: List all branches for a dashboard's repository.
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a list of branch metadata dictionaries.
# @RETURN: List[dict]
def list_branches(self, dashboard_id: int) -> List[dict]:
with belief_scope("GitService.list_branches"):
repo = self.get_repo(dashboard_id)
logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}")
branches = []
for ref in repo.refs:
try:
name = ref.name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
if any(b['name'] == name for b in branches):
continue
branches.append({
"name": name,
"commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000",
"is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False,
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
})
except Exception as e:
logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}")
try:
active_name = repo.active_branch.name
if not any(b['name'] == active_name for b in branches):
branches.append({
"name": active_name, "commit_hash": "0000000",
"is_remote": False, "last_updated": datetime.utcnow()
})
except Exception as e:
logger.warning(f"[list_branches][Action] Could not determine active branch: {e}")
if not branches:
branches.append({
"name": "dev", "commit_hash": "0000000",
"is_remote": False, "last_updated": datetime.utcnow()
})
return branches
# [/DEF:list_branches:Function]
# [DEF:create_branch:Function]
# @PURPOSE: Create a new branch from an existing one.
# @PARAM: name (str) - New branch name.
# @PARAM: from_branch (str) - Source branch.
# @PRE: Repository exists; name is valid; from_branch exists or repo is empty.
# @POST: A new branch is created in the repository.
def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
with belief_scope("GitService.create_branch"):
repo = self.get_repo(dashboard_id)
logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}")
if not repo.heads and not repo.remotes:
logger.warning("[create_branch][Action] Repository is empty. Creating initial commit to enable branching.")
readme_path = os.path.join(repo.working_dir, "README.md")
if not os.path.exists(readme_path):
with open(readme_path, "w") as f:
f.write(f"# Dashboard {dashboard_id}\nGit repository for Superset dashboard integration.")
repo.index.add(["README.md"])
repo.index.commit("Initial commit")
try:
repo.commit(from_branch)
except Exception:
logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD")
from_branch = repo.head
try:
new_branch = repo.create_head(name, from_branch)
return new_branch
except Exception as e:
logger.error(f"[create_branch][Coherence:Failed] {e}")
raise
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @PURPOSE: Switch to a specific branch.
# @PRE: Repository exists and the specified branch name exists.
# @POST: The repository working directory is updated to the specified branch.
def checkout_branch(self, dashboard_id: int, name: str):
with belief_scope("GitService.checkout_branch"):
repo = self.get_repo(dashboard_id)
logger.info(f"[checkout_branch][Action] Checking out branch {name}")
repo.git.checkout(name)
# [/DEF:checkout_branch:Function]
# [DEF:commit_changes:Function]
# @PURPOSE: Stage and commit changes.
# @PARAM: message (str) - Commit message.
# @PARAM: files (List[str]) - Optional list of specific files to stage.
# @PRE: Repository exists and has changes (dirty) or files are specified.
# @POST: Changes are staged and a new commit is created.
def commit_changes(self, dashboard_id: int, message: str, files: List[str] = None):
with belief_scope("GitService.commit_changes"):
repo = self.get_repo(dashboard_id)
if not repo.is_dirty(untracked_files=True) and not files:
logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}")
return
if files:
logger.info(f"[commit_changes][Action] Staging files: {files}")
repo.index.add(files)
else:
logger.info("[commit_changes][Action] Staging all changes")
repo.git.add(A=True)
repo.index.commit(message)
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
# [/DEF:commit_changes:Function]
# [/DEF:GitServiceBranchMixin:Class]
# [/DEF:GitServiceBranchMixin:Module]

View File

@@ -0,0 +1,262 @@
# [DEF:GitServiceGiteaMixin:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, gitea, api, provider, connection_test
# @PURPOSE: Gitea API operations for GitService — connection testing, repository CRUD, and pull request creation.
# @RELATION: USED_BY -> [GitService]
import httpx
from typing import Any, Dict, List, Optional
from urllib.parse import quote
from fastapi import HTTPException
from src.core.logger import logger, belief_scope
from src.models.git import GitProvider
# [DEF:GitServiceGiteaMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing Gitea API operations for GitService.
class GitServiceGiteaMixin:
# [DEF:test_connection:Function]
# @PURPOSE: Test connection to Git provider using PAT.
# @PARAM: provider (GitProvider), url (str), pat (str)
# @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided.
# @POST: Returns True if connection to the provider's API succeeds.
# @RETURN: bool
async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
with belief_scope("GitService.test_connection"):
if ".local" in url or "localhost" in url:
logger.info("[test_connection][Action] Local/Offline mode detected for URL")
return True
if not url.startswith(('http://', 'https://')):
logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
return False
if not pat or not pat.strip():
logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
return False
pat = pat.strip()
try:
async with httpx.AsyncClient() as client:
if provider == GitProvider.GITHUB:
headers = {"Authorization": f"token {pat}"}
api_url = "https://api.github.com/user" if "github.com" in url else f"{url.rstrip('/')}/api/v3/user"
resp = await client.get(api_url, headers=headers)
elif provider == GitProvider.GITLAB:
headers = {"PRIVATE-TOKEN": pat}
api_url = f"{url.rstrip('/')}/api/v4/user"
resp = await client.get(api_url, headers=headers)
elif provider == GitProvider.GITEA:
headers = {"Authorization": f"token {pat}"}
api_url = f"{url.rstrip('/')}/api/v1/user"
resp = await client.get(api_url, headers=headers)
else:
return False
if resp.status_code != 200:
logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}")
return resp.status_code == 200
except Exception as e:
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
return False
# [/DEF:test_connection:Function]
# [DEF:_gitea_headers:Function]
# @PURPOSE: Build Gitea API authorization headers.
# @PRE: pat is provided.
# @POST: Returns headers with token auth.
# @RETURN: Dict[str, str]
def _gitea_headers(self, pat: str) -> Dict[str, str]:
token = (pat or "").strip()
if not token:
raise HTTPException(status_code=400, detail="Git PAT is required for Gitea operations")
return {
"Authorization": f"token {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
# [/DEF:_gitea_headers:Function]
# [DEF:_gitea_request:Function]
# @PURPOSE: Execute HTTP request against Gitea API with stable error mapping.
# @PRE: method and endpoint are valid.
# @POST: Returns decoded JSON payload.
# @RETURN: Any
async def _gitea_request(
self, method: str, server_url: str, pat: str, endpoint: str,
payload: Optional[Dict[str, Any]] = None,
) -> Any:
base_url = self._normalize_git_server_url(server_url)
url = f"{base_url}/api/v1{endpoint}"
headers = self._gitea_headers(pat)
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.request(method=method, url=url, headers=headers, json=payload)
except Exception as e:
logger.error(f"[gitea_request][Coherence:Failed] Network error: {e}")
raise HTTPException(status_code=503, detail=f"Gitea API is unavailable: {str(e)}")
if response.status_code >= 400:
detail = response.text
try:
parsed = response.json()
detail = parsed.get("message") or parsed.get("error") or detail
except Exception:
pass
logger.error(f"[gitea_request][Coherence:Failed] method={method} endpoint={endpoint} status={response.status_code} detail={detail}")
raise HTTPException(status_code=response.status_code, detail=f"Gitea API error: {detail}")
if response.status_code == 204:
return None
return response.json()
# [/DEF:_gitea_request:Function]
# [DEF:get_gitea_current_user:Function]
# @PURPOSE: Resolve current Gitea user for PAT.
# @PRE: server_url and pat are valid.
# @POST: Returns current username.
# @RETURN: str
async def get_gitea_current_user(self, server_url: str, pat: str) -> str:
payload = await self._gitea_request("GET", server_url, pat, "/user")
username = payload.get("login") or payload.get("username")
if not username:
raise HTTPException(status_code=500, detail="Failed to resolve Gitea username")
return str(username)
# [/DEF:get_gitea_current_user:Function]
# [DEF:list_gitea_repositories:Function]
# @PURPOSE: List repositories visible to authenticated Gitea user.
# @PRE: server_url and pat are valid.
# @POST: Returns repository list from Gitea.
# @RETURN: List[dict]
async def list_gitea_repositories(self, server_url: str, pat: str) -> List[dict]:
payload = await self._gitea_request("GET", server_url, pat, "/user/repos?limit=100&page=1")
if not isinstance(payload, list):
return []
return payload
# [/DEF:list_gitea_repositories:Function]
# [DEF:create_gitea_repository:Function]
# @PURPOSE: Create repository in Gitea for authenticated user.
# @PRE: name is non-empty and PAT has repo creation permission.
# @POST: Returns created repository payload.
# @RETURN: dict
async def create_gitea_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
) -> Dict[str, Any]:
payload: Dict[str, Any] = {"name": name, "private": bool(private), "auto_init": bool(auto_init)}
if description:
payload["description"] = description
if default_branch:
payload["default_branch"] = default_branch
created = await self._gitea_request("POST", server_url, pat, "/user/repos", payload=payload)
if not isinstance(created, dict):
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository")
return created
# [/DEF:create_gitea_repository:Function]
# [DEF:delete_gitea_repository:Function]
# @PURPOSE: Delete repository in Gitea.
# @PRE: owner and repo_name are non-empty.
# @POST: Repository deleted on Gitea server.
async def delete_gitea_repository(self, server_url: str, pat: str, owner: str, repo_name: str) -> None:
if not owner or not repo_name:
raise HTTPException(status_code=400, detail="owner and repo_name are required")
await self._gitea_request("DELETE", server_url, pat, f"/repos/{owner}/{repo_name}")
# [/DEF:delete_gitea_repository:Function]
# [DEF:_gitea_branch_exists:Function]
# @PURPOSE: Check whether a branch exists in Gitea repository.
# @PRE: owner/repo/branch are non-empty.
# @POST: Returns True when branch exists, False when 404.
# @RETURN: bool
async def _gitea_branch_exists(self, server_url: str, pat: str, owner: str, repo: str, branch: str) -> bool:
if not owner or not repo or not branch:
return False
endpoint = f"/repos/{owner}/{repo}/branches/{quote(branch, safe='')}"
try:
await self._gitea_request("GET", server_url, pat, endpoint)
return True
except HTTPException as exc:
if exc.status_code == 404:
return False
raise
# [/DEF:_gitea_branch_exists:Function]
# [DEF:_build_gitea_pr_404_detail:Function]
# @PURPOSE: Build actionable error detail for Gitea PR 404 responses.
# @PRE: owner/repo/from_branch/to_branch are provided.
# @POST: Returns specific branch-missing message when detected.
# @RETURN: Optional[str]
async def _build_gitea_pr_404_detail(
self, server_url: str, pat: str, owner: str, repo: str, from_branch: str, to_branch: str,
) -> Optional[str]:
source_exists = await self._gitea_branch_exists(
server_url=server_url, pat=pat, owner=owner, repo=repo, branch=from_branch,
)
target_exists = await self._gitea_branch_exists(
server_url=server_url, pat=pat, owner=owner, repo=repo, branch=to_branch,
)
if not source_exists:
return f"Gitea branch not found: source branch '{from_branch}' in {owner}/{repo}"
if not target_exists:
return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}"
return None
# [/DEF:_build_gitea_pr_404_detail:Function]
# [DEF:create_gitea_pull_request:Function]
# @PURPOSE: Create pull request in Gitea.
# @PRE: Config and remote URL are valid.
# @POST: Returns normalized PR metadata.
# @RETURN: Dict[str, Any]
async def create_gitea_pull_request(
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
title: str, description: Optional[str] = None,
) -> Dict[str, Any]:
identity = self._parse_remote_repo_identity(remote_url)
req_payload = {"title": title, "head": from_branch, "base": to_branch, "body": description or ""}
endpoint = f"/repos/{identity['owner']}/{identity['repo']}/pulls"
active_server_url = server_url
try:
data = await self._gitea_request("POST", active_server_url, pat, endpoint, payload=req_payload)
except HTTPException as exc:
fallback_url = self._derive_server_url_from_remote(remote_url)
normalized_primary = self._normalize_git_server_url(server_url)
should_retry_with_fallback = (
exc.status_code == 404 and fallback_url and fallback_url != normalized_primary
)
if should_retry_with_fallback:
logger.warning(
"[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s",
fallback_url,
)
active_server_url = fallback_url
try:
data = await self._gitea_request("POST", active_server_url, pat, endpoint, payload=req_payload)
except HTTPException as retry_exc:
if retry_exc.status_code == 404:
branch_detail = await self._build_gitea_pr_404_detail(
server_url=active_server_url, pat=pat,
owner=identity["owner"], repo=identity["repo"],
from_branch=from_branch, to_branch=to_branch,
)
if branch_detail:
raise HTTPException(status_code=400, detail=branch_detail)
raise
else:
if exc.status_code == 404:
branch_detail = await self._build_gitea_pr_404_detail(
server_url=active_server_url, pat=pat,
owner=identity["owner"], repo=identity["repo"],
from_branch=from_branch, to_branch=to_branch,
)
if branch_detail:
raise HTTPException(status_code=400, detail=branch_detail)
raise
if not isinstance(data, dict):
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating pull request")
return {
"id": data.get("number") or data.get("id"),
"url": data.get("html_url") or data.get("url"),
"status": data.get("state") or "open",
}
# [/DEF:create_gitea_pull_request:Function]
# [/DEF:GitServiceGiteaMixin:Class]
# [/DEF:GitServiceGiteaMixin:Module]

View File

@@ -0,0 +1,278 @@
# [DEF:GitServiceMergeMixin:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, merge, conflicts, resolution, promote
# @PURPOSE: Merge operations for GitService — conflict detection, resolution, abort, continue, and direct promote.
# @RELATION: USED_BY -> [GitService]
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from git import Repo
from git.exc import GitCommandError
from git.objects.blob import Blob
from fastapi import HTTPException
from src.core.logger import logger, belief_scope
# [DEF:GitServiceMergeMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing merge operations for GitService.
class GitServiceMergeMixin:
# [DEF:_read_blob_text:Function]
# @PURPOSE: Read text from a Git blob.
def _read_blob_text(self, blob: Blob) -> str:
with belief_scope("GitService._read_blob_text"):
if blob is None:
return ""
try:
return blob.data_stream.read().decode("utf-8", errors="replace")
except Exception:
return ""
# [/DEF:_read_blob_text:Function]
# [DEF:_get_unmerged_file_paths:Function]
# @PURPOSE: List files with merge conflicts.
def _get_unmerged_file_paths(self, repo: Repo) -> List[str]:
with belief_scope("GitService._get_unmerged_file_paths"):
try:
return sorted(list(repo.index.unmerged_blobs().keys()))
except Exception:
return []
# [/DEF:_get_unmerged_file_paths:Function]
# [DEF:_build_unfinished_merge_payload:Function]
# @PURPOSE: Build payload for unfinished merge state.
def _build_unfinished_merge_payload(self, repo: Repo) -> Dict[str, Any]:
with belief_scope("GitService._build_unfinished_merge_payload"):
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
merge_head_value = ""
merge_msg_preview = ""
current_branch = "unknown"
try:
merge_head_value = Path(merge_head_path).read_text(encoding="utf-8").strip()
except Exception:
merge_head_value = "<unreadable>"
try:
merge_msg_path = os.path.join(repo.git_dir, "MERGE_MSG")
if os.path.exists(merge_msg_path):
merge_msg_preview = (
Path(merge_msg_path).read_text(encoding="utf-8").strip().splitlines()[:1] or [""]
)[0]
except Exception:
merge_msg_preview = "<unreadable>"
try:
current_branch = repo.active_branch.name
except Exception:
current_branch = "detached_or_unknown"
conflicts_count = len(self._get_unmerged_file_paths(repo))
return {
"error_code": "GIT_UNFINISHED_MERGE",
"message": (
"\u0412 \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0438 \u0435\u0441\u0442\u044c \u043d\u0435\u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043d\u043d\u043e\u0435 \u0441\u043b\u0438\u044f\u043d\u0438\u0435. "
"\u0417\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0435 \u0438\u043b\u0438 \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u0435 \u0441\u043b\u0438\u044f\u043d\u0438\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e."
),
"repository_path": repo.working_tree_dir,
"git_dir": repo.git_dir,
"current_branch": current_branch,
"merge_head": merge_head_value,
"merge_message_preview": merge_msg_preview,
"conflicts_count": conflicts_count,
"next_steps": [
"\u041e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0439 \u043f\u043e \u043f\u0443\u0442\u0438 repository_path",
"\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435: git status",
"\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u044b \u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 commit, \u043b\u0438\u0431\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u0435: git merge --abort",
"\u041f\u043e\u0441\u043b\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f/\u043e\u0442\u043c\u0435\u043d\u044b \u0441\u043b\u0438\u044f\u043d\u0438\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 Pull \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430",
],
"manual_commands": ["git status", "git add <resolved-files>", 'git commit -m "resolve merge conflicts"', "git merge --abort"],
}
# [/DEF:_build_unfinished_merge_payload:Function]
# [DEF:get_merge_status:Function]
# @PURPOSE: Get current merge status for a dashboard repository.
def get_merge_status(self, dashboard_id: int) -> Dict[str, Any]:
with belief_scope("GitService.get_merge_status"):
repo = self.get_repo(dashboard_id)
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
if not os.path.exists(merge_head_path):
current_branch = "unknown"
try:
current_branch = repo.active_branch.name
except Exception:
current_branch = "detached_or_unknown"
return {
"has_unfinished_merge": False,
"repository_path": repo.working_tree_dir,
"git_dir": repo.git_dir,
"current_branch": current_branch,
"merge_head": None,
"merge_message_preview": None,
"conflicts_count": 0,
}
payload = self._build_unfinished_merge_payload(repo)
return {
"has_unfinished_merge": True,
"repository_path": payload["repository_path"],
"git_dir": payload["git_dir"],
"current_branch": payload["current_branch"],
"merge_head": payload["merge_head"],
"merge_message_preview": payload["merge_message_preview"],
"conflicts_count": int(payload.get("conflicts_count") or 0),
}
# [/DEF:get_merge_status:Function]
# [DEF:get_merge_conflicts:Function]
# @PURPOSE: List all files with conflicts and their contents.
def get_merge_conflicts(self, dashboard_id: int) -> List[Dict[str, Any]]:
with belief_scope("GitService.get_merge_conflicts"):
repo = self.get_repo(dashboard_id)
conflicts = []
unmerged = repo.index.unmerged_blobs()
for file_path, stages in unmerged.items():
mine_blob = None
theirs_blob = None
for stage, blob in stages:
if stage == 2:
mine_blob = blob
elif stage == 3:
theirs_blob = blob
conflicts.append({
"file_path": file_path,
"mine": self._read_blob_text(mine_blob) if mine_blob else "",
"theirs": self._read_blob_text(theirs_blob) if theirs_blob else "",
})
return sorted(conflicts, key=lambda item: item["file_path"])
# [/DEF:get_merge_conflicts:Function]
# [DEF:resolve_merge_conflicts:Function]
# @PURPOSE: Resolve conflicts using specified strategy.
def resolve_merge_conflicts(self, dashboard_id: int, resolutions: List[Dict[str, Any]]) -> List[str]:
with belief_scope("GitService.resolve_merge_conflicts"):
repo = self.get_repo(dashboard_id)
resolved_files: List[str] = []
repo_root = os.path.abspath(str(repo.working_tree_dir or ""))
if not repo_root:
raise HTTPException(status_code=500, detail="Repository working tree directory is unavailable")
for item in resolutions or []:
file_path = str(item.get("file_path") or "").strip()
strategy = str(item.get("resolution") or "").strip().lower()
content = item.get("content")
if not file_path:
raise HTTPException(status_code=400, detail="resolution.file_path is required")
if strategy not in {"mine", "theirs", "manual"}:
raise HTTPException(status_code=400, detail=f"Unsupported resolution strategy: {strategy}")
if strategy == "mine":
repo.git.checkout("--ours", "--", file_path)
elif strategy == "theirs":
repo.git.checkout("--theirs", "--", file_path)
else:
abs_target = os.path.abspath(os.path.join(repo_root, file_path))
if abs_target != repo_root and not abs_target.startswith(repo_root + os.sep):
raise HTTPException(status_code=400, detail=f"Invalid conflict file path: {file_path}")
os.makedirs(os.path.dirname(abs_target), exist_ok=True)
with open(abs_target, "w", encoding="utf-8") as file_obj:
file_obj.write(str(content or ""))
repo.git.add(file_path)
resolved_files.append(file_path)
return resolved_files
# [/DEF:resolve_merge_conflicts:Function]
# [DEF:abort_merge:Function]
# @PURPOSE: Abort ongoing merge.
def abort_merge(self, dashboard_id: int) -> Dict[str, Any]:
with belief_scope("GitService.abort_merge"):
repo = self.get_repo(dashboard_id)
try:
repo.git.merge("--abort")
except GitCommandError as e:
details = str(e)
lowered = details.lower()
if "there is no merge to abort" in lowered or "no merge to abort" in lowered:
return {"status": "no_merge_in_progress"}
raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}")
return {"status": "aborted"}
# [/DEF:abort_merge:Function]
# [DEF:continue_merge:Function]
# @PURPOSE: Finalize merge after conflict resolution.
def continue_merge(self, dashboard_id: int, message: Optional[str] = None) -> Dict[str, Any]:
with belief_scope("GitService.continue_merge"):
repo = self.get_repo(dashboard_id)
unmerged_files = self._get_unmerged_file_paths(repo)
if unmerged_files:
raise HTTPException(
status_code=409,
detail={
"error_code": "GIT_MERGE_CONFLICTS_REMAIN",
"message": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c merge: \u043e\u0441\u0442\u0430\u043b\u0438\u0441\u044c \u043d\u0435\u0440\u0430\u0437\u0440\u0435\u0448\u0451\u043d\u043d\u044b\u0435 \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u044b.",
"unresolved_files": unmerged_files,
},
)
try:
normalized_message = str(message or "").strip()
if normalized_message:
repo.git.commit("-m", normalized_message)
else:
repo.git.commit("--no-edit")
except GitCommandError as e:
details = str(e)
lowered = details.lower()
if "nothing to commit" in lowered:
return {"status": "already_clean"}
raise HTTPException(status_code=409, detail=f"Cannot continue merge: {details}")
commit_hash = ""
try:
commit_hash = repo.head.commit.hexsha
except Exception:
commit_hash = ""
return {"status": "committed", "commit_hash": commit_hash}
# [/DEF:continue_merge:Function]
# [DEF:promote_direct_merge:Function]
# @PURPOSE: Perform direct merge between branches in local repo and push target branch.
# @PRE: Repository exists and both branches are valid.
# @POST: Target branch contains merged changes from source branch.
# @RETURN: Dict[str, Any]
def promote_direct_merge(self, dashboard_id: int, from_branch: str, to_branch: str) -> Dict[str, Any]:
with belief_scope("GitService.promote_direct_merge"):
if not from_branch or not to_branch:
raise HTTPException(status_code=400, detail="from_branch and to_branch are required")
repo = self.get_repo(dashboard_id)
source = from_branch.strip()
target = to_branch.strip()
if source == target:
raise HTTPException(status_code=400, detail="from_branch and to_branch must be different")
try:
origin = repo.remote(name="origin")
except ValueError:
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
try:
origin.fetch()
if source not in [head.name for head in repo.heads]:
if f"origin/{source}" in [ref.name for ref in repo.refs]:
repo.git.checkout("-b", source, f"origin/{source}")
else:
raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found")
if target in [head.name for head in repo.heads]:
repo.git.checkout(target)
elif f"origin/{target}" in [ref.name for ref in repo.refs]:
repo.git.checkout("-b", target, f"origin/{target}")
else:
raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found")
try:
origin.pull(target)
except Exception:
pass
repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}")
origin.push(refspec=f"{target}:{target}")
except HTTPException:
raise
except Exception as e:
message = str(e)
if "CONFLICT" in message.upper():
raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}")
raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}")
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
# [/DEF:promote_direct_merge:Function]
# [/DEF:GitServiceMergeMixin:Class]
# [/DEF:GitServiceMergeMixin:Module]

View File

@@ -0,0 +1,186 @@
# [DEF:GitServiceRemoteMixin:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, github, gitlab, api, provider, repository, pull_request, merge_request
# @PURPOSE: GitHub and GitLab provider operations for GitService — repository creation and PR/MR creation.
# @RELATION: USED_BY -> [GitService]
import httpx
from typing import Any, Dict, Optional
from urllib.parse import quote
from fastapi import HTTPException
from src.core.logger import logger, belief_scope
# [DEF:GitServiceGithubMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing GitHub API operations for GitService.
class GitServiceGithubMixin:
# [DEF:create_github_repository:Function]
# @PURPOSE: Create repository in GitHub or GitHub Enterprise.
# @PRE: PAT has repository create permission.
# @POST: Returns created repository payload.
# @RETURN: dict
async def create_github_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
) -> Dict[str, Any]:
base_url = self._normalize_git_server_url(server_url)
if "github.com" in base_url:
api_url = "https://api.github.com/user/repos"
else:
api_url = f"{base_url}/api/v3/user/repos"
headers = {
"Authorization": f"token {pat.strip()}",
"Content-Type": "application/json",
"Accept": "application/vnd.github+json",
}
payload: Dict[str, Any] = {"name": name, "private": bool(private), "auto_init": bool(auto_init)}
if description:
payload["description"] = description
if default_branch:
payload["default_branch"] = default_branch
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(api_url, headers=headers, json=payload)
except Exception as e:
raise HTTPException(status_code=503, detail=f"GitHub API is unavailable: {str(e)}")
if response.status_code >= 400:
detail = response.text
try:
parsed = response.json()
detail = parsed.get("message") or detail
except Exception:
pass
raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
return response.json()
# [/DEF:create_github_repository:Function]
# [DEF:create_github_pull_request:Function]
# @PURPOSE: Create pull request in GitHub or GitHub Enterprise.
# @PRE: Config and remote URL are valid.
# @POST: Returns normalized PR metadata.
# @RETURN: Dict[str, Any]
async def create_github_pull_request(
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
title: str, description: Optional[str] = None, draft: bool = False,
) -> Dict[str, Any]:
identity = self._parse_remote_repo_identity(remote_url)
base_url = self._normalize_git_server_url(server_url)
if "github.com" in base_url:
api_url = f"https://api.github.com/repos/{identity['namespace']}/{identity['repo']}/pulls"
else:
api_url = f"{base_url}/api/v3/repos/{identity['namespace']}/{identity['repo']}/pulls"
headers = {
"Authorization": f"token {pat.strip()}",
"Content-Type": "application/json",
"Accept": "application/vnd.github+json",
}
payload = {
"title": title, "head": from_branch, "base": to_branch,
"body": description or "", "draft": bool(draft),
}
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(api_url, headers=headers, json=payload)
except Exception as e:
raise HTTPException(status_code=503, detail=f"GitHub API is unavailable: {str(e)}")
if response.status_code >= 400:
detail = response.text
try:
detail = response.json().get("message") or detail
except Exception:
pass
raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
data = response.json()
return {"id": data.get("number") or data.get("id"), "url": data.get("html_url") or data.get("url"), "status": data.get("state") or "open"}
# [/DEF:create_github_pull_request:Function]
# [DEF:GitServiceGitlabMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing GitLab API operations for GitService.
class GitServiceGitlabMixin:
# [DEF:create_gitlab_repository:Function]
# @PURPOSE: Create repository(project) in GitLab.
# @PRE: PAT has api scope.
# @POST: Returns created repository payload.
# @RETURN: dict
async def create_gitlab_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: Optional[str] = None, auto_init: bool = True, default_branch: Optional[str] = "main",
) -> Dict[str, Any]:
base_url = self._normalize_git_server_url(server_url)
api_url = f"{base_url}/api/v4/projects"
headers = {"PRIVATE-TOKEN": pat.strip(), "Content-Type": "application/json", "Accept": "application/json"}
payload: Dict[str, Any] = {
"name": name, "visibility": "private" if private else "public",
"initialize_with_readme": bool(auto_init),
}
if description:
payload["description"] = description
if default_branch:
payload["default_branch"] = default_branch
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(api_url, headers=headers, json=payload)
except Exception as e:
raise HTTPException(status_code=503, detail=f"GitLab API is unavailable: {str(e)}")
if response.status_code >= 400:
detail = response.text
try:
parsed = response.json()
if isinstance(parsed, dict):
detail = parsed.get("message") or detail
except Exception:
pass
raise HTTPException(status_code=response.status_code, detail=f"GitLab API error: {detail}")
data = response.json()
if "clone_url" not in data:
data["clone_url"] = data.get("http_url_to_repo")
if "html_url" not in data:
data["html_url"] = data.get("web_url")
if "ssh_url" not in data:
data["ssh_url"] = data.get("ssh_url_to_repo")
if "full_name" not in data:
data["full_name"] = data.get("path_with_namespace") or data.get("name")
return data
# [/DEF:create_gitlab_repository:Function]
# [DEF:create_gitlab_merge_request:Function]
# @PURPOSE: Create merge request in GitLab.
# @PRE: Config and remote URL are valid.
# @POST: Returns normalized MR metadata.
# @RETURN: Dict[str, Any]
async def create_gitlab_merge_request(
self, server_url: str, pat: str, remote_url: str, from_branch: str, to_branch: str,
title: str, description: Optional[str] = None, remove_source_branch: bool = False,
) -> Dict[str, Any]:
identity = self._parse_remote_repo_identity(remote_url)
base_url = self._normalize_git_server_url(server_url)
project_id = quote(identity["full_name"], safe="")
api_url = f"{base_url}/api/v4/projects/{project_id}/merge_requests"
headers = {"PRIVATE-TOKEN": pat.strip(), "Content-Type": "application/json", "Accept": "application/json"}
payload = {
"source_branch": from_branch, "target_branch": to_branch, "title": title,
"description": description or "", "remove_source_branch": bool(remove_source_branch),
}
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(api_url, headers=headers, json=payload)
except Exception as e:
raise HTTPException(status_code=503, detail=f"GitLab API is unavailable: {str(e)}")
if response.status_code >= 400:
detail = response.text
try:
parsed = response.json()
if isinstance(parsed, dict):
detail = parsed.get("message") or detail
except Exception:
pass
raise HTTPException(status_code=response.status_code, detail=f"GitLab API error: {detail}")
data = response.json()
return {"id": data.get("iid") or data.get("id"), "url": data.get("web_url") or data.get("url"), "status": data.get("state") or "opened"}
# [/DEF:create_gitlab_merge_request:Function]
# [/DEF:GitServiceGitlabMixin:Class]
# [/DEF:GitServiceRemoteMixin:Module]

View File

@@ -0,0 +1,164 @@
# [DEF:GitServiceStatusMixin:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, status, diff, history, porcelain
# @PURPOSE: Status, diff, and commit history operations for GitService using git status --porcelain to avoid --cached flag issues.
# @RELATION: USED_BY -> [GitService]
from typing import Any, Dict, List
from datetime import datetime
from git import Repo
from src.core.logger import logger, belief_scope
# [DEF:GitServiceStatusMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing repository status, diff, and commit history for GitService.
class GitServiceStatusMixin:
# [DEF:_parse_status_porcelain:Function]
# @COMPLEXITY: 2
# @PURPOSE: Parse git status --porcelain output into staged, modified, and untracked file lists.
# @PRE: `repo` is an open GitPython Repo instance.
# @POST: Returns (staged, modified, untracked) tuple of file path lists.
# @RATIONALE: Avoids repo.is_dirty() / repo.index.diff("HEAD") which internally
# call git diff --cached, a flag unsupported in some Git environments (exit 129).
# Using git status --porcelain is self-contained and avoids the --cached flag entirely.
def _parse_status_porcelain(self, repo) -> tuple[list[str], list[str], list[str]]:
with belief_scope("GitService._parse_status_porcelain"):
staged: list[str] = []
modified: list[str] = []
untracked: list[str] = []
try:
output = repo.git.status("--porcelain")
except Exception:
logger.warning("[_parse_status_porcelain][Coherence:Failed] git status --porcelain failed")
return staged, modified, untracked
for line in output.split("\n"):
if not line:
continue
if line.startswith("??"):
untracked.append(line[2:].strip())
continue
if line.startswith("!!"):
continue
if len(line) < 3:
continue
x = line[0]
y = line[1]
rest = line[3:]
path = rest.split(" -> ")[-1].strip() if " -> " in rest else rest.strip()
if x != " " and x != "?":
staged.append(path)
if y != " " and y != "?":
if path not in modified:
modified.append(path)
return staged, modified, untracked
# [/DEF:_parse_status_porcelain:Function]
# [DEF:get_status:Function]
# @PURPOSE: Get current repository status (dirty files, untracked, etc.)
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a dictionary representing the Git status.
# @RETURN: dict
def get_status(self, dashboard_id: int) -> dict:
with belief_scope("GitService.get_status"):
repo = self.get_repo(dashboard_id)
has_commits = False
try:
repo.head.commit
has_commits = True
except (ValueError, Exception):
has_commits = False
current_branch = repo.active_branch.name
tracking_branch = None
has_upstream = False
ahead_count = 0
behind_count = 0
try:
tracking_branch = repo.active_branch.tracking_branch()
has_upstream = tracking_branch is not None
except Exception:
tracking_branch = None
has_upstream = False
if has_upstream and tracking_branch is not None:
try:
ahead_count = sum(1 for _ in repo.iter_commits(f"{tracking_branch.name}..{current_branch}"))
behind_count = sum(1 for _ in repo.iter_commits(f"{current_branch}..{tracking_branch.name}"))
except Exception:
ahead_count = 0
behind_count = 0
staged_files, modified_files, untracked_files = self._parse_status_porcelain(repo)
is_dirty = bool(staged_files or modified_files or untracked_files)
is_diverged = ahead_count > 0 and behind_count > 0
if is_diverged:
sync_state = "DIVERGED"
elif behind_count > 0:
sync_state = "BEHIND_REMOTE"
elif ahead_count > 0:
sync_state = "AHEAD_REMOTE"
elif is_dirty or modified_files or staged_files or untracked_files:
sync_state = "CHANGES"
else:
sync_state = "SYNCED"
return {
"is_dirty": is_dirty,
"untracked_files": untracked_files,
"modified_files": modified_files,
"staged_files": staged_files,
"current_branch": current_branch,
"upstream_branch": tracking_branch.name if tracking_branch is not None else None,
"has_upstream": has_upstream,
"ahead_count": ahead_count,
"behind_count": behind_count,
"is_diverged": is_diverged,
"sync_state": sync_state,
}
# [/DEF:get_status:Function]
# [DEF:get_diff:Function]
# @PURPOSE: Generate diff for a file or the whole repository.
# @PARAM: file_path (str) - Optional specific file.
# @PARAM: staged (bool) - Whether to show staged changes.
# @PRE: Repository for dashboard_id exists.
# @POST: Returns the diff text as a string.
# @RETURN: str
def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
with belief_scope("GitService.get_diff"):
repo = self.get_repo(dashboard_id)
diff_args = []
if staged:
diff_args.append("--staged")
if file_path:
return repo.git.diff(*diff_args, "--", file_path)
return repo.git.diff(*diff_args)
# [/DEF:get_diff:Function]
# [DEF:get_commit_history:Function]
# @PURPOSE: Retrieve commit history for a repository.
# @PARAM: limit (int) - Max number of commits to return.
# @PRE: Repository for dashboard_id exists.
# @POST: Returns a list of dictionaries for each commit in history.
# @RETURN: List[dict]
def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]:
with belief_scope("GitService.get_commit_history"):
repo = self.get_repo(dashboard_id)
commits = []
try:
if not repo.heads and not repo.remotes:
return []
for commit in repo.iter_commits(max_count=limit):
commits.append({
"hash": commit.hexsha,
"author": commit.author.name,
"email": commit.author.email,
"timestamp": datetime.fromtimestamp(commit.committed_date),
"message": commit.message.strip(),
"files_changed": list(commit.stats.files.keys())
})
except Exception as e:
logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
return []
return commits
# [/DEF:get_commit_history:Function]
# [/DEF:GitServiceStatusMixin:Class]
# [/DEF:GitServiceStatusMixin:Module]

View File

@@ -0,0 +1,177 @@
# [DEF:GitServiceSyncMixin:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, push, pull, sync, remote
# @PURPOSE: Push and pull operations for GitService with origin host auto-alignment.
# @RELATION: USED_BY -> [GitService]
# @RELATION: DEPENDS_ON -> [GitServiceUrlMixin]
import os
from typing import Optional
from git import Repo
from git.exc import GitCommandError
from fastapi import HTTPException
from src.core.database import SessionLocal
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository, GitServerConfig
# [DEF:GitServiceSyncMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing push and pull operations with origin host alignment.
class GitServiceSyncMixin:
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to remote.
# @PRE: Repository exists and has an 'origin' remote.
# @POST: Local branch commits are pushed to origin.
def push_changes(self, dashboard_id: int):
with belief_scope("GitService.push_changes"):
repo = self.get_repo(dashboard_id)
if not repo.heads:
logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}")
return
try:
origin = repo.remote(name='origin')
except ValueError:
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")
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),
)
try:
current_branch = repo.active_branch
logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin")
tracking_branch = None
try:
tracking_branch = current_branch.tracking_branch()
except Exception:
tracking_branch = None
if tracking_branch is None:
repo.git.push("--set-upstream", "origin", f"{current_branch.name}:{current_branch.name}")
else:
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
for info in push_info:
if info.flags & info.ERROR:
logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}")
raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}")
except GitCommandError as e:
details = str(e)
lowered = details.lower()
if "non-fast-forward" in lowered or "rejected" in lowered:
raise HTTPException(
status_code=409,
detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.",
)
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
raise HTTPException(status_code=500, detail=f"Git push failed: {details}")
except Exception as e:
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}")
raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}")
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from remote.
# @PRE: Repository exists and has an 'origin' remote.
# @POST: Changes from origin are pulled and merged into the active branch.
def pull_changes(self, dashboard_id: int):
with belief_scope("GitService.pull_changes"):
repo = self.get_repo(dashboard_id)
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
if os.path.exists(merge_head_path):
payload = self._build_unfinished_merge_payload(repo)
logger.warning(
"[pull_changes][Action] Unfinished merge detected for dashboard %s (repo_path=%s git_dir=%s branch=%s merge_head=%s merge_msg=%s)",
dashboard_id, payload["repository_path"], payload["git_dir"],
payload["current_branch"], payload["merge_head"], payload["merge_message_preview"],
)
raise HTTPException(status_code=409, detail=payload)
try:
origin = repo.remote(name='origin')
current_branch = repo.active_branch.name
try:
origin_urls = list(origin.urls)
except Exception:
origin_urls = []
logger.info(
"[pull_changes][Action] Pull diagnostics dashboard=%s repo_path=%s branch=%s origin_urls=%s",
dashboard_id, repo.working_tree_dir, current_branch, origin_urls,
)
origin.fetch(prune=True)
remote_ref = f"origin/{current_branch}"
has_remote_branch = any(ref.name == remote_ref for ref in repo.refs)
logger.info(
"[pull_changes][Action] Pull remote branch check dashboard=%s branch=%s remote_ref=%s exists=%s",
dashboard_id, current_branch, remote_ref, has_remote_branch,
)
if not has_remote_branch:
raise HTTPException(
status_code=409,
detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.",
)
logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}")
repo.git.pull("--no-rebase", "origin", current_branch)
except ValueError:
logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
except GitCommandError as e:
details = str(e)
lowered = details.lower()
if "conflict" in lowered or "not possible to fast-forward" in lowered:
raise HTTPException(
status_code=409,
detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
)
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
raise HTTPException(status_code=500, detail=f"Git pull failed: {details}")
except HTTPException:
raise
except Exception as e:
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}")
raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}")
# [/DEF:pull_changes:Function]
# [/DEF:GitServiceSyncMixin:Class]
# [/DEF:GitServiceSyncMixin:Module]

View File

@@ -0,0 +1,212 @@
# [DEF:GitServiceUrlMixin:Module]
# @COMPLEXITY: 3
# @LAYER: Infra
# @SEMANTICS: git, url, parsing, normalization, host_alignment
# @PURPOSE: URL helper mixin for GitService — parse, normalize, align, and strip credentials from Git URLs.
# @RELATION: USED_BY -> [GitServiceSyncMixin]
# @RELATION: USED_BY -> [GitServiceGiteaMixin]
# @RELATION: USED_BY -> [GitServiceRemoteMixin]
import os
from typing import Any, Dict, List, Optional
from urllib.parse import quote, urlparse
from fastapi import HTTPException
from src.core.database import SessionLocal
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository, GitServerConfig
# [DEF:GitServiceUrlMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: URL helper methods for GitService — extract host, strip credentials, replace host, align origin, parse remote identity, derive server URL, normalize server URL.
class GitServiceUrlMixin:
# [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"} or 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:_parse_remote_repo_identity:Function]
# @PURPOSE: Parse owner/repo from remote URL for Git server API operations.
# @PRE: remote_url is a valid git URL.
# @POST: Returns owner/repo tokens.
# @RETURN: Dict[str, str]
def _parse_remote_repo_identity(self, remote_url: str) -> Dict[str, str]:
normalized = str(remote_url or "").strip()
if not normalized:
raise HTTPException(status_code=400, detail="Repository remote_url is empty")
if normalized.startswith("git@"):
path = normalized.split(":", 1)[1] if ":" in normalized else ""
else:
parsed = urlparse(normalized)
path = parsed.path or ""
path = path.strip("/")
if path.endswith(".git"):
path = path[:-4]
parts = [segment for segment in path.split("/") if segment]
if len(parts) < 2:
raise HTTPException(status_code=400, detail=f"Cannot parse repository owner/name from remote URL: {remote_url}")
owner = parts[0]
repo = parts[-1]
namespace = "/".join(parts[:-1])
return {"owner": owner, "repo": repo, "namespace": namespace, "full_name": f"{namespace}/{repo}"}
# [/DEF:_parse_remote_repo_identity:Function]
# [DEF:_derive_server_url_from_remote:Function]
# @PURPOSE: Build API base URL from remote repository URL without credentials.
# @PRE: remote_url may be any git URL.
# @POST: Returns normalized http(s) base URL or None when derivation is impossible.
# @RETURN: Optional[str]
def _derive_server_url_from_remote(self, remote_url: str) -> Optional[str]:
normalized = str(remote_url or "").strip()
if not normalized or normalized.startswith("git@"):
return None
parsed = urlparse(normalized)
if parsed.scheme not in {"http", "https"}:
return None
if not parsed.hostname:
return None
netloc = parsed.hostname
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
return f"{parsed.scheme}://{netloc}".rstrip("/")
# [/DEF:_derive_server_url_from_remote:Function]
# [DEF:_normalize_git_server_url:Function]
# @PURPOSE: Normalize Git server URL for provider API calls.
# @PRE: raw_url is non-empty.
# @POST: Returns URL without trailing slash.
# @RETURN: str
def _normalize_git_server_url(self, raw_url: str) -> str:
normalized = (raw_url or "").strip()
if not normalized:
raise HTTPException(status_code=400, detail="Git server URL is required")
return normalized.rstrip("/")
# [/DEF:_normalize_git_server_url:Function]
# [/DEF:GitServiceUrlMixin:Class]
# [/DEF:GitServiceUrlMixin:Module]