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:
319
backend/src/services/git/_base.py
Normal file
319
backend/src/services/git/_base.py
Normal 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]
|
||||
Reference in New Issue
Block a user