Files
ss-tools/backend/src/services/git_service.py
2026-03-09 13:19:06 +03:00

1945 lines
83 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# [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]