semantics: complete DEF-to-region migration, fix regressions

- Convert legacy [DEF🆔Type] anchors to #region/#endregion across 329 files
- Reinstate _normalize_timestamp_value in sql_generator.py
- Fix MarkerLogger→logger migration in events.py (molecular CoT markers)
- Fix dataset_review orchestrator dependencies (_build_execution_snapshot)
- Fix config_manager stale-record deletion (moved to save path only)
- Add 77 missing [/DEF:] closers in 5 unbalanced test files
- Update assistant_chat.integration.test.js for #region format
- Apply molecular-cot-logging markers (REASON/REFLECT/EXPLORE) via logger.* methods
This commit is contained in:
2026-05-12 23:54:55 +03:00
parent fe8978f716
commit 306c5ae742
331 changed files with 9630 additions and 10312 deletions

View File

@@ -1,6 +1,6 @@
# #region GitServiceModule [C:3] [TYPE Module] [SEMANTICS git, service, decomposition, mixin, composition]
# @LAYER: Infra
# @BRIEF Composed GitService via multiple inheritance from domain-specific mixins.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [GitServiceBase]
# @RELATION DEPENDS_ON -> [GitServiceBranchMixin]
# @RELATION DEPENDS_ON -> [GitServiceSyncMixin]
@@ -10,8 +10,8 @@
# @RELATION DEPENDS_ON -> [GitServiceGiteaMixin]
# @RELATION DEPENDS_ON -> [GitServiceGithubMixin]
# @RELATION DEPENDS_ON -> [GitServiceGitlabMixin]
# @RATIONALE Decomposed from monolithic git_service.py (2101 lines) into
#
# @RATIONALE: Decomposed from monolithic git_service.py (2101 lines) into
# domain-scoped mixins to satisfy INV_7 (module < 400 lines). The composed class
# preserves the original public API surface — all consumers continue to import
# `from src.services.git_service import GitService` without changes.
@@ -32,15 +32,15 @@ __all__ = ["GitService"]
# #region GitService [C:3] [TYPE Class]
# @BRIEF Composed GitService class providing all Git operations — init, clone, branches, commits, push, pull,
# merge, status, diff, history, and provider API operations (Gitea, GitHub, GitLab).
# @RELATION: INHERITS -> [GitServiceBase]
# @RELATION: INHERITS -> [GitServiceBranchMixin]
# @RELATION: INHERITS -> [GitServiceSyncMixin]
# @RELATION: INHERITS -> [GitServiceStatusMixin]
# @RELATION: INHERITS -> [GitServiceMergeMixin]
# @RELATION: INHERITS -> [GitServiceUrlMixin]
# @RELATION: INHERITS -> [GitServiceGiteaMixin]
# @RELATION: INHERITS -> [GitServiceGithubMixin]
# @RELATION: INHERITS -> [GitServiceGitlabMixin]
# @RELATION INHERITS -> [GitServiceBase]
# @RELATION INHERITS -> [GitServiceBranchMixin]
# @RELATION INHERITS -> [GitServiceSyncMixin]
# @RELATION INHERITS -> [GitServiceStatusMixin]
# @RELATION INHERITS -> [GitServiceMergeMixin]
# @RELATION INHERITS -> [GitServiceUrlMixin]
# @RELATION INHERITS -> [GitServiceGiteaMixin]
# @RELATION INHERITS -> [GitServiceGithubMixin]
# @RELATION INHERITS -> [GitServiceGitlabMixin]
class GitService(
GitServiceGiteaMixin,
GitServiceGithubMixin,

View File

@@ -1,6 +1,6 @@
# #region GitServiceBase [C:3] [TYPE Module] [SEMANTICS git, service, repository, version_control, initialization]
# @LAYER: Infra
# @BRIEF Core GitService base class — initialization, path resolution, repo lifecycle (init/delete/get), and identity configuration.
# @LAYER Infra
# @RELATION INHERITED_BY -> [GitService]
# @RELATION DEPENDS_ON -> [SessionLocal]
# @RELATION DEPENDS_ON -> [AppConfigRecord]
@@ -14,10 +14,7 @@ from typing import Any, Dict, List, Optional
from git import Repo
from git.exc import InvalidGitRepositoryError, NoSuchPathError
from fastapi import HTTPException
from src.core.logger import belief_scope
from src.core.cot_logger import MarkerLogger
log = MarkerLogger("GitBase")
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository
from src.models.config import AppConfigRecord
from src.core.database import SessionLocal
@@ -26,8 +23,8 @@ from src.core.database import SessionLocal
# #region GitServiceBase [C:3] [TYPE Class]
# @BRIEF Base class for GitService providing initialization, path resolution, repository lifecycle, and identity.
class GitServiceBase:
# #region GitService_init [TYPE Function]
# @BRIEF Initializes the GitService with a base path for repositories.
# [DEF:GitService_init:Function]
# @PURPOSE: Initializes the GitService with a base path for repositories.
# @PARAM: base_path (str) - Root directory for all Git clones.
# @PRE: base_path is a valid string path.
# @POST: GitService is initialized; base_path directory exists.
@@ -38,12 +35,12 @@ class GitServiceBase:
self._uses_default_base_path = base_path == "git_repos"
self.base_path = self._resolve_base_path(base_path)
self._ensure_base_path_exists()
# #endregion GitService_init
# [/DEF:GitService_init:Function]
# #region _ensure_base_path_exists [TYPE Function]
# @BRIEF Ensure the repositories root directory exists and is a directory.
# @PRE self.base_path is resolved to filesystem path.
# @POST self.base_path exists as directory or raises ValueError.
# [DEF:_ensure_base_path_exists:Function]
# @PURPOSE: Ensure the repositories root directory exists and is a directory.
# @PRE: self.base_path is resolved to filesystem path.
# @POST: self.base_path exists as directory or raises ValueError.
def _ensure_base_path_exists(self) -> None:
base = Path(self.base_path)
if base.exists() and not base.is_dir():
@@ -51,15 +48,17 @@ class GitServiceBase:
try:
base.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError) as e:
log.explore("Cannot create Git repositories base path", payload={"base_path": self.base_path}, error=str(e))
logger.warning(
f"[_ensure_base_path_exists][Coherence:Failed] Cannot create Git repositories base path: {self.base_path}. Error: {e}"
)
raise ValueError(f"Cannot create Git repositories base path: {self.base_path}. {e}")
# #endregion _ensure_base_path_exists
# [/DEF:_ensure_base_path_exists:Function]
# #region _resolve_base_path [TYPE Function]
# @BRIEF 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:Function]
# @PURPOSE: Resolve base repository directory from explicit argument or global storage settings.
# @PRE: base_path is a string path.
# @POST: Returns absolute path for Git repositories root.
# @RETURN: str
def _resolve_base_path(self, base_path: str) -> str:
backend_root = Path(__file__).parents[3]
fallback_path = str((backend_root / base_path).resolve())
@@ -86,25 +85,25 @@ class GitServiceBase:
return str(repo_root.resolve())
return str((root / repo_root).resolve())
except Exception as e:
log.explore("Falling back to default base path", error=str(e))
logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}")
return fallback_path
# #endregion _resolve_base_path
# [/DEF:_resolve_base_path:Function]
# #region _normalize_repo_key [TYPE Function]
# @BRIEF 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: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"
# #endregion _normalize_repo_key
# [/DEF:_normalize_repo_key:Function]
# #region _update_repo_local_path [TYPE Function]
# @BRIEF Persist repository local_path in GitRepository table when record exists.
# @PRE dashboard_id is valid integer.
# @POST local_path is updated for existing record.
# [DEF:_update_repo_local_path:Function]
# @PURPOSE: Persist repository local_path in GitRepository table when record exists.
# @PRE: dashboard_id is valid integer.
# @POST: local_path is updated for existing record.
def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None:
try:
session = SessionLocal()
@@ -120,21 +119,21 @@ class GitServiceBase:
finally:
session.close()
except Exception as e:
log.explore("Failed to update repo local path", error=str(e))
# #endregion _update_repo_local_path
logger.warning(f"[_update_repo_local_path][Coherence:Failed] {e}")
# [/DEF:_update_repo_local_path:Function]
# #region _migrate_repo_directory [TYPE Function]
# @BRIEF 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: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):
log.explore(f"Target already exists, keeping source path: {target_abs}", error="Target path exists")
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:
@@ -142,12 +141,14 @@ class GitServiceBase:
except OSError:
shutil.move(source_abs, target_abs)
self._update_repo_local_path(dashboard_id, target_abs)
log.reflect(f"Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}")
logger.info(
f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}"
)
return target_abs
# #endregion _migrate_repo_directory
# [/DEF:_migrate_repo_directory:Function]
# #region _get_repo_path [TYPE Function]
# @BRIEF Resolves the local filesystem path for a dashboard's repository.
# [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.
@@ -183,17 +184,17 @@ class GitServiceBase:
return self._migrate_repo_directory(dashboard_id, db_path, target_path)
return db_path
except Exception as e:
log.explore("Could not resolve local_path from DB", error=str(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
# #endregion _get_repo_path
# [/DEF:_get_repo_path:Function]
# #region init_repo [TYPE Function]
# @BRIEF Initialize or clone a repository for a dashboard.
# [DEF:init_repo:Function]
# @PURPOSE: Initialize or clone a repository for a dashboard.
# @PARAM: dashboard_id (int), remote_url (str), pat (str), repo_key (Optional[str]).
# @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
# @POST: Repository is cloned or opened at the local path.
@@ -209,11 +210,11 @@ class GitServiceBase:
else:
auth_url = remote_url
if os.path.exists(repo_path):
log.reason(f"Opening existing repo at {repo_path}")
logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}")
try:
repo = Repo(repo_path)
except (InvalidGitRepositoryError, NoSuchPathError):
log.explore(f"Existing path is not a Git repository, recreating: {repo_path}", error="Not a git repository")
logger.warning(f"[init_repo][Action] Existing path is not a Git repository, recreating: {repo_path}")
stale_path = Path(repo_path)
if stale_path.exists():
shutil.rmtree(stale_path, ignore_errors=True)
@@ -225,16 +226,16 @@ class GitServiceBase:
repo = Repo.clone_from(auth_url, repo_path)
self._ensure_gitflow_branches(repo, dashboard_id)
return repo
log.reason(f"Cloning {remote_url} to {repo_path}")
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
# #endregion init_repo
# [/DEF:init_repo:Function]
# #region delete_repo [TYPE Function]
# @BRIEF Remove local repository and DB binding for a dashboard.
# @PRE dashboard_id is a valid integer.
# @POST Local path is deleted when present and GitRepository row is removed.
# [DEF:delete_repo:Function]
# @PURPOSE: Remove local repository and DB binding for a dashboard.
# @PRE: dashboard_id is a valid integer.
# @POST: Local path is deleted when present and GitRepository row is removed.
def delete_repo(self, dashboard_id: int) -> None:
with belief_scope("GitService.delete_repo"):
repo_path = self._get_repo_path(dashboard_id)
@@ -267,34 +268,34 @@ class GitServiceBase:
raise
except Exception as e:
session.rollback()
log.explore(f"Failed to delete repository for dashboard {dashboard_id}", error=str(e))
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()
# #endregion delete_repo
# [/DEF:delete_repo:Function]
# #region get_repo [TYPE Function]
# @BRIEF 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: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):
log.explore(f"Repository for dashboard {dashboard_id} does not exist", error="Repository not found")
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:
log.explore(f"Failed to open repository at {repo_path}", error=str(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")
# #endregion get_repo
# [/DEF:get_repo:Function]
# #region configure_identity [TYPE Function]
# @BRIEF Configure repository-local Git committer identity for user-scoped operations.
# @PRE dashboard_id repository exists; git_username/git_email may be empty.
# @POST Repository config has user.name and user.email when both identity values are provided.
# [DEF:configure_identity:Function]
# @PURPOSE: Configure repository-local Git committer identity for user-scoped operations.
# @PRE: dashboard_id repository exists; git_username/git_email may be empty.
# @POST: Repository config has user.name and user.email when both identity values are provided.
def configure_identity(self, dashboard_id: int, git_username: Optional[str], git_email: Optional[str]) -> None:
with belief_scope("GitService.configure_identity"):
normalized_username = str(git_username or "").strip()
@@ -306,10 +307,10 @@ class GitServiceBase:
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)
log.reason(f"Applied repository-local git identity for dashboard {dashboard_id}")
logger.info("[configure_identity][Action] Applied repository-local git identity for dashboard %s", dashboard_id)
except Exception as e:
log.explore("Failed to configure git identity", error=str(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)}")
# #endregion configure_identity
# [/DEF:configure_identity:Function]
# #endregion GitServiceBase
# #endregion GitServiceBase

View File

@@ -1,6 +1,6 @@
# #region GitServiceBranchMixin [C:3] [TYPE Module] [SEMANTICS git, branches, commits, checkout, gitflow]
# @LAYER: Infra
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
import os
@@ -9,19 +9,16 @@ from datetime import datetime
from git import Repo
from git.exc import GitCommandError
from fastapi import HTTPException
from src.core.logger import belief_scope
from src.core.cot_logger import MarkerLogger
log = MarkerLogger("GitBranch")
from src.core.logger import logger, belief_scope
# #region GitServiceBranchMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing branch and commit operations for GitService.
class GitServiceBranchMixin:
# #region _ensure_gitflow_branches [TYPE Function]
# @BRIEF Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
# @PRE repo is a valid GitPython Repo instance.
# @POST main, dev, preprod are available in local repository and pushed to origin when available.
# [DEF:_ensure_gitflow_branches:Function]
# @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
# @PRE: repo is a valid GitPython Repo instance.
# @POST: main, dev, preprod are available in local repository and pushed to origin when available.
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
with belief_scope("GitService._ensure_gitflow_branches"):
required_branches = ["main", "dev", "preprod"]
@@ -34,20 +31,26 @@ class GitServiceBranchMixin:
if "main" in local_heads:
base_commit = local_heads["main"].commit
if base_commit is None:
log.explore("Branch bootstrap skipped - no commits", payload={"dashboard_id": dashboard_id}, error="Repository has no initial commit to branch from")
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)
log.reason("Created local branch main", payload={"dashboard_id": dashboard_id})
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)
log.reason("Created local branch", payload={"branch_name": branch_name, "dashboard_id": dashboard_id})
logger.info(
f"[_ensure_gitflow_branches][Action] Created local branch {branch_name} for dashboard {dashboard_id}"
)
try:
origin = repo.remote(name="origin")
except ValueError:
log.reason("Remote origin not configured; skipping remote branch creation", payload={"dashboard_id": dashboard_id})
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:
@@ -57,35 +60,37 @@ class GitServiceBranchMixin:
if remote_head:
remote_branch_names.add(str(remote_head))
except Exception as e:
log.explore("Failed to fetch origin refs", error=str(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}")
log.reason("Pushed branch to origin", payload={"branch_name": branch_name, "dashboard_id": dashboard_id})
logger.info(
f"[_ensure_gitflow_branches][Action] Pushed branch {branch_name} to origin for dashboard {dashboard_id}"
)
except Exception as e:
log.explore("Failed to push branch to origin", error=str(e))
logger.error(f"[_ensure_gitflow_branches][Coherence:Failed] Failed to push branch {branch_name} to origin: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to create default branch '{branch_name}' on remote: {str(e)}",
)
try:
repo.git.checkout("dev")
log.reason("Checked out default branch dev", payload={"dashboard_id": dashboard_id})
logger.info(f"[_ensure_gitflow_branches][Action] Checked out default branch dev for dashboard {dashboard_id}")
except Exception as e:
log.explore("Could not checkout dev branch", payload={"dashboard_id": dashboard_id}, error=str(e))
# #endregion _ensure_gitflow_branches
logger.warning(f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}")
# [/DEF:_ensure_gitflow_branches:Function]
# #region list_branches [TYPE Function]
# @BRIEF 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: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)
log.reason("Listing branches", payload={"dashboard_id": dashboard_id})
logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}")
branches = []
for ref in repo.refs:
try:
@@ -99,7 +104,7 @@ class GitServiceBranchMixin:
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.utcnow()
})
except Exception as e:
log.explore("Skipping ref", payload={"ref": str(ref)}, error=str(e))
logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}")
try:
active_name = repo.active_branch.name
if not any(b['name'] == active_name for b in branches):
@@ -108,17 +113,17 @@ class GitServiceBranchMixin:
"is_remote": False, "last_updated": datetime.utcnow()
})
except Exception as e:
log.explore("Could not determine active branch", error=str(e))
logger.warning(f"[list_branches][Action] Could not determine active branch: {e}")
if not branches:
branches.append({
"name": "dev", "commit_hash": "0000000",
"is_remote": False, "last_updated": datetime.utcnow()
})
return branches
# #endregion list_branches
# [/DEF:list_branches:Function]
# #region create_branch [TYPE Function]
# @BRIEF Create a new branch from an existing one.
# [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.
@@ -126,9 +131,9 @@ class GitServiceBranchMixin:
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)
log.reason("Creating branch", payload={"name": name, "from_branch": from_branch})
logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}")
if not repo.heads and not repo.remotes:
log.explore("Repository is empty; creating initial commit to enable branching", error="No branches or remotes exist; initializing from scratch")
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:
@@ -138,29 +143,29 @@ class GitServiceBranchMixin:
try:
repo.commit(from_branch)
except Exception:
log.explore("Source branch not found, using HEAD", payload={"from_branch": from_branch}, error=f"Branch '{from_branch}' does not exist, falling back to HEAD")
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:
log.explore("Failed to create branch", error=str(e))
logger.error(f"[create_branch][Coherence:Failed] {e}")
raise
# #endregion create_branch
# [/DEF:create_branch:Function]
# #region checkout_branch [TYPE Function]
# @BRIEF 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: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)
log.reason("Checking out branch", payload={"name": name})
logger.info(f"[checkout_branch][Action] Checking out branch {name}")
repo.git.checkout(name)
# #endregion checkout_branch
# [/DEF:checkout_branch:Function]
# #region commit_changes [TYPE Function]
# @BRIEF Stage and commit changes.
# [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.
@@ -169,16 +174,16 @@ class GitServiceBranchMixin:
with belief_scope("GitService.commit_changes"):
repo = self.get_repo(dashboard_id)
if not repo.is_dirty(untracked_files=True) and not files:
log.reason("No changes to commit", payload={"dashboard_id": dashboard_id})
logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}")
return
if files:
log.reason("Staging files", payload={"files": files})
logger.info(f"[commit_changes][Action] Staging files: {files}")
repo.index.add(files)
else:
log.reason("Staging all changes")
logger.info("[commit_changes][Action] Staging all changes")
repo.git.add(A=True)
repo.index.commit(message)
log.reflect("Committed changes", payload={"message": message})
# #endregion commit_changes
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}")
# [/DEF:commit_changes:Function]
# #endregion GitServiceBranchMixin
# #endregion GitServiceBranchMixin

