fix repo place
This commit is contained in:
Submodule backend/git_repos/10 deleted from dec289695f
@@ -175,6 +175,38 @@ def _resolve_dashboard_id_from_ref(
|
|||||||
return dashboard_id
|
return dashboard_id
|
||||||
# [/DEF:_resolve_dashboard_id_from_ref:Function]
|
# [/DEF:_resolve_dashboard_id_from_ref:Function]
|
||||||
|
|
||||||
|
|
||||||
|
# [DEF:_resolve_repo_key_from_ref:Function]
|
||||||
|
# @PURPOSE: Resolve repository folder key with slug-first strategy and deterministic fallback.
|
||||||
|
# @PRE: dashboard_id is resolved and valid.
|
||||||
|
# @POST: Returns safe key to be used in local repository path.
|
||||||
|
# @RETURN: str
|
||||||
|
def _resolve_repo_key_from_ref(
|
||||||
|
dashboard_ref: str,
|
||||||
|
dashboard_id: int,
|
||||||
|
config_manager,
|
||||||
|
env_id: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
normalized_ref = str(dashboard_ref or "").strip()
|
||||||
|
if normalized_ref and not normalized_ref.isdigit():
|
||||||
|
return normalized_ref
|
||||||
|
|
||||||
|
if env_id:
|
||||||
|
try:
|
||||||
|
environments = config_manager.get_environments()
|
||||||
|
env = next((e for e in environments if e.id == env_id), None)
|
||||||
|
if env:
|
||||||
|
payload = SupersetClient(env).get_dashboard(dashboard_id)
|
||||||
|
dashboard_data = payload.get("result", payload) if isinstance(payload, dict) else {}
|
||||||
|
dashboard_slug = dashboard_data.get("slug")
|
||||||
|
if dashboard_slug:
|
||||||
|
return str(dashboard_slug)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return f"dashboard-{dashboard_id}"
|
||||||
|
# [/DEF:_resolve_repo_key_from_ref:Function]
|
||||||
|
|
||||||
# [DEF:get_git_configs:Function]
|
# [DEF:get_git_configs:Function]
|
||||||
# @PURPOSE: List all configured Git servers.
|
# @PURPOSE: List all configured Git servers.
|
||||||
# @PRE: Database session `db` is available.
|
# @PRE: Database session `db` is available.
|
||||||
@@ -417,6 +449,7 @@ async def init_repository(
|
|||||||
):
|
):
|
||||||
with belief_scope("init_repository"):
|
with belief_scope("init_repository"):
|
||||||
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id)
|
||||||
|
repo_key = _resolve_repo_key_from_ref(dashboard_ref, dashboard_id, config_manager, env_id)
|
||||||
# 1. Get config
|
# 1. Get config
|
||||||
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first()
|
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first()
|
||||||
if not config:
|
if not config:
|
||||||
@@ -425,10 +458,10 @@ async def init_repository(
|
|||||||
try:
|
try:
|
||||||
# 2. Perform Git clone/init
|
# 2. Perform Git clone/init
|
||||||
logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}")
|
logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}")
|
||||||
git_service.init_repo(dashboard_id, init_data.remote_url, config.pat)
|
git_service.init_repo(dashboard_id, init_data.remote_url, config.pat, repo_key=repo_key)
|
||||||
|
|
||||||
# 3. Save to DB
|
# 3. Save to DB
|
||||||
repo_path = git_service._get_repo_path(dashboard_id)
|
repo_path = git_service._get_repo_path(dashboard_id, repo_key=repo_key)
|
||||||
db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
|
db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first()
|
||||||
if not db_repo:
|
if not db_repo:
|
||||||
db_repo = GitRepository(
|
db_repo = GitRepository(
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
# [DEF:backend.src.services.git_service:Module]
|
# [DEF:backend.src.services.git_service:Module]
|
||||||
#
|
#
|
||||||
|
# @TIER: STANDARD
|
||||||
# @SEMANTICS: git, service, gitpython, repository, version_control
|
# @SEMANTICS: git, service, gitpython, repository, version_control
|
||||||
# @PURPOSE: Core Git logic using GitPython to manage dashboard repositories.
|
# @PURPOSE: Core Git logic using GitPython to manage dashboard repositories.
|
||||||
# @LAYER: Service
|
# @LAYER: Service
|
||||||
# @RELATION: INHERITS_FROM -> None
|
# @RELATION: INHERITS_FROM -> None
|
||||||
# @RELATION: USED_BY -> src.api.routes.git
|
# @RELATION: USED_BY -> src.api.routes.git
|
||||||
# @RELATION: USED_BY -> src.plugins.git_plugin
|
# @RELATION: USED_BY -> src.plugins.git_plugin
|
||||||
|
# @RELATION: DEPENDS_ON -> src.core.database.SessionLocal
|
||||||
|
# @RELATION: DEPENDS_ON -> src.models.config.AppConfigRecord
|
||||||
|
# @RELATION: DEPENDS_ON -> src.models.git.GitRepository
|
||||||
#
|
#
|
||||||
# @INVARIANT: All Git operations must be performed on a valid local directory.
|
# @INVARIANT: All Git operations must be performed on a valid local directory.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import httpx
|
import httpx
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
from git import Repo
|
from git import Repo
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
from src.core.logger import logger, belief_scope
|
from src.core.logger import logger, belief_scope
|
||||||
from src.models.git import GitProvider
|
from src.models.git import GitProvider
|
||||||
|
from src.models.git import GitRepository
|
||||||
|
from src.models.config import AppConfigRecord
|
||||||
|
from src.core.database import SessionLocal
|
||||||
|
|
||||||
# [DEF:GitService:Class]
|
# [DEF:GitService:Class]
|
||||||
# @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling.
|
# @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling.
|
||||||
@@ -33,28 +43,164 @@ class GitService:
|
|||||||
# @POST: GitService is initialized; base_path directory exists.
|
# @POST: GitService is initialized; base_path directory exists.
|
||||||
def __init__(self, base_path: str = "git_repos"):
|
def __init__(self, base_path: str = "git_repos"):
|
||||||
with belief_scope("GitService.__init__"):
|
with belief_scope("GitService.__init__"):
|
||||||
# Resolve relative to the backend directory
|
|
||||||
# Path(__file__) is backend/src/services/git_service.py
|
|
||||||
# parents[2] is backend/
|
|
||||||
from pathlib import Path
|
|
||||||
backend_root = Path(__file__).parents[2]
|
backend_root = Path(__file__).parents[2]
|
||||||
|
self.legacy_base_path = str((backend_root / "git_repos").resolve())
|
||||||
self.base_path = str((backend_root / base_path).resolve())
|
self.base_path = self._resolve_base_path(base_path)
|
||||||
if not os.path.exists(self.base_path):
|
if not os.path.exists(self.base_path):
|
||||||
os.makedirs(self.base_path)
|
os.makedirs(self.base_path)
|
||||||
# [/DEF:__init__:Function]
|
# [/DEF:__init__: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:
|
||||||
|
# Resolve relative to backend directory for backward compatibility.
|
||||||
|
backend_root = Path(__file__).parents[2]
|
||||||
|
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[3]
|
||||||
|
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.
|
||||||
|
# @RETURN: None
|
||||||
|
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]
|
# [DEF:_get_repo_path:Function]
|
||||||
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
|
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
|
||||||
# @PARAM: dashboard_id (int)
|
# @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.
|
# @PRE: dashboard_id is an integer.
|
||||||
# @POST: Returns the absolute or relative path to the dashboard's repo.
|
# @POST: Returns DB-local_path when present, otherwise base_path/<normalized repo_key>.
|
||||||
# @RETURN: str
|
# @RETURN: str
|
||||||
def _get_repo_path(self, dashboard_id: int) -> str:
|
def _get_repo_path(self, dashboard_id: int, repo_key: Optional[str] = None) -> str:
|
||||||
with belief_scope("GitService._get_repo_path"):
|
with belief_scope("GitService._get_repo_path"):
|
||||||
if dashboard_id is None:
|
if dashboard_id is None:
|
||||||
raise ValueError("dashboard_id cannot be None")
|
raise ValueError("dashboard_id cannot be None")
|
||||||
return os.path.join(self.base_path, str(dashboard_id))
|
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)
|
||||||
|
|
||||||
|
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:_get_repo_path:Function]
|
||||||
|
|
||||||
# [DEF:init_repo:Function]
|
# [DEF:init_repo:Function]
|
||||||
@@ -62,12 +208,14 @@ class GitService:
|
|||||||
# @PARAM: dashboard_id (int)
|
# @PARAM: dashboard_id (int)
|
||||||
# @PARAM: remote_url (str)
|
# @PARAM: remote_url (str)
|
||||||
# @PARAM: pat (str) - Personal Access Token for authentication.
|
# @PARAM: pat (str) - Personal Access Token for authentication.
|
||||||
|
# @PARAM: repo_key (Optional[str]) - Slug-like key for deterministic folder naming on first init.
|
||||||
# @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
|
# @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
|
||||||
# @POST: Repository is cloned or opened at the local path.
|
# @POST: Repository is cloned or opened at the local path.
|
||||||
# @RETURN: Repo - GitPython Repo object.
|
# @RETURN: Repo - GitPython Repo object.
|
||||||
def init_repo(self, dashboard_id: int, remote_url: str, pat: str) -> 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"):
|
with belief_scope("GitService.init_repo"):
|
||||||
repo_path = self._get_repo_path(dashboard_id)
|
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)
|
||||||
|
|
||||||
# Inject PAT into remote URL if needed
|
# Inject PAT into remote URL if needed
|
||||||
if pat and "://" in remote_url:
|
if pat and "://" in remote_url:
|
||||||
|
|||||||
Reference in New Issue
Block a user