refactor: decompose 6 monolithic modules (>1000 LOC) into domain packages

Decompose 6 large modules into packages with <400-line modules to satisfy INV_7:

- superset_client.py (2145->0) -> superset_client/ package (12 modules)
- assistant.py (2683->0) -> assistant/ package (12 modules)
- git_service.py (2101->11 shim) -> services/git/ package (9 modules)
- git.py (1757->0) -> routes/git/ package (11 modules)
- dashboards.py (1619->0) -> routes/dashboards/ package (8 modules)
- translate.py (1587->0) -> routes/translate/ package (11 modules)
- superset_context_extractor.py (1397->0) -> utils/superset_context_extractor/ (7 modules)

All modules <400 lines (INV_7). All 80 tests pass.
All external imports preserved via __init__.py re-exports or shim.
Semantic [DEF] contracts with @LAYER/@SEMANTICS added to all Module types.
This commit is contained in:
2026-05-09 10:13:07 +03:00
parent 344b47ca23
commit 201886eeb0
76 changed files with 9831 additions and 12576 deletions

View File

@@ -0,0 +1,44 @@
# [DEF:GitPackage:Module]
# @COMPLEXITY: 3
# @PURPOSE: Package root for decomposed git routes. Re-exports all public symbols from submodules.
# @LAYER: API
# @SEMANTICS: git, package, re-exports
# @RELATION: USES -> [GitRouter, GitDeps, GitHelpers, GitConfigRoutes, GitGiteaRoutes,
# GitRepoRoutes, GitRepoOperationsRoutes, GitRepoLifecycleRoutes,
# GitMergeRoutes, GitEnvironmentRoutes]
# @INVARIANT: git_service and os are module-level attributes for test monkeypatch compatibility.
# All route functions are re-exported for direct access via `from src.api.routes import git`.
import os
from src.services.git_service import GitService
from ._router import router
from ._deps import MAX_REPOSITORY_STATUS_BATCH
# -- git_service singleton (canonical instance; tests may monkeypatch git_routes.git_service) --
git_service = GitService()
# -- Config routes --
from ._config_routes import get_git_configs, create_git_config, update_git_config, delete_git_config, test_git_config # noqa: E501, F401
# -- Gitea routes --
from ._gitea_routes import list_gitea_repositories, create_gitea_repository, delete_gitea_repository, create_remote_repository # noqa: E501, F401
# -- Repo routes (core) --
from ._repo_routes import init_repository, get_repository_binding, delete_repository, get_branches, create_branch, checkout_branch # noqa: E501, F401
from ._helpers import _resolve_dashboard_id_from_ref, _resolve_dashboard_id_from_ref_async, _resolve_repo_key_from_ref # noqa: E501, F401 — re-exported for test monkeypatch compatibility
# -- Repo operations routes (commit, push, pull, status, diff, history, generate-message) --
from ._repo_operations_routes import commit_changes, push_changes, pull_changes, get_repository_status, get_repository_status_batch, get_repository_diff, get_history, generate_commit_message # noqa: E501, F401
# -- Repo lifecycle routes (sync, promote, deploy) --
from ._repo_lifecycle_routes import sync_dashboard, promote_dashboard, deploy_dashboard # noqa: E501, F401
# -- Merge routes --
from ._merge_routes import get_merge_status, get_merge_conflicts, resolve_merge_conflicts, abort_merge, continue_merge # noqa: E501, F401
# -- Environment routes --
from ._environment_routes import get_environments # noqa: E501, F401
# [/DEF:GitPackage:Module]

View File