View File

@@ -1,24 +1,21 @@
# #region GitServiceGiteaMixin [C:3] [TYPE Module] [SEMANTICS git, gitea, api, provider, connection_test]
# @LAYER: Infra
# @BRIEF Gitea API operations for GitService — connection testing, repository CRUD, and pull request creation.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
import httpx
from typing import Any, Dict, List, Optional
from urllib.parse import quote
from fastapi import HTTPException
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
log = MarkerLogger("GitGitea")
from src.core.logger import logger, belief_scope
from src.models.git import GitProvider
# #region GitServiceGiteaMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing Gitea API operations for GitService.
class GitServiceGiteaMixin:
# #region test_connection [TYPE Function]
# @BRIEF Test connection to Git provider using PAT.
# [DEF:test_connection:Function]
# @PURPOSE: Test connection to Git provider using PAT.
# @PARAM: provider (GitProvider), url (str), pat (str)
# @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided.
# @POST: Returns True if connection to the provider's API succeeds.
@@ -26,13 +23,13 @@ class GitServiceGiteaMixin:
async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
with belief_scope("GitService.test_connection"):
if ".local" in url or "localhost" in url:
log.reason("Local/Offline mode detected for URL")
logger.info("[test_connection][Action] Local/Offline mode detected for URL")
return True
if not url.startswith(('http://', 'https://')):
log.explore(f"Invalid URL protocol: {url}", error="Invalid URL protocol")
logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}")
return False
if not pat or not pat.strip():
log.explore("Git PAT is missing or empty", error="Git PAT is missing or empty")
logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty")
return False
pat = pat.strip()
try:
@@ -52,18 +49,18 @@ class GitServiceGiteaMixin:
else:
return False
if resp.status_code != 200:
log.explore(f"Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}", error="Git connection test failed")
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:
log.explore(f"Error testing git connection: {e}", error=str(e))
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
return False
# #endregion test_connection
# [/DEF:test_connection:Function]
# #region _gitea_headers [TYPE Function]
# @BRIEF Build Gitea API authorization headers.
# @PRE pat is provided.
# @POST Returns headers with token auth.
# @RETURN Dict[str, str]
# [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:
@@ -73,13 +70,13 @@ class GitServiceGiteaMixin:
"Content-Type": "application/json",
"Accept": "application/json",
}
# #endregion _gitea_headers
# [/DEF:_gitea_headers:Function]
# #region _gitea_request [TYPE Function]
# @BRIEF Execute HTTP request against Gitea API with stable error mapping.
# @PRE method and endpoint are valid.
# @POST Returns decoded JSON payload.
# @RETURN Any
# [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,
@@ -91,7 +88,7 @@ class GitServiceGiteaMixin:
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:
log.explore(f"Network error: {e}", error=str(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
@@ -100,43 +97,43 @@ class GitServiceGiteaMixin:
detail = parsed.get("message") or parsed.get("error") or detail
except Exception:
pass
log.explore(f"Gitea API error ({endpoint})", payload={"method": method, "status": response.status_code}, error=detail)
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()
# #endregion _gitea_request
# [/DEF:_gitea_request:Function]
# #region get_gitea_current_user [TYPE Function]
# @BRIEF Resolve current Gitea user for PAT.
# @PRE server_url and pat are valid.
# @POST Returns current username.
# @RETURN str
# [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)
# #endregion get_gitea_current_user
# [/DEF:get_gitea_current_user:Function]
# #region list_gitea_repositories [TYPE Function]
# @BRIEF List repositories visible to authenticated Gitea user.
# @PRE server_url and pat are valid.
# @POST Returns repository list from Gitea.
# @RETURN List[dict]
# [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
# #endregion list_gitea_repositories
# [/DEF:list_gitea_repositories:Function]
# #region create_gitea_repository [TYPE Function]
# @BRIEF 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
# [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",
@@ -150,23 +147,23 @@ class GitServiceGiteaMixin:
if not isinstance(created, dict):
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository")
return created
# #endregion create_gitea_repository
# [/DEF:create_gitea_repository:Function]
# #region delete_gitea_repository [TYPE Function]
# @BRIEF Delete repository in Gitea.
# @PRE owner and repo_name are non-empty.
# @POST Repository deleted on Gitea server.
# [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}")
# #endregion delete_gitea_repository
# [/DEF:delete_gitea_repository:Function]
# #region _gitea_branch_exists [TYPE Function]
# @BRIEF 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
# [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
@@ -178,13 +175,13 @@ class GitServiceGiteaMixin:
if exc.status_code == 404:
return False
raise
# #endregion _gitea_branch_exists
# [/DEF:_gitea_branch_exists:Function]
# #region _build_gitea_pr_404_detail [TYPE Function]
# @BRIEF 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]
# [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]:
@@ -199,13 +196,13 @@ class GitServiceGiteaMixin:
if not target_exists:
return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}"
return None
# #endregion _build_gitea_pr_404_detail
# [/DEF:_build_gitea_pr_404_detail:Function]
# #region create_gitea_pull_request [TYPE Function]
# @BRIEF Create pull request in Gitea.
# @PRE Config and remote URL are valid.
# @POST Returns normalized PR metadata.
# @RETURN Dict[str, Any]
# [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,
@@ -223,7 +220,10 @@ class GitServiceGiteaMixin:
exc.status_code == 404 and fallback_url and fallback_url != normalized_primary
)
if should_retry_with_fallback:
log.explore(f"Primary Gitea URL not found, retrying with remote host: {fallback_url}", error=fallback_url)
logger.warning(
"[create_gitea_pull_request][Action] Primary Gitea URL not found, retrying with remote host: %s",
fallback_url,
)
active_server_url = fallback_url
try:
data = await self._gitea_request("POST", active_server_url, pat, endpoint, payload=req_payload)
@@ -254,6 +254,6 @@ class GitServiceGiteaMixin:
"url": data.get("html_url") or data.get("url"),
"status": data.get("state") or "open",
}
# #endregion create_gitea_pull_request
# [/DEF:create_gitea_pull_request:Function]
# #endregion GitServiceGiteaMixin
# #endregion GitServiceGiteaMixin

