1945 lines
83 KiB
Python
1945 lines
83 KiB
Python
# [DEF:backend.src.services.git_service:Module]
|
||
#
|
||
# @TIER: STANDARD
|
||
# @SEMANTICS: git, service, gitpython, repository, version_control
|
||
# @PURPOSE: Core Git logic using GitPython to manage dashboard repositories.
|
||
# @LAYER: Service
|
||
# @RELATION: INHERITS_FROM -> None
|
||
# @RELATION: USED_BY -> src.api.routes.git
|
||
# @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.
|
||
|
||
import os
|
||
import httpx
|
||
import re
|
||
import shutil
|
||
from git import Repo
|
||
from git.exc import GitCommandError
|
||
from git.exc import InvalidGitRepositoryError, NoSuchPathError
|
||
from git.objects.blob import Blob
|
||
from fastapi import HTTPException
|
||
from typing import Any, Dict, List, Optional
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from urllib.parse import quote, urlparse
|
||
from src.core.logger import logger, belief_scope
|
||
from src.models.git import GitProvider
|
||
from src.models.git import GitRepository, GitServerConfig
|
||
from src.models.config import AppConfigRecord
|
||
from src.core.database import SessionLocal
|
||
|
||
# [DEF:GitService:Class]
|
||
# @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling.
|
||
class GitService:
|
||
"""
|
||
Wrapper for GitPython operations.
|
||
"""
|
||
|
||
# [DEF:__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[2]
|
||
self.legacy_base_path = str((backend_root / "git_repos").resolve())
|
||
self.base_path = self._resolve_base_path(base_path)
|
||
self._ensure_base_path_exists()
|
||
# [/DEF:__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.
|
||
# @RETURN: None
|
||
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}")
|
||
base.mkdir(parents=True, exist_ok=True)
|
||
# [/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:
|
||
# 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:_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.
|
||
# @RETURN: None
|
||
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)}",
|
||
)
|
||
|
||
# Keep default working branch on DEV for day-to-day changes.
|
||
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:_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)
|
||
|
||
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)
|
||
# @PARAM: remote_url (str)
|
||
# @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.
|
||
# @POST: Repository is cloned or opened at the local path.
|
||
# @RETURN: Repo - GitPython Repo object.
|
||
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)
|
||
|
||
# Inject PAT into remote URL if needed
|
||
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}"
|
||
)
|
||
if os.path.isdir(repo_path):
|
||
shutil.rmtree(repo_path)
|
||
else:
|
||
os.remove(repo_path)
|
||
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.
|
||
# @RETURN: None
|
||
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.
|
||
# @RETURN: None
|
||
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: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 = []
|
||
|
||
# Add existing refs
|
||
for ref in repo.refs:
|
||
try:
|
||
# Strip prefixes for UI
|
||
name = ref.name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
|
||
|
||
# Avoid duplicates (e.g. local and remote with same name)
|
||
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}")
|
||
|
||
# Ensure the current active branch is in the list even if it has no commits or refs
|
||
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 everything else failed and list is still empty, add default
|
||
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}")
|
||
|
||
# Handle empty repository case (no commits)
|
||
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")
|
||
|
||
# Verify source branch exists
|
||
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)
|
||
|
||
# Check if there are any changes to commit
|
||
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:_extract_http_host:Function]
|
||
# @PURPOSE: Extract normalized host[:port] from HTTP(S) URL.
|
||
# @PRE: url_value may be empty.
|
||
# @POST: Returns lowercase host token or None.
|
||
# @RETURN: Optional[str]
|
||
def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]:
|
||
normalized = str(url_value or "").strip()
|
||
if not normalized:
|
||
return None
|
||
try:
|
||
parsed = urlparse(normalized)
|
||
except Exception:
|
||
return None
|
||
if parsed.scheme not in {"http", "https"}:
|
||
return None
|
||
host = parsed.hostname
|
||
if not host:
|
||
return None
|
||
if parsed.port:
|
||
return f"{host.lower()}:{parsed.port}"
|
||
return host.lower()
|
||
# [/DEF:_extract_http_host:Function]
|
||
|
||
# [DEF:_strip_url_credentials:Function]
|
||
# @PURPOSE: Remove credentials from URL while preserving scheme/host/path.
|
||
# @PRE: url_value may contain credentials.
|
||
# @POST: Returns URL without username/password.
|
||
# @RETURN: str
|
||
def _strip_url_credentials(self, url_value: str) -> str:
|
||
normalized = str(url_value or "").strip()
|
||
if not normalized:
|
||
return normalized
|
||
try:
|
||
parsed = urlparse(normalized)
|
||
except Exception:
|
||
return normalized
|
||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||
return normalized
|
||
host = parsed.hostname
|
||
if parsed.port:
|
||
host = f"{host}:{parsed.port}"
|
||
return parsed._replace(netloc=host).geturl()
|
||
# [/DEF:_strip_url_credentials:Function]
|
||
|
||
# [DEF:_replace_host_in_url:Function]
|
||
# @PURPOSE: Replace source URL host with host from configured server URL.
|
||
# @PRE: source_url and config_url are HTTP(S) URLs.
|
||
# @POST: Returns source URL with updated host (credentials preserved) or None.
|
||
# @RETURN: Optional[str]
|
||
def _replace_host_in_url(self, source_url: Optional[str], config_url: Optional[str]) -> Optional[str]:
|
||
source = str(source_url or "").strip()
|
||
config = str(config_url or "").strip()
|
||
if not source or not config:
|
||
return None
|
||
try:
|
||
source_parsed = urlparse(source)
|
||
config_parsed = urlparse(config)
|
||
except Exception:
|
||
return None
|
||
|
||
if source_parsed.scheme not in {"http", "https"}:
|
||
return None
|
||
if config_parsed.scheme not in {"http", "https"}:
|
||
return None
|
||
if not source_parsed.hostname or not config_parsed.hostname:
|
||
return None
|
||
|
||
target_host = config_parsed.hostname
|
||
if config_parsed.port:
|
||
target_host = f"{target_host}:{config_parsed.port}"
|
||
|
||
auth_part = ""
|
||
if source_parsed.username:
|
||
auth_part = quote(source_parsed.username, safe="")
|
||
if source_parsed.password is not None:
|
||
auth_part = f"{auth_part}:{quote(source_parsed.password, safe='')}"
|
||
auth_part = f"{auth_part}@"
|
||
|
||
new_netloc = f"{auth_part}{target_host}"
|
||
return source_parsed._replace(netloc=new_netloc).geturl()
|
||
# [/DEF:_replace_host_in_url:Function]
|
||
|
||
# [DEF:_align_origin_host_with_config:Function]
|
||
# @PURPOSE: Auto-align local origin host to configured Git server host when they drift.
|
||
# @PRE: origin remote exists.
|
||
# @POST: origin URL host updated and DB binding normalized when mismatch detected.
|
||
# @RETURN: Optional[str]
|
||
def _align_origin_host_with_config(
|
||
self,
|
||
dashboard_id: int,
|
||
origin,
|
||
config_url: Optional[str],
|
||
current_origin_url: Optional[str],
|
||
binding_remote_url: Optional[str],
|
||
) -> Optional[str]:
|
||
config_host = self._extract_http_host(config_url)
|
||
source_origin_url = str(current_origin_url or "").strip() or str(binding_remote_url or "").strip()
|
||
origin_host = self._extract_http_host(source_origin_url)
|
||
|
||
if not config_host or not origin_host:
|
||
return None
|
||
if config_host == origin_host:
|
||
return None
|
||
|
||
aligned_url = self._replace_host_in_url(source_origin_url, config_url)
|
||
if not aligned_url:
|
||
return None
|
||
|
||
logger.warning(
|
||
"[_align_origin_host_with_config][Action] Host mismatch for dashboard %s: config_host=%s origin_host=%s, applying origin.set_url",
|
||
dashboard_id,
|
||
config_host,
|
||
origin_host,
|
||
)
|
||
|
||
try:
|
||
origin.set_url(aligned_url)
|
||
except Exception as e:
|
||
logger.warning(
|
||
"[_align_origin_host_with_config][Coherence:Failed] Failed to set origin URL for dashboard %s: %s",
|
||
dashboard_id,
|
||
e,
|
||
)
|
||
return None
|
||
|
||
try:
|
||
session = SessionLocal()
|
||
try:
|
||
db_repo = (
|
||
session.query(GitRepository)
|
||
.filter(GitRepository.dashboard_id == int(dashboard_id))
|
||
.first()
|
||
)
|
||
if db_repo:
|
||
db_repo.remote_url = self._strip_url_credentials(aligned_url)
|
||
session.commit()
|
||
finally:
|
||
session.close()
|
||
except Exception as e:
|
||
logger.warning(
|
||
"[_align_origin_host_with_config][Action] Failed to persist aligned remote_url for dashboard %s: %s",
|
||
dashboard_id,
|
||
e,
|
||
)
|
||
|
||
return aligned_url
|
||
# [/DEF:_align_origin_host_with_config:Function]
|
||
|
||
# [DEF:push_changes:Function]
|
||
# @PURPOSE: Push local commits to remote.
|
||
# @PRE: Repository exists and has an 'origin' remote.
|
||
# @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)
|
||
|
||
# Ensure we have something to push
|
||
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")
|
||
|
||
# Emit diagnostic context to verify config-url vs repository-origin mismatch.
|
||
try:
|
||
origin_urls = list(origin.urls)
|
||
except Exception:
|
||
origin_urls = []
|
||
binding_remote_url = None
|
||
binding_config_id = None
|
||
binding_config_url = None
|
||
try:
|
||
session = SessionLocal()
|
||
try:
|
||
db_repo = (
|
||
session.query(GitRepository)
|
||
.filter(GitRepository.dashboard_id == int(dashboard_id))
|
||
.first()
|
||
)
|
||
if db_repo:
|
||
binding_remote_url = db_repo.remote_url
|
||
binding_config_id = db_repo.config_id
|
||
db_config = (
|
||
session.query(GitServerConfig)
|
||
.filter(GitServerConfig.id == db_repo.config_id)
|
||
.first()
|
||
)
|
||
if db_config:
|
||
binding_config_url = db_config.url
|
||
finally:
|
||
session.close()
|
||
except Exception as diag_error:
|
||
logger.warning(
|
||
"[push_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s",
|
||
dashboard_id,
|
||
diag_error,
|
||
)
|
||
|
||
realigned_origin_url = self._align_origin_host_with_config(
|
||
dashboard_id=dashboard_id,
|
||
origin=origin,
|
||
config_url=binding_config_url,
|
||
current_origin_url=(origin_urls[0] if origin_urls else None),
|
||
binding_remote_url=binding_remote_url,
|
||
)
|
||
try:
|
||
origin_urls = list(origin.urls)
|
||
except Exception:
|
||
origin_urls = []
|
||
|
||
logger.info(
|
||
"[push_changes][Action] Push diagnostics dashboard=%s config_id=%s config_url=%s binding_remote_url=%s origin_urls=%s origin_realigned=%s",
|
||
dashboard_id,
|
||
binding_config_id,
|
||
binding_config_url,
|
||
binding_remote_url,
|
||
origin_urls,
|
||
bool(realigned_origin_url),
|
||
)
|
||
|
||
# Check if current branch has an upstream
|
||
try:
|
||
current_branch = repo.active_branch
|
||
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
|
||
|
||
# First push for a new branch must set upstream, otherwise future pull fails.
|
||
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 _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 _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 _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": (
|
||
"В репозитории есть незавершённое слияние. "
|
||
"Завершите или отмените слияние вручную."
|
||
),
|
||
"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": [
|
||
"Откройте локальный репозиторий по пути repository_path",
|
||
"Проверьте состояние: git status",
|
||
"Разрешите конфликты и выполните commit, либо отмените: git merge --abort",
|
||
"После завершения/отмены слияния повторите Pull из интерфейса",
|
||
],
|
||
"manual_commands": [
|
||
"git status",
|
||
"git add <resolved-files>",
|
||
"git commit -m \"resolve merge conflicts\"",
|
||
"git merge --abort",
|
||
],
|
||
}
|
||
|
||
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_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 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 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 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": "Невозможно завершить merge: остались неразрешённые конфликты.",
|
||
"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 pull_changes(self, dashboard_id: int):
|
||
with belief_scope("GitService.pull_changes"):
|
||
repo = self.get_repo(dashboard_id)
|
||
|
||
# Check for unfinished merge (MERGE_HEAD exists)
|
||
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}")
|
||
# Force deterministic merge strategy for modern git versions.
|
||
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: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)
|
||
|
||
# Handle empty repository (no commits)
|
||
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:
|
||
# Commits present locally but not in upstream.
|
||
ahead_count = sum(
|
||
1 for _ in repo.iter_commits(f"{tracking_branch.name}..{current_branch}")
|
||
)
|
||
# Commits present in upstream but not local.
|
||
behind_count = sum(
|
||
1 for _ in repo.iter_commits(f"{current_branch}..{tracking_branch.name}")
|
||
)
|
||
except Exception:
|
||
ahead_count = 0
|
||
behind_count = 0
|
||
|
||
is_dirty = repo.is_dirty(untracked_files=True)
|
||
untracked_files = repo.untracked_files
|
||
modified_files = [item.a_path for item in repo.index.diff(None)]
|
||
staged_files = [item.a_path for item in repo.index.diff("HEAD")] if has_commits else []
|
||
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:
|
||
# Check if there are any commits at all
|
||
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:test_connection:Function]
|
||
# @PURPOSE: Test connection to Git provider using PAT.
|
||
# @PARAM: provider (GitProvider)
|
||
# @PARAM: url (str)
|
||
# @PARAM: 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"):
|
||
# Check for offline mode or local-only URLs
|
||
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:_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:_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 = {
|
||
"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_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
|
||
# GitHub API does not reliably support setting default branch on create without template/import.
|
||
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_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()
|
||
# Normalize clone URL key to keep route response stable.
|
||
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:_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@"):
|
||
# git@host:owner/repo.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: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()
|
||
# Ensure local source branch exists.
|
||
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")
|
||
|
||
# Ensure local target branch exists and is checked out.
|
||
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")
|
||
|
||
# Bring target up to date and merge source into target.
|
||
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: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)
|
||
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=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=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: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: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:GitService:Class]
|
||
# [/DEF:backend.src.services.git_service:Module]
|