@@ -0,0 +1,159 @@
# [DEF:GitConfigRoutes:Module]
# @COMPLEXITY: 2
# @PURPOSE: FastAPI endpoints for Git server configuration CRUD and connection testing.
# @LAYER: API
# @SEMANTICS: git, config, routes, crud
from typing import List
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.logger import belief_scope, logger
from src.dependencies import has_permission
from src.models.git import GitServerConfig
from src.api.routes.git_schemas import (
GitServerConfigCreate,
GitServerConfigSchema,
GitServerConfigUpdate,
)
from ._router import router
from ._helpers import _get_git_config_or_404
from ._deps import get_git_service
# [DEF:get_git_configs:Function]
# @COMPLEXITY: 2
# @PURPOSE: List all configured Git servers.
@router.get("/config", response_model=List[GitServerConfigSchema])
async def get_git_configs(
db: Session = Depends(get_db),
_=Depends(has_permission("git_config", "READ")),
):
with belief_scope("get_git_configs"):
configs = db.query(GitServerConfig).all()
result = []
for config in configs:
schema = GitServerConfigSchema.from_orm(config)
schema.pat = "********"
result.append(schema)
return result
# [/DEF:get_git_configs:Function]
# [DEF:create_git_config:Function]
# @COMPLEXITY: 2
# @PURPOSE: Register a new Git server configuration.
@router.post("/config", response_model=GitServerConfigSchema)
async def create_git_config(
config: GitServerConfigCreate,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("create_git_config"):
config_dict = config.dict(exclude={"config_id"})
db_config = GitServerConfig(**config_dict)
db.add(db_config)
db.commit()
db.refresh(db_config)
return db_config
# [/DEF:create_git_config:Function]
# [DEF:update_git_config:Function]
# @COMPLEXITY: 2
# @PURPOSE: Update an existing Git server configuration.
@router.put("/config/{config_id}", response_model=GitServerConfigSchema)
async def update_git_config(
config_id: str,
config_update: GitServerConfigUpdate,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_git_config"):
db_config = (
db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
)
if not db_config:
raise HTTPException(status_code=404, detail="Configuration not found")
update_data = config_update.dict(exclude_unset=True)
if update_data.get("pat") == "********":
update_data.pop("pat")
for key, value in update_data.items():
setattr(db_config, key, value)
db.commit()
db.refresh(db_config)
result_schema = GitServerConfigSchema.from_orm(db_config)
result_schema.pat = "********"
return result_schema
# [/DEF:update_git_config:Function]
# [DEF:delete_git_config:Function]
# @COMPLEXITY: 2
# @PURPOSE: Remove a Git server configuration.
@router.delete("/config/{config_id}")
async def delete_git_config(
config_id: str,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("delete_git_config"):
db_config = (
db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
)
if not db_config:
raise HTTPException(status_code=404, detail="Configuration not found")
db.delete(db_config)
db.commit()
return {"status": "success", "message": "Configuration deleted"}
# [/DEF:delete_git_config:Function]
# [DEF:test_git_config:Function]
# @COMPLEXITY: 2
# @PURPOSE: Validate connection to a Git server using provided credentials.
@router.post("/config/test")
async def test_git_config(
config: GitServerConfigCreate,
db: Session = Depends(get_db),
_=Depends(has_permission("git_config", "READ")),
):
with belief_scope("test_git_config"):
_git_service = get_git_service()
pat_to_use = config.pat
if pat_to_use == "********":
if config.config_id:
db_config = (
db.query(GitServerConfig)
.filter(GitServerConfig.id == config.config_id)
.first()
)
if db_config:
pat_to_use = db_config.pat
else:
db_config = (
db.query(GitServerConfig)
.filter(GitServerConfig.url == config.url)
.first()
)
if db_config:
pat_to_use = db_config.pat
success = await _git_service.test_connection(
config.provider, config.url, pat_to_use
)
if success:
return {"status": "success", "message": "Connection successful"}
else:
raise HTTPException(status_code=400, detail="Connection failed")
# [/DEF:test_git_config:Function]
# [/DEF:GitConfigRoutes:Module]

View File

@@ -0,0 +1,24 @@
# [DEF:GitDeps:Module]
# @COMPLEXITY: 1
# @PURPOSE: Shared dependency wiring for monkeypatch-safe git_service access and constants.
# @LAYER: API
# @SEMANTICS: git, deps, service-locator
# @INVARIANT: get_git_service() resolves from sys.modules at call time so test monkeypatching
# of git_routes.git_service (the __init__ attribute) takes effect across all submodules.
import sys
# Batch limit for repository status queries
MAX_REPOSITORY_STATUS_BATCH: int = 50
def get_git_service():
"""Resolve git_service from the git package root at call time.
This indirection ensures that test monkeypatching via
``monkeypatch.setattr(git_routes, 'git_service', mock)``
is visible to every submodule function, because each call
re-resolves ``sys.modules['src.api.routes.git'].git_service``.
"""
return sys.modules["src.api.routes.git"].git_service
# [/DEF:GitDeps:Module]

View File

@@ -0,0 +1,36 @@
# [DEF:GitEnvironmentRoutes:Module]
# @COMPLEXITY: 2
# @PURPOSE: FastAPI endpoint for listing deployment environments.
# @LAYER: API
# @SEMANTICS: git, environments, routes
from typing import List
from fastapi import Depends
from src.core.logger import belief_scope
from src.dependencies import get_config_manager, has_permission
from src.api.routes.git_schemas import DeploymentEnvironmentSchema
from ._router import router
# [DEF:get_environments:Function]
# @COMPLEXITY: 2
# @PURPOSE: List all deployment environments.
@router.get("/environments", response_model=List[DeploymentEnvironmentSchema])
async def get_environments(
config_manager=Depends(get_config_manager),
_=Depends(has_permission("environments", "READ")),
):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
return [
DeploymentEnvironmentSchema(
id=e.id, name=e.name, superset_url=e.url, is_active=True
)
for e in envs
]
# [/DEF:get_environments:Function]
# [/DEF:GitEnvironmentRoutes:Module]

View File

@@ -0,0 +1,188 @@
# [DEF:GitGiteaRoutes:Module]
# @COMPLEXITY: 2
# @PURPOSE: FastAPI endpoints for Gitea-specific repository operations.
# @LAYER: API
# @SEMANTICS: git, gitea, routes, repositories
from typing import List
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.logger import belief_scope
from src.dependencies import has_permission
from src.models.git import GitProvider
from src.api.routes.git_schemas import (
GiteaRepoCreateRequest,
GiteaRepoSchema,
RemoteRepoCreateRequest,
RemoteRepoSchema,
)
from ._router import router
from ._helpers import _get_git_config_or_404
from ._deps import get_git_service
# [DEF:list_gitea_repositories:Function]
# @COMPLEXITY: 2
# @PURPOSE: List repositories in Gitea for a saved Gitea config.
@router.get("/config/{config_id}/gitea/repos", response_model=List[GiteaRepoSchema])
async def list_gitea_repositories(
config_id: str,
db: Session = Depends(get_db),
_=Depends(has_permission("git_config", "READ")),
):
_gs = get_git_service()
with belief_scope("list_gitea_repositories"):
config = _get_git_config_or_404(db, config_id)
if config.provider != GitProvider.GITEA:
raise HTTPException(
status_code=400, detail="This endpoint supports GITEA provider only"
)
repos = await _gs.list_gitea_repositories(config.url, config.pat)
return [
GiteaRepoSchema(
name=repo.get("name", ""),
full_name=repo.get("full_name", ""),
private=bool(repo.get("private", False)),
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
ssh_url=repo.get("ssh_url"),
default_branch=repo.get("default_branch"),
)
for repo in repos
]
# [/DEF:list_gitea_repositories:Function]
# [DEF:create_gitea_repository:Function]
# @COMPLEXITY: 2
# @PURPOSE: Create a repository in Gitea for a saved Gitea config.
@router.post("/config/{config_id}/gitea/repos", response_model=GiteaRepoSchema)
async def create_gitea_repository(
config_id: str,
request: GiteaRepoCreateRequest,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
_gs = get_git_service()
with belief_scope("create_gitea_repository"):
config = _get_git_config_or_404(db, config_id)
if config.provider != GitProvider.GITEA:
raise HTTPException(
status_code=400, detail="This endpoint supports GITEA provider only"
)
repo = await _gs.create_gitea_repository(
server_url=config.url,
pat=config.pat,
name=request.name,
private=request.private,
description=request.description,
auto_init=request.auto_init,
default_branch=request.default_branch,
)
return GiteaRepoSchema(
name=repo.get("name", ""),
full_name=repo.get("full_name", ""),
private=bool(repo.get("private", False)),
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
ssh_url=repo.get("ssh_url"),
default_branch=repo.get("default_branch"),
)
# [/DEF:create_gitea_repository:Function]
# [DEF:delete_gitea_repository:Function]
# @COMPLEXITY: 2
# @PURPOSE: Delete repository in Gitea for a saved Gitea config.
@router.delete("/config/{config_id}/gitea/repos/{owner}/{repo_name}")
async def delete_gitea_repository(
config_id: str,
owner: str,
repo_name: str,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
_gs = get_git_service()
with belief_scope("delete_gitea_repository"):
config = _get_git_config_or_404(db, config_id)
if config.provider != GitProvider.GITEA:
raise HTTPException(
status_code=400, detail="This endpoint supports GITEA provider only"
)
await _gs.delete_gitea_repository(
server_url=config.url,
pat=config.pat,
owner=owner,
repo_name=repo_name,
)
return {"status": "success", "message": "Repository deleted"}
# [/DEF:delete_gitea_repository:Function]
# [DEF:create_remote_repository:Function]
# @COMPLEXITY: 2
# @PURPOSE: Create repository on remote Git server using selected provider config.
@router.post("/config/{config_id}/repositories", response_model=RemoteRepoSchema)
async def create_remote_repository(
config_id: str,
request: RemoteRepoCreateRequest,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
_gs = get_git_service()
with belief_scope("create_remote_repository"):
config = _get_git_config_or_404(db, config_id)
if config.provider == GitProvider.GITEA:
repo = await _gs.create_gitea_repository(
server_url=config.url,
pat=config.pat,
name=request.name,
private=request.private,
description=request.description,
auto_init=request.auto_init,
default_branch=request.default_branch,
)
elif config.provider == GitProvider.GITHUB:
repo = await _gs.create_github_repository(
server_url=config.url,
pat=config.pat,
name=request.name,
private=request.private,
description=request.description,
auto_init=request.auto_init,
default_branch=request.default_branch,
)
elif config.provider == GitProvider.GITLAB:
repo = await _gs.create_gitlab_repository(
server_url=config.url,
pat=config.pat,
name=request.name,
private=request.private,
description=request.description,
auto_init=request.auto_init,
default_branch=request.default_branch,
)
else:
raise HTTPException(
status_code=501,
detail=f"Provider {config.provider} is not supported",
)
return RemoteRepoSchema(
provider=config.provider,
name=repo.get("name", ""),
full_name=repo.get("full_name", repo.get("name", "")),
private=bool(repo.get("private", False)),
clone_url=repo.get("clone_url"),
html_url=repo.get("html_url"),
ssh_url=repo.get("ssh_url"),
default_branch=repo.get("default_branch"),
)
# [/DEF:create_remote_repository:Function]
# [/DEF:GitGiteaRoutes:Module]

View File

@@ -0,0 +1,345 @@
# [DEF:GitHelpers:Module]
# @COMPLEXITY: 3
# @PURPOSE: Shared helper functions for Git route modules.
# @LAYER: API
# @SEMANTICS: git, helpers, resolution, identity
# @RELATION USES -> [GitDeps]
# @RELATION USES -> [SupersetClient]
# @RELATION USES -> [UserDashboardPreference]
import os
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.core.superset_client import SupersetClient
from src.dependencies import get_config_manager
from src.models.auth import User
from src.models.git import GitServerConfig
from src.models.profile import UserDashboardPreference
from ._deps import get_git_service, MAX_REPOSITORY_STATUS_BATCH
# [DEF:_build_no_repo_status_payload:Function]
# @COMPLEXITY: 1
# @PURPOSE: Build a consistent status payload for dashboards without initialized repositories.
# @POST: Returns a stable payload compatible with frontend repository status parsing.
def _build_no_repo_status_payload() -> dict:
return {
"is_dirty": False,
"untracked_files": [],
"modified_files": [],
"staged_files": [],
"current_branch": None,
"upstream_branch": None,
"has_upstream": False,
"ahead_count": 0,
"behind_count": 0,
"is_diverged": False,
"sync_state": "NO_REPO",
"sync_status": "NO_REPO",
"has_repo": False,
}
# [/DEF:_build_no_repo_status_payload:Function]
# [DEF:_handle_unexpected_git_route_error:Function]
# @COMPLEXITY: 1
# @PURPOSE: Convert unexpected route-level exceptions to stable 500 API responses.
# @PRE: `error` is a non-HTTPException instance.
# @POST: Raises HTTPException(500) with route-specific context.
def _handle_unexpected_git_route_error(route_name: str, error: Exception) -> None:
logger.error(f"[{route_name}][Coherence:Failed] {error}")
raise HTTPException(status_code=500, detail=f"{route_name} failed: {str(error)}")
# [/DEF:_handle_unexpected_git_route_error:Function]
# [DEF:_resolve_repository_status:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve repository status for one dashboard with graceful NO_REPO semantics.
# @PRE: `dashboard_id` is a valid integer.
# @POST: Returns standard status payload or `NO_REPO` payload when repository path is absent.
def _resolve_repository_status(dashboard_id: int) -> dict:
git_service = get_git_service()
repo_path = git_service._get_repo_path(dashboard_id)
if not os.path.exists(repo_path):
logger.debug(
f"[get_repository_status][Action] Repository is not initialized for dashboard {dashboard_id}"
)
return _build_no_repo_status_payload()
try:
return git_service.get_status(dashboard_id)
except HTTPException as e:
if e.status_code == 404:
logger.debug(
f"[get_repository_status][Action] Repository is not initialized for dashboard {dashboard_id}"
)
return _build_no_repo_status_payload()
raise
# [/DEF:_resolve_repository_status:Function]
# [DEF:_get_git_config_or_404:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve GitServerConfig by id or raise 404.
def _get_git_config_or_404(db: Session, config_id: str) -> GitServerConfig:
config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
if not config:
raise HTTPException(status_code=404, detail="Git configuration not found")
return config
# [/DEF:_get_git_config_or_404:Function]
# [DEF:_find_dashboard_id_by_slug:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard numeric ID by slug in a specific environment.
def _find_dashboard_id_by_slug(
client: SupersetClient,
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{
"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
{
"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
]
for query in query_variants:
try:
_count, dashboards = client.get_dashboards_page(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug:Function]
# [DEF:_resolve_dashboard_id_from_ref:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard ID from slug-or-id reference for Git routes.
def _resolve_dashboard_id_from_ref(
dashboard_ref: str,
config_manager,
env_id: Optional[str] = None,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=400, detail="dashboard_ref is required")
if normalized_ref.isdigit():
return int(normalized_ref)
if not env_id:
raise HTTPException(
status_code=400,
detail="env_id is required for slug-based Git operations",
)
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
dashboard_id = _find_dashboard_id_by_slug(SupersetClient(env), normalized_ref)
if dashboard_id is None:
raise HTTPException(
status_code=404, detail=f"Dashboard slug '{normalized_ref}' not found"
)
return dashboard_id
# [/DEF:_resolve_dashboard_id_from_ref:Function]
# [DEF:_find_dashboard_id_by_slug_async:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard numeric ID by slug asynchronously for hot-path Git routes.
async def _find_dashboard_id_by_slug_async(
client: "AsyncSupersetClient",
dashboard_slug: str,
) -> Optional[int]:
query_variants = [
{
"filters": [{"col": "slug", "opr": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
{
"filters": [{"col": "slug", "op": "eq", "value": dashboard_slug}],
"page": 0,
"page_size": 1,
},
]
for query in query_variants:
try:
_count, dashboards = await client.get_dashboards_page_async(query=query)
if dashboards:
resolved_id = dashboards[0].get("id")
if resolved_id is not None:
return int(resolved_id)
except Exception:
continue
return None
# [/DEF:_find_dashboard_id_by_slug_async:Function]
# [DEF:_resolve_dashboard_id_from_ref_async:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve dashboard ID asynchronously from slug-or-id reference for hot Git routes.
async def _resolve_dashboard_id_from_ref_async(
dashboard_ref: str,
config_manager,
env_id: Optional[str] = None,
) -> int:
normalized_ref = str(dashboard_ref or "").strip()
if not normalized_ref:
raise HTTPException(status_code=400, detail="dashboard_ref is required")
if normalized_ref.isdigit():
return int(normalized_ref)
if not env_id:
raise HTTPException(
status_code=400,
detail="env_id is required for slug-based Git operations",
)
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if not env:
raise HTTPException(status_code=404, detail="Environment not found")
from src.core.async_superset_client import AsyncSupersetClient
client = AsyncSupersetClient(env)
try:
dashboard_id = await _find_dashboard_id_by_slug_async(client, normalized_ref)
if dashboard_id is None:
raise HTTPException(
status_code=404, detail=f"Dashboard slug '{normalized_ref}' not found"
)
return dashboard_id
finally:
await client.aclose()
# [/DEF:_resolve_dashboard_id_from_ref_async:Function]
# [DEF:_resolve_repo_key_from_ref:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve repository folder key with slug-first strategy and deterministic fallback.
def _resolve_repo_key_from_ref(
dashboard_ref: str,
dashboard_id: int,
config_manager,
env_id: Optional[str] = None,
) -> str:
normalized_ref = str(dashboard_ref or "").strip()
if normalized_ref and not normalized_ref.isdigit():
return normalized_ref
if env_id:
try:
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
if env:
payload = SupersetClient(env).get_dashboard(dashboard_id)
dashboard_data = (
payload.get("result", payload) if isinstance(payload, dict) else {}
)
dashboard_slug = dashboard_data.get("slug")
if dashboard_slug:
return str(dashboard_slug)
except Exception:
pass
return f"dashboard-{dashboard_id}"
# [/DEF:_resolve_repo_key_from_ref:Function]
# [DEF:_sanitize_optional_identity_value:Function]
# @COMPLEXITY: 1
# @PURPOSE: Normalize optional identity value into trimmed string or None.
def _sanitize_optional_identity_value(value: Optional[str]) -> Optional[str]:
normalized = str(value or "").strip()
if not normalized:
return None
return normalized
# [/DEF:_sanitize_optional_identity_value:Function]
# [DEF:_resolve_current_user_git_identity:Function]
# @COMPLEXITY: 2
# @PURPOSE: Resolve configured Git username/email from current user's profile preferences.
def _resolve_current_user_git_identity(
db: Session,
current_user: Optional[User],
) -> Optional[tuple[str, str]]:
if db is None or not hasattr(db, "query"):
return None
user_id = _sanitize_optional_identity_value(getattr(current_user, "id", None))
if not user_id:
return None
try:
preference = (
db.query(UserDashboardPreference)
.filter(UserDashboardPreference.user_id == user_id)
.first()
)
except Exception as resolve_error:
logger.warning(
"[_resolve_current_user_git_identity][Action] Failed to load profile preference for user %s: %s",
user_id,
resolve_error,
)
return None
if not preference:
return None
git_username = _sanitize_optional_identity_value(
getattr(preference, "git_username", None)
)
git_email = _sanitize_optional_identity_value(
getattr(preference, "git_email", None)
)
if not git_username or not git_email:
return None
return git_username, git_email
# [/DEF:_resolve_current_user_git_identity:Function]
# [DEF:_apply_git_identity_from_profile:Function]
# @COMPLEXITY: 2
# @PURPOSE: Apply user-scoped Git identity to repository-local config before write/pull operations.
def _apply_git_identity_from_profile(
dashboard_id: int,
db: Session,
current_user: Optional[User],
) -> None:
identity = _resolve_current_user_git_identity(db, current_user)
if not identity:
return
git_service = get_git_service()
configure_identity = getattr(git_service, "configure_identity", None)
if not callable(configure_identity):
return
git_username, git_email = identity
configure_identity(dashboard_id, git_username, git_email)
# [/DEF:_apply_git_identity_from_profile:Function]
# [/DEF:GitHelpers:Module]

View File

@@ -0,0 +1,169 @@
# [DEF:GitMergeRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: FastAPI endpoints for merge operations (status, conflicts, resolve, abort, continue).
# @LAYER: API
# @SEMANTICS: git, merge, routes, conflicts
from typing import List, Optional
from fastapi import Depends, HTTPException
from src.core.logger import belief_scope
from src.dependencies import get_config_manager, has_permission
from src.api.routes.git_schemas import (
MergeConflictFileSchema,
MergeContinueRequest,
MergeResolveRequest,
MergeStatusSchema,
)
from ._router import router
from ._helpers import (
_handle_unexpected_git_route_error,
)
from ._deps import get_git_service
# [DEF:get_merge_status:Function]
# @COMPLEXITY: 2
# @PURPOSE: Return unfinished-merge status for repository (web-only recovery support).
@router.get(
"/repositories/{dashboard_ref}/merge/status", response_model=MergeStatusSchema
)
async def get_merge_status(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_merge_status"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.get_merge_status(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_merge_status", e)
# [/DEF:get_merge_status:Function]
# [DEF:get_merge_conflicts:Function]
# @COMPLEXITY: 2
# @PURPOSE: Return conflicted files with mine/theirs previews for web conflict resolver.
@router.get(
"/repositories/{dashboard_ref}/merge/conflicts",
response_model=List[MergeConflictFileSchema],
)
async def get_merge_conflicts(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_merge_conflicts"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.get_merge_conflicts(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_merge_conflicts", e)
# [/DEF:get_merge_conflicts:Function]
# [DEF:resolve_merge_conflicts:Function]
# @COMPLEXITY: 3
# @PURPOSE: Apply mine/theirs/manual conflict resolutions from WebUI and stage files.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/merge/resolve")
async def resolve_merge_conflicts(
dashboard_ref: str,
resolve_data: MergeResolveRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("resolve_merge_conflicts"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
resolved_files = _gs.resolve_merge_conflicts(
dashboard_id,
[item.dict() for item in resolve_data.resolutions],
)
return {"status": "success", "resolved_files": resolved_files}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("resolve_merge_conflicts", e)
# [/DEF:resolve_merge_conflicts:Function]
# [DEF:abort_merge:Function]
# @COMPLEXITY: 2
# @PURPOSE: Abort unfinished merge from WebUI flow.
@router.post("/repositories/{dashboard_ref}/merge/abort")
async def abort_merge(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("abort_merge"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.abort_merge(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("abort_merge", e)
# [/DEF:abort_merge:Function]
# [DEF:continue_merge:Function]
# @COMPLEXITY: 3
# @PURPOSE: Finalize unfinished merge from WebUI flow.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/merge/continue")
async def continue_merge(
dashboard_ref: str,
continue_data: MergeContinueRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("continue_merge"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.continue_merge(dashboard_id, continue_data.message)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("continue_merge", e)
# [/DEF:continue_merge:Function]
# [/DEF:GitMergeRoutes:Module]

View File

@@ -0,0 +1,228 @@
# [DEF:GitRepoLifecycleRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: FastAPI endpoints for Git lifecycle operations (sync, promote, deploy).
# @LAYER: API
# @SEMANTICS: git, lifecycle, sync, promote, deploy
import typing
from typing import Optional
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.logger import belief_scope, logger
from src.dependencies import get_config_manager, get_current_user, has_permission
from src.models.auth import User
from src.models.git import GitProvider, GitRepository
from src.api.routes.git_schemas import (
DeployRequest,
PromoteRequest,
PromoteResponse,
)
from ._router import router
from ._helpers import (
_apply_git_identity_from_profile,
_get_git_config_or_404,
_handle_unexpected_git_route_error,
)
from ._deps import get_git_service
# [DEF:sync_dashboard:Function]
# @COMPLEXITY: 3
# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin.
# @RELATION: CALLS -> [GitPlugin]
@router.post("/repositories/{dashboard_ref}/sync")
async def sync_dashboard(
dashboard_ref: str,
env_id: Optional[str] = None,
source_env_id: typing.Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("sync_dashboard"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
return await plugin.execute(
{
"operation": "sync",
"dashboard_id": dashboard_id,
"source_env_id": source_env_id,
}
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("sync_dashboard", e)
# [/DEF:sync_dashboard:Function]
# [DEF:promote_dashboard:Function]
# @COMPLEXITY: 3
# @PURPOSE: Promote changes between branches via MR or direct merge.
# @RELATION: CALLS -> [GitPlugin]
@router.post("/repositories/{dashboard_ref}/promote", response_model=PromoteResponse)
async def promote_dashboard(
dashboard_ref: str,
payload: PromoteRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("promote_dashboard"):
from . import _resolve_dashboard_id_from_ref
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if not db_repo:
raise HTTPException(
status_code=404,
detail=f"Repository for dashboard {dashboard_ref} is not initialized",
)
config = _get_git_config_or_404(db, db_repo.config_id)
from_branch = payload.from_branch.strip()
to_branch = payload.to_branch.strip()
if not from_branch or not to_branch:
raise HTTPException(
status_code=400, detail="from_branch and to_branch are required"
)
if from_branch == to_branch:
raise HTTPException(
status_code=400, detail="from_branch and to_branch must be different"
)
mode = (payload.mode or "mr").strip().lower()
if mode == "direct":
reason = (payload.reason or "").strip()
if not reason:
raise HTTPException(
status_code=400, detail="Direct promote requires non-empty reason"
)
logger.warning(
"[promote_dashboard][PolicyViolation] Direct promote without MR by actor=unknown dashboard_ref=%s from=%s to=%s reason=%s",
dashboard_ref,
from_branch,
to_branch,
reason,
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
result = _gs.promote_direct_merge(
dashboard_id=dashboard_id,
from_branch=from_branch,
to_branch=to_branch,
)
return PromoteResponse(
mode="direct",
from_branch=from_branch,
to_branch=to_branch,
status=result.get("status", "merged"),
policy_violation=True,
)
title = (payload.title or "").strip() or f"Promote {from_branch} -> {to_branch}"
description = payload.description
if config.provider == GitProvider.GITEA:
pr = await _gs.create_gitea_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
)
elif config.provider == GitProvider.GITHUB:
pr = await _gs.create_github_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
draft=payload.draft,
)
elif config.provider == GitProvider.GITLAB:
pr = await _gs.create_gitlab_merge_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
remove_source_branch=payload.remove_source_branch,
)
else:
raise HTTPException(
status_code=501,
detail=f"Provider {config.provider} does not support promotion API",
)
return PromoteResponse(
mode="mr",
from_branch=from_branch,
to_branch=to_branch,
status=pr.get("status", "opened"),
url=pr.get("url"),
reference_id=str(pr.get("id")) if pr.get("id") is not None else None,
policy_violation=False,
)
# [/DEF:promote_dashboard:Function]
# [DEF:deploy_dashboard:Function]
# @COMPLEXITY: 3
# @PURPOSE: Deploy dashboard from Git to a target environment.
# @RELATION: CALLS -> [GitPlugin]
@router.post("/repositories/{dashboard_ref}/deploy")
async def deploy_dashboard(
dashboard_ref: str,
deploy_data: DeployRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("deploy_dashboard"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
from src.plugins.git_plugin import GitPlugin
plugin = GitPlugin()
return await plugin.execute(
{
"operation": "deploy",
"dashboard_id": dashboard_id,
"environment_id": deploy_data.environment_id,
}
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("deploy_dashboard", e)
# [/DEF:deploy_dashboard:Function]
# [/DEF:GitRepoLifecycleRoutes:Module]

View File

@@ -0,0 +1,365 @@
# [DEF:GitRepoOperationsRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: FastAPI endpoints for Git repository operations (commit, push, pull, status, diff, history).
# @LAYER: API
# @SEMANTICS: git, repository, operations, commit, push, pull, status
from typing import List, Optional
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.logger import belief_scope, logger
from src.dependencies import get_config_manager, get_current_user, has_permission
from src.models.auth import User
from src.models.git import GitRepository, GitServerConfig
from src.api.routes.git_schemas import (
CommitCreate,
CommitSchema,
RepoStatusBatchRequest,
RepoStatusBatchResponse,
)
from ._router import router
from ._helpers import (
_apply_git_identity_from_profile,
_build_no_repo_status_payload,
_handle_unexpected_git_route_error,
_resolve_repository_status,
)
from ._deps import get_git_service, MAX_REPOSITORY_STATUS_BATCH
# [DEF:commit_changes:Function]
# @COMPLEXITY: 2
# @PURPOSE: Stage and commit changes in the dashboard's repository.
@router.post("/repositories/{dashboard_ref}/commit")
async def commit_changes(
dashboard_ref: str,
commit_data: CommitCreate,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("commit_changes"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.commit_changes(
dashboard_id, commit_data.message, commit_data.files
)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("commit_changes", e)
# [/DEF:commit_changes:Function]
# [DEF:push_changes:Function]
# @COMPLEXITY: 2
# @PURPOSE: Push local commits to the remote repository.
@router.post("/repositories/{dashboard_ref}/push")
async def push_changes(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("push_changes"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_gs.push_changes(dashboard_id)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("push_changes", e)
# [/DEF:push_changes:Function]
# [DEF:pull_changes:Function]
# @COMPLEXITY: 3
# @PURPOSE: Pull changes from the remote repository.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/pull")
async def pull_changes(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("pull_changes"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
db_repo = None
config_url = None
config_provider = None
try:
db_repo_candidate = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if getattr(db_repo_candidate, "config_id", None):
db_repo = db_repo_candidate
config_row = (
db.query(GitServerConfig)
.filter(GitServerConfig.id == db_repo.config_id)
.first()
)
if config_row:
config_url = config_row.url
config_provider = config_row.provider
except Exception as diagnostics_error:
logger.warning(
"[pull_changes][Action] Failed to load repository binding diagnostics for dashboard %s: %s",
dashboard_id,
diagnostics_error,
)
logger.info(
"[pull_changes][Action] Route diagnostics dashboard_ref=%s env_id=%s resolved_dashboard_id=%s "
"binding_exists=%s binding_local_path=%s binding_remote_url=%s binding_config_id=%s config_provider=%s config_url=%s",
dashboard_ref,
env_id,
dashboard_id,
bool(db_repo),
(db_repo.local_path if db_repo else None),
(db_repo.remote_url if db_repo else None),
(db_repo.config_id if db_repo else None),
config_provider,
config_url,
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.pull_changes(dashboard_id)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("pull_changes", e)
# [/DEF:pull_changes:Function]
# [DEF:get_repository_status:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get current Git status for a dashboard repository.
@router.get("/repositories/{dashboard_ref}/status")
async def get_repository_status(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_repository_status"):
from . import _resolve_dashboard_id_from_ref_async
try:
dashboard_id = await _resolve_dashboard_id_from_ref_async(
dashboard_ref, config_manager, env_id
)
return _resolve_repository_status(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_repository_status", e)
# [/DEF:get_repository_status:Function]
# [DEF:get_repository_status_batch:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get Git statuses for multiple dashboard repositories in one request.
@router.post("/repositories/status/batch", response_model=RepoStatusBatchResponse)
async def get_repository_status_batch(
request: RepoStatusBatchRequest,
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_repository_status_batch"):
dashboard_ids = list(dict.fromkeys(request.dashboard_ids))
if len(dashboard_ids) > MAX_REPOSITORY_STATUS_BATCH:
logger.warning(
"[get_repository_status_batch][Action] Batch size %s exceeds limit %s. Truncating request.",
len(dashboard_ids),
MAX_REPOSITORY_STATUS_BATCH,
)
dashboard_ids = dashboard_ids[:MAX_REPOSITORY_STATUS_BATCH]
statuses = {}
for dashboard_id in dashboard_ids:
try:
statuses[str(dashboard_id)] = _resolve_repository_status(dashboard_id)
except HTTPException:
statuses[str(dashboard_id)] = {
**_build_no_repo_status_payload(),
"sync_state": "ERROR",
"sync_status": "ERROR",
}
except Exception as e:
logger.error(
f"[get_repository_status_batch][Coherence:Failed] Failed for dashboard {dashboard_id}: {e}"
)
statuses[str(dashboard_id)] = {
**_build_no_repo_status_payload(),
"sync_state": "ERROR",
"sync_status": "ERROR",
}
return RepoStatusBatchResponse(statuses=statuses)
# [/DEF:get_repository_status_batch:Function]
# [DEF:get_repository_diff:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get Git diff for a dashboard repository.
@router.get("/repositories/{dashboard_ref}/diff")
async def get_repository_diff(
dashboard_ref: str,
file_path: Optional[str] = None,
staged: bool = False,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_repository_diff"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.get_diff(dashboard_id, file_path, staged)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_repository_diff", e)
# [/DEF:get_repository_diff:Function]
# [DEF:get_history:Function]
# @COMPLEXITY: 2
# @PURPOSE: View commit history for a dashboard's repository.
@router.get("/repositories/{dashboard_ref}/history", response_model=List[CommitSchema])
async def get_history(
dashboard_ref: str,
limit: int = 50,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_history"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.get_commit_history(dashboard_id, limit)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_history", e)
# [/DEF:get_history:Function]
# [DEF:generate_commit_message:Function]
# @COMPLEXITY: 3
# @PURPOSE: Generate a suggested commit message using LLM.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/generate-message")
async def generate_commit_message(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("generate_commit_message"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
diff = _gs.get_diff(dashboard_id, staged=True)
if not diff:
diff = _gs.get_diff(dashboard_id, staged=False)
if not diff:
return {"message": "No changes detected"}
history_objs = _gs.get_commit_history(dashboard_id, limit=5)
history = [h.message for h in history_objs if hasattr(h, "message")]
from ...services.llm_provider import LLMProviderService
from ...plugins.llm_analysis.service import LLMClient
from ...plugins.llm_analysis.models import LLMProviderType
from ...services.llm_prompt_templates import (
DEFAULT_LLM_PROMPTS,
normalize_llm_settings,
resolve_bound_provider_id,
)
llm_service = LLMProviderService(db)
providers = llm_service.get_all_providers()
llm_settings = normalize_llm_settings(
config_manager.get_config().settings.llm
)
bound_provider_id = resolve_bound_provider_id(llm_settings, "git_commit")
provider = next((p for p in providers if p.id == bound_provider_id), None)
if not provider:
provider = next((p for p in providers if p.is_active), None)
if not provider:
raise HTTPException(
status_code=400, detail="No active LLM provider found"
)
api_key = llm_service.get_decrypted_api_key(provider.id)
client = LLMClient(
provider_type=LLMProviderType(provider.provider_type),
api_key=api_key,
base_url=provider.base_url,
default_model=provider.default_model,
)
from ...plugins.git.llm_extension import GitLLMExtension
extension = GitLLMExtension(client)
git_prompt = llm_settings["prompts"].get(
"git_commit_prompt",
DEFAULT_LLM_PROMPTS["git_commit_prompt"],
)
message = await extension.suggest_commit_message(
diff,
history,
prompt_template=git_prompt,
)
return {"message": message}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("generate_commit_message", e)
# [/DEF:generate_commit_message:Function]
# [/DEF:GitRepoOperationsRoutes:Module]

View File

@@ -0,0 +1,269 @@
# [DEF:GitRepoRoutes:Module]
# @COMPLEXITY: 3
# @PURPOSE: FastAPI endpoints for core Git repository operations (init, binding, branches, checkout).
# @LAYER: API
# @SEMANTICS: git, repository, routes, init, branches
from typing import List, Optional
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from src.core.database import get_db
from src.core.logger import belief_scope, logger
from src.dependencies import get_config_manager, get_current_user, has_permission
from src.models.auth import User
from src.models.git import GitRepository, GitServerConfig
from src.api.routes.git_schemas import (
BranchCheckout,
BranchCreate,
BranchSchema,
RepoInitRequest,
RepositoryBindingSchema,
)
from ._router import router
from ._helpers import (
_apply_git_identity_from_profile,
_get_git_config_or_404,
_handle_unexpected_git_route_error,
)
from ._deps import get_git_service
# [DEF:init_repository:Function]
# @COMPLEXITY: 3
# @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init.
# @RELATION: CALLS -> [GitService]
@router.post("/repositories/{dashboard_ref}/init")
async def init_repository(
dashboard_ref: str,
init_data: RepoInitRequest,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("init_repository"):
from . import _resolve_dashboard_id_from_ref, _resolve_repo_key_from_ref
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
repo_key = _resolve_repo_key_from_ref(
dashboard_ref, dashboard_id, config_manager, env_id
)
config = (
db.query(GitServerConfig)
.filter(GitServerConfig.id == init_data.config_id)
.first()
)
if not config:
raise HTTPException(status_code=404, detail="Git configuration not found")
try:
logger.info(
f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}"
)
_gs.init_repo(
dashboard_id,
init_data.remote_url,
config.pat,
repo_key=repo_key,
default_branch=config.default_branch,
)
repo_path = _gs._get_repo_path(dashboard_id, repo_key=repo_key)
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if not db_repo:
db_repo = GitRepository(
dashboard_id=dashboard_id,
config_id=config.id,
remote_url=init_data.remote_url,
local_path=repo_path,
current_branch="dev",
)
db.add(db_repo)
else:
db_repo.config_id = config.id
db_repo.remote_url = init_data.remote_url
db_repo.local_path = repo_path
db_repo.current_branch = "dev"
db.commit()
logger.info(
f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}"
)
return {"status": "success", "message": "Repository initialized"}
except Exception as e:
db.rollback()
logger.error(
f"[init_repository][Coherence:Failed] Failed to init repository: {e}"
)
if isinstance(e, HTTPException):
raise
_handle_unexpected_git_route_error("init_repository", e)
# [/DEF:init_repository:Function]
# [DEF:get_repository_binding:Function]
# @COMPLEXITY: 2
# @PURPOSE: Return repository binding with provider metadata for selected dashboard.
@router.get("/repositories/{dashboard_ref}", response_model=RepositoryBindingSchema)
async def get_repository_binding(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_repository_binding"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if not db_repo:
raise HTTPException(
status_code=404, detail="Repository not initialized"
)
config = _get_git_config_or_404(db, db_repo.config_id)
return RepositoryBindingSchema(
dashboard_id=db_repo.dashboard_id,
config_id=db_repo.config_id,
provider=config.provider,
remote_url=db_repo.remote_url,
local_path=db_repo.local_path,
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_repository_binding", e)
# [/DEF:get_repository_binding:Function]
# [DEF:delete_repository:Function]
# @COMPLEXITY: 2
# @PURPOSE: Delete local repository workspace and DB binding for selected dashboard.
@router.delete("/repositories/{dashboard_ref}")
async def delete_repository(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("delete_repository"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_gs.delete_repo(dashboard_id)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("delete_repository", e)
# [/DEF:delete_repository:Function]
# [DEF:get_branches:Function]
# @COMPLEXITY: 2
# @PURPOSE: List all branches for a dashboard's repository.
@router.get("/repositories/{dashboard_ref}/branches", response_model=List[BranchSchema])
async def get_branches(
dashboard_ref: str,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("get_branches"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return _gs.list_branches(dashboard_id)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_branches", e)
# [/DEF:get_branches:Function]
# [DEF:create_branch:Function]
# @COMPLEXITY: 2
# @PURPOSE: Create a new branch in the dashboard's repository.
@router.post("/repositories/{dashboard_ref}/branches")
async def create_branch(
dashboard_ref: str,
branch_data: BranchCreate,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("create_branch"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_apply_git_identity_from_profile(dashboard_id, db, current_user)
_gs.create_branch(
dashboard_id, branch_data.name, branch_data.from_branch
)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("create_branch", e)
# [/DEF:create_branch:Function]
# [DEF:checkout_branch:Function]
# @COMPLEXITY: 2
# @PURPOSE: Switch the dashboard's repository to a specific branch.
@router.post("/repositories/{dashboard_ref}/checkout")
async def checkout_branch(
dashboard_ref: str,
checkout_data: BranchCheckout,
env_id: Optional[str] = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("checkout_branch"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_gs.checkout_branch(dashboard_id, checkout_data.name)
return {"status": "success"}
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("checkout_branch", e)
# [/DEF:checkout_branch:Function]
# [/DEF:GitRepoRoutes:Module]

View File

@@ -0,0 +1,10 @@
# [DEF:GitRouter:Module]
# @COMPLEXITY: 1
# @PURPOSE: Shared APIRouter for all Git route modules.
# @LAYER: API
# @SEMANTICS: git, routes, fastapi, router
from fastapi import APIRouter
router = APIRouter(tags=["git"])
# [/DEF:GitRouter:Module]