View File

@@ -1,6 +1,6 @@
# #region GitServiceMergeMixin [C:3] [TYPE Module] [SEMANTICS git, merge, conflicts, resolution, promote]
# @LAYER: Infra
# @BRIEF Merge operations for GitService — conflict detection, resolution, abort, continue, and direct promote.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
import os
@@ -16,8 +16,8 @@ from src.core.logger import logger, belief_scope
# #region GitServiceMergeMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing merge operations for GitService.
class GitServiceMergeMixin:
# #region _read_blob_text [TYPE Function]
# @BRIEF Read text from a Git blob.
# [DEF:_read_blob_text:Function]
# @PURPOSE: Read text from a Git blob.
def _read_blob_text(self, blob: Blob) -> str:
with belief_scope("GitService._read_blob_text"):
if blob is None:
@@ -26,20 +26,20 @@ class GitServiceMergeMixin:
return blob.data_stream.read().decode("utf-8", errors="replace")
except Exception:
return ""
# #endregion _read_blob_text
# [/DEF:_read_blob_text:Function]
# #region _get_unmerged_file_paths [TYPE Function]
# @BRIEF List files with merge conflicts.
# [DEF:_get_unmerged_file_paths:Function]
# @PURPOSE: List files with merge conflicts.
def _get_unmerged_file_paths(self, repo: Repo) -> List[str]:
with belief_scope("GitService._get_unmerged_file_paths"):
try:
return sorted(list(repo.index.unmerged_blobs().keys()))
except Exception:
return []
# #endregion _get_unmerged_file_paths
# [/DEF:_get_unmerged_file_paths:Function]
# #region _build_unfinished_merge_payload [TYPE Function]
# @BRIEF Build payload for unfinished merge state.
# [DEF:_build_unfinished_merge_payload:Function]
# @PURPOSE: Build payload for unfinished merge state.
def _build_unfinished_merge_payload(self, repo: Repo) -> Dict[str, Any]:
with belief_scope("GitService._build_unfinished_merge_payload"):
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
@@ -83,10 +83,10 @@ class GitServiceMergeMixin:
],
"manual_commands": ["git status", "git add <resolved-files>", 'git commit -m "resolve merge conflicts"', "git merge --abort"],
}
# #endregion _build_unfinished_merge_payload
# [/DEF:_build_unfinished_merge_payload:Function]
# #region get_merge_status [TYPE Function]
# @BRIEF Get current merge status for a dashboard repository.
# [DEF:get_merge_status:Function]
# @PURPOSE: Get current merge status for a dashboard repository.
def get_merge_status(self, dashboard_id: int) -> Dict[str, Any]:
with belief_scope("GitService.get_merge_status"):
repo = self.get_repo(dashboard_id)
@@ -116,10 +116,10 @@ class GitServiceMergeMixin:
"merge_message_preview": payload["merge_message_preview"],
"conflicts_count": int(payload.get("conflicts_count") or 0),
}
# #endregion get_merge_status
# [/DEF:get_merge_status:Function]
# #region get_merge_conflicts [TYPE Function]
# @BRIEF List all files with conflicts and their contents.
# [DEF:get_merge_conflicts:Function]
# @PURPOSE: List all files with conflicts and their contents.
def get_merge_conflicts(self, dashboard_id: int) -> List[Dict[str, Any]]:
with belief_scope("GitService.get_merge_conflicts"):
repo = self.get_repo(dashboard_id)
@@ -139,10 +139,10 @@ class GitServiceMergeMixin:
"theirs": self._read_blob_text(theirs_blob) if theirs_blob else "",
})
return sorted(conflicts, key=lambda item: item["file_path"])
# #endregion get_merge_conflicts
# [/DEF:get_merge_conflicts:Function]
# #region resolve_merge_conflicts [TYPE Function]
# @BRIEF Resolve conflicts using specified strategy.
# [DEF:resolve_merge_conflicts:Function]
# @PURPOSE: Resolve conflicts using specified strategy.
def resolve_merge_conflicts(self, dashboard_id: int, resolutions: List[Dict[str, Any]]) -> List[str]:
with belief_scope("GitService.resolve_merge_conflicts"):
repo = self.get_repo(dashboard_id)
@@ -172,10 +172,10 @@ class GitServiceMergeMixin:
repo.git.add(file_path)
resolved_files.append(file_path)
return resolved_files
# #endregion resolve_merge_conflicts
# [/DEF:resolve_merge_conflicts:Function]
# #region abort_merge [TYPE Function]
# @BRIEF Abort ongoing merge.
# [DEF:abort_merge:Function]
# @PURPOSE: Abort ongoing merge.
def abort_merge(self, dashboard_id: int) -> Dict[str, Any]:
with belief_scope("GitService.abort_merge"):
repo = self.get_repo(dashboard_id)
@@ -188,10 +188,10 @@ class GitServiceMergeMixin:
return {"status": "no_merge_in_progress"}
raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}")
return {"status": "aborted"}
# #endregion abort_merge
# [/DEF:abort_merge:Function]
# #region continue_merge [TYPE Function]
# @BRIEF Finalize merge after conflict resolution.
# [DEF:continue_merge:Function]
# @PURPOSE: Finalize merge after conflict resolution.
def continue_merge(self, dashboard_id: int, message: Optional[str] = None) -> Dict[str, Any]:
with belief_scope("GitService.continue_merge"):
repo = self.get_repo(dashboard_id)
@@ -223,13 +223,13 @@ class GitServiceMergeMixin:
except Exception:
commit_hash = ""
return {"status": "committed", "commit_hash": commit_hash}
# #endregion continue_merge
# [/DEF:continue_merge:Function]
# #region promote_direct_merge [TYPE Function]
# @BRIEF 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: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:
@@ -270,6 +270,6 @@ class GitServiceMergeMixin:
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"}
# #endregion promote_direct_merge
# [/DEF:promote_direct_merge:Function]
# #endregion GitServiceMergeMixin
# #endregion GitServiceMergeMixin

View File

@@ -1,6 +1,6 @@
# #region GitServiceRemoteMixin [C:3] [TYPE Module] [SEMANTICS git, github, gitlab, api, provider, repository, pull_request, merge_request]
# @LAYER: Infra
# @BRIEF GitHub and GitLab provider operations for GitService — repository creation and PR/MR creation.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
import httpx
@@ -10,15 +10,14 @@ from fastapi import HTTPException
from src.core.logger import logger, belief_scope
# [DEF:GitServiceGithubMixin:Class]
# @COMPLEXITY: 3
# @PURPOSE: Mixin providing GitHub API operations for GitService.
# #region GitServiceGithubMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing GitHub API operations for GitService.
class GitServiceGithubMixin:
# #region create_github_repository [TYPE Function]
# @BRIEF Create repository in GitHub or GitHub Enterprise.
# @PRE PAT has repository create permission.
# @POST Returns created repository payload.
# @RETURN dict
# [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",
@@ -52,13 +51,13 @@ class GitServiceGithubMixin:
pass
raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
return response.json()
# #endregion create_github_repository
# [/DEF:create_github_repository:Function]
# #region create_github_pull_request [TYPE Function]
# @BRIEF Create pull request in GitHub or GitHub Enterprise.
# @PRE Config and remote URL are valid.
# @POST Returns normalized PR metadata.
# @RETURN Dict[str, Any]
# [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,
@@ -92,17 +91,17 @@ class GitServiceGithubMixin:
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"}
# #endregion create_github_pull_request
# [/DEF:create_github_pull_request:Function]
# #region GitServiceGitlabMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing GitLab API operations for GitService.
class GitServiceGitlabMixin:
# #region create_gitlab_repository [TYPE Function]
# @BRIEF Create repository(project) in GitLab.
# @PRE PAT has api scope.
# @POST Returns created repository payload.
# @RETURN dict
# [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",
@@ -142,13 +141,13 @@ class GitServiceGitlabMixin:
if "full_name" not in data:
data["full_name"] = data.get("path_with_namespace") or data.get("name")
return data
# #endregion create_gitlab_repository
# [/DEF:create_gitlab_repository:Function]
# #region create_gitlab_merge_request [TYPE Function]
# @BRIEF Create merge request in GitLab.
# @PRE Config and remote URL are valid.
# @POST Returns normalized MR metadata.
# @RETURN Dict[str, Any]
# [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,
@@ -178,6 +177,7 @@ class GitServiceGitlabMixin:
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"}
# #endregion create_gitlab_merge_request
# [/DEF:create_gitlab_merge_request:Function]
# #endregion GitServiceGitlabMixin
# #endregion GitServiceRemoteMixin
# #endregion GitServiceRemoteMixin

View File

@@ -1,25 +1,23 @@
# #region GitServiceStatusMixin [C:3] [TYPE Module] [SEMANTICS git, status, diff, history, porcelain]
# @LAYER: Infra
# @BRIEF Status, diff, and commit history operations for GitService using git status --porcelain to avoid --cached flag issues.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
from typing import Any, Dict, List
from datetime import datetime
from git import Repo
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
log = MarkerLogger("GitStatus")
from src.core.logger import logger, belief_scope
# #region GitServiceStatusMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing repository status, diff, and commit history for GitService.
class GitServiceStatusMixin:
# #region _parse_status_porcelain [C:2] [TYPE Function]
# @BRIEF Parse git status --porcelain output into staged, modified, and untracked file lists.
# @PRE `repo` is an open GitPython Repo instance.
# @POST Returns (staged, modified, untracked) tuple of file path lists.
# @RATIONALE Avoids repo.is_dirty() / repo.index.diff("HEAD") which internally
# [DEF:_parse_status_porcelain:Function]
# @COMPLEXITY: 2
# @PURPOSE: Parse git status --porcelain output into staged, modified, and untracked file lists.
# @PRE: `repo` is an open GitPython Repo instance.
# @POST: Returns (staged, modified, untracked) tuple of file path lists.
# @RATIONALE: Avoids repo.is_dirty() / repo.index.diff("HEAD") which internally
# call git diff --cached, a flag unsupported in some Git environments (exit 129).
# Using git status --porcelain is self-contained and avoids the --cached flag entirely.
def _parse_status_porcelain(self, repo) -> tuple[list[str], list[str], list[str]]:
@@ -30,7 +28,7 @@ class GitServiceStatusMixin:
try:
output = repo.git.status("--porcelain")
except Exception:
log.explore("git status --porcelain failed", error="git status --porcelain command failed")
logger.warning("[_parse_status_porcelain][Coherence:Failed] git status --porcelain failed")
return staged, modified, untracked
for line in output.split("\n"):
if not line:
@@ -52,13 +50,13 @@ class GitServiceStatusMixin:
if path not in modified:
modified.append(path)
return staged, modified, untracked
# #endregion _parse_status_porcelain
# [/DEF:_parse_status_porcelain:Function]
# #region get_status [TYPE Function]
# @BRIEF 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: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)
@@ -112,10 +110,10 @@ class GitServiceStatusMixin:
"is_diverged": is_diverged,
"sync_state": sync_state,
}
# #endregion get_status
# [/DEF:get_status:Function]
# #region get_diff [TYPE Function]
# @BRIEF Generate diff for a file or the whole repository.
# [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.
@@ -130,10 +128,10 @@ class GitServiceStatusMixin:
if file_path:
return repo.git.diff(*diff_args, "--", file_path)
return repo.git.diff(*diff_args)
# #endregion get_diff
# [/DEF:get_diff:Function]
# #region get_commit_history [TYPE Function]
# @BRIEF Retrieve commit history for a repository.
# [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.
@@ -155,8 +153,9 @@ class GitServiceStatusMixin:
"files_changed": list(commit.stats.files.keys())
})
except Exception as e:
log.explore(f"Could not retrieve commit history for dashboard {dashboard_id}: {e}", error=str(e))
logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
return []
return commits
# #endregion get_commit_history
# [/DEF:get_commit_history:Function]
# #endregion GitServiceStatusMixin
# #endregion GitServiceStatusMixin

View File

@@ -1,6 +1,6 @@
# #region GitServiceSyncMixin [C:3] [TYPE Module] [SEMANTICS git, push, pull, sync, remote]
# @LAYER: Infra
# @BRIEF Push and pull operations for GitService with origin host auto-alignment.
# @LAYER Infra
# @RELATION USED_BY -> [GitService]
# @RELATION DEPENDS_ON -> [GitServiceUrlMixin]
@@ -10,30 +10,27 @@ from git import Repo
from git.exc import GitCommandError
from fastapi import HTTPException
from src.core.database import SessionLocal
from src.core.logger import belief_scope
from src.core.cot_logger import MarkerLogger
log = MarkerLogger("GitSync")
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository, GitServerConfig
# #region GitServiceSyncMixin [C:3] [TYPE Class]
# @BRIEF Mixin providing push and pull operations with origin host alignment.
class GitServiceSyncMixin:
# #region push_changes [TYPE Function]
# @BRIEF Push local commits to remote.
# @PRE Repository exists and has an 'origin' remote.
# @POST Local branch commits are pushed to origin.
# [DEF:push_changes:Function]
# @PURPOSE: Push local commits to remote.
# @PRE: Repository exists and has an 'origin' remote.
# @POST: Local branch commits are pushed to origin.
def push_changes(self, dashboard_id: int):
with belief_scope("GitService.push_changes"):
repo = self.get_repo(dashboard_id)
if not repo.heads:
log.explore("No local branches to push", payload={"dashboard_id": dashboard_id}, error="Repository has no local branch heads to push")
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:
log.explore("Remote 'origin' not found", payload={"dashboard_id": dashboard_id}, error="Git remote 'origin' is not configured for this repository")
logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}")
raise HTTPException(status_code=400, detail="Remote 'origin' not configured")
try:
origin_urls = list(origin.urls)
@@ -63,7 +60,10 @@ class GitServiceSyncMixin:
finally:
session.close()
except Exception as diag_error:
log.explore("Failed to load repository binding diagnostics", payload={"dashboard_id": dashboard_id}, error=str(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,
@@ -75,17 +75,13 @@ class GitServiceSyncMixin:
origin_urls = list(origin.urls)
except Exception:
origin_urls = []
log.reason(
"Push diagnostics",
payload={
"dashboard_id": dashboard_id, "config_id": binding_config_id,
"config_url": binding_config_url, "binding_remote_url": binding_remote_url,
"origin_urls": origin_urls, "origin_realigned": bool(realigned_origin_url),
},
logger.info(
"[push_changes][Action] Push diagnostics dashboard=%s config_id=%s config_url=%s binding_remote_url=%s origin_urls=%s origin_realigned=%s",
dashboard_id, binding_config_id, binding_config_url, binding_remote_url, origin_urls, bool(realigned_origin_url),
)
try:
current_branch = repo.active_branch
log.reason("Pushing branch", payload={"branch": current_branch.name, "origin": True})
logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin")
tracking_branch = None
try:
tracking_branch = current_branch.tracking_branch()
@@ -97,7 +93,7 @@ class GitServiceSyncMixin:
push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}')
for info in push_info:
if info.flags & info.ERROR:
log.explore("Error pushing ref", payload={"ref": info.remote_ref_string, "summary": info.summary}, error=f"Git push error: {info.summary}")
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)
@@ -107,31 +103,28 @@ class GitServiceSyncMixin:
status_code=409,
detail="Push rejected: remote branch contains newer commits. Run Pull first, resolve conflicts if any, then push again.",
)
log.explore("Failed to push changes", error=str(e))
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:
log.explore("Failed to push changes", error=str(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)}")
# #endregion push_changes
# [/DEF:push_changes:Function]
# #region pull_changes [TYPE Function]
# @BRIEF Pull changes from remote.
# @PRE Repository exists and has an 'origin' remote.
# @POST Changes from origin are pulled and merged into the active branch.
# [DEF:pull_changes:Function]
# @PURPOSE: Pull changes from remote.
# @PRE: Repository exists and has an 'origin' remote.
# @POST: Changes from origin are pulled and merged into the active branch.
def pull_changes(self, dashboard_id: int):
with belief_scope("GitService.pull_changes"):
repo = self.get_repo(dashboard_id)
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
if os.path.exists(merge_head_path):
payload = self._build_unfinished_merge_payload(repo)
log.explore("Unfinished merge detected", error="Unfinished merge state found",
payload={
"dashboard_id": dashboard_id,
"repo_path": payload["repository_path"],
"git_dir": payload["git_dir"],
"branch": payload["current_branch"],
"merge_head": payload["merge_head"],
})
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')
@@ -140,36 +133,26 @@ class GitServiceSyncMixin:
origin_urls = list(origin.urls)
except Exception:
origin_urls = []
log.reason(
"Pull diagnostics",
payload={
"dashboard_id": dashboard_id,
"repo_path": repo.working_tree_dir,
"branch": current_branch,
"origin_urls": 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)
log.reason(
"Pull remote branch check",
payload={
"dashboard_id": dashboard_id,
"branch": current_branch,
"remote_ref": remote_ref,
"exists": has_remote_branch,
},
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.",
)
log.reason("Pulling changes", payload={"branch": current_branch})
logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}")
repo.git.pull("--no-rebase", "origin", current_branch)
except ValueError:
log.explore("Remote 'origin' not found", payload={"dashboard_id": dashboard_id}, error="Git remote 'origin' is not configured for this repository")
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)
@@ -179,13 +162,13 @@ class GitServiceSyncMixin:
status_code=409,
detail="Pull requires conflict resolution. Resolve conflicts in repository and repeat operation.",
)
log.explore("Failed to pull changes", error=str(e))
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:
log.explore("Failed to pull changes", error=str(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)}")
# #endregion pull_changes
# [/DEF:pull_changes:Function]
# #endregion GitServiceSyncMixin
# #endregion GitServiceSyncMixin

View File

@@ -1,6 +1,6 @@
# #region GitServiceUrlMixin [C:3] [TYPE Module] [SEMANTICS git, url, parsing, normalization, host_alignment]
# @LAYER: Infra
# @BRIEF URL helper mixin for GitService — parse, normalize, align, and strip credentials from Git URLs.
# @LAYER Infra
# @RELATION USED_BY -> [GitServiceSyncMixin]
# @RELATION USED_BY -> [GitServiceGiteaMixin]
# @RELATION USED_BY -> [GitServiceRemoteMixin]
@@ -10,20 +10,17 @@ from typing import Any, Dict, List, Optional
from urllib.parse import quote, urlparse
from fastapi import HTTPException
from src.core.database import SessionLocal
from src.core.cot_logger import MarkerLogger
from src.core.logger import belief_scope
log = MarkerLogger("GitUrl")
from src.core.logger import logger, belief_scope
from src.models.git import GitRepository, GitServerConfig
# #region GitServiceUrlMixin [C:3] [TYPE Class]
# @BRIEF URL helper methods for GitService — extract host, strip credentials, replace host, align origin, parse remote identity, derive server URL, normalize server URL.
class GitServiceUrlMixin:
# #region _extract_http_host [TYPE Function]
# @BRIEF 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: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:
@@ -40,13 +37,13 @@ class GitServiceUrlMixin:
if parsed.port:
return f"{host.lower()}:{parsed.port}"
return host.lower()
# #endregion _extract_http_host
# [/DEF:_extract_http_host:Function]
# #region _strip_url_credentials [TYPE Function]
# @BRIEF 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: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:
@@ -61,13 +58,13 @@ class GitServiceUrlMixin:
if parsed.port:
host = f"{host}:{parsed.port}"
return parsed._replace(netloc=host).geturl()
# #endregion _strip_url_credentials
# [/DEF:_strip_url_credentials:Function]
# #region _replace_host_in_url [TYPE Function]
# @BRIEF 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: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()
@@ -93,13 +90,13 @@ class GitServiceUrlMixin:
auth_part = f"{auth_part}@"
new_netloc = f"{auth_part}{target_host}"
return source_parsed._replace(netloc=new_netloc).geturl()
# #endregion _replace_host_in_url
# [/DEF:_replace_host_in_url:Function]
# #region _align_origin_host_with_config [TYPE Function]
# @BRIEF 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: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,
@@ -118,11 +115,17 @@ class GitServiceUrlMixin:
aligned_url = self._replace_host_in_url(source_origin_url, config_url)
if not aligned_url:
return None
log.explore(f"Host mismatch for dashboard {dashboard_id}: config_host={config_host} origin_host={origin_host}, applying origin.set_url", error=f"Host mismatch for dashboard {dashboard_id}")
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:
log.explore(f"Failed to set origin URL for dashboard {dashboard_id}: {e}", error=str(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()
@@ -138,15 +141,18 @@ class GitServiceUrlMixin:
finally:
session.close()
except Exception as e:
log.explore(f"Failed to persist aligned remote_url for dashboard {dashboard_id}: {e}", error=str(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
# #endregion _align_origin_host_with_config
# [/DEF:_align_origin_host_with_config:Function]
# #region _parse_remote_repo_identity [TYPE Function]
# @BRIEF 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: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:
@@ -166,13 +172,13 @@ class GitServiceUrlMixin:
repo = parts[-1]
namespace = "/".join(parts[:-1])
return {"owner": owner, "repo": repo, "namespace": namespace, "full_name": f"{namespace}/{repo}"}
# #endregion _parse_remote_repo_identity
# [/DEF:_parse_remote_repo_identity:Function]
# #region _derive_server_url_from_remote [TYPE Function]
# @BRIEF 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: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@"):
@@ -186,18 +192,18 @@ class GitServiceUrlMixin:
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
return f"{parsed.scheme}://{netloc}".rstrip("/")
# #endregion _derive_server_url_from_remote
# [/DEF:_derive_server_url_from_remote:Function]
# #region _normalize_git_server_url [TYPE Function]
# @BRIEF 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: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("/")
# #endregion _normalize_git_server_url
# [/DEF:_normalize_git_server_url:Function]
# #endregion GitServiceUrlMixin
# #endregion GitServiceUrlMixin