diff --git a/.kilocode/mcp.json b/.kilocode/mcp.json index b31fbda2..5e7ca9c1 100644 --- a/.kilocode/mcp.json +++ b/.kilocode/mcp.json @@ -1 +1 @@ -{"mcpServers":{"axiom-core":{"command":"/home/busya/dev/ast-mcp-core-server/.venv/bin/python","args":["-c","from src.server import main; main()"],"env":{"PYTHONPATH":"/home/busya/dev/ast-mcp-core-server"},"alwaysAllow":["read_grace_outline_tool","ast_search_tool","get_semantic_context_tool","build_task_context_tool","workspace_semantic_health_tool","audit_contracts_tool","diff_contract_semantics_tool","impact_analysis_tool","simulate_patch_tool","patch_contract_tool","rename_contract_id_tool","move_contract_tool","extract_contract_tool","infer_missing_relations_tool","map_runtime_trace_to_contracts_tool","trace_tests_for_contract_tool","scaffold_contract_tests_tool","search_contracts_tool"]}}} \ No newline at end of file +{"mcpServers":{"axiom-core":{"command":"/home/busya/dev/ast-mcp-core-server/.venv/bin/python","args":["-c","from src.server import main; main()"],"env":{"PYTHONPATH":"/home/busya/dev/ast-mcp-core-server"},"alwaysAllow":["read_grace_outline_tool","ast_search_tool","get_semantic_context_tool","build_task_context_tool","audit_contracts_tool","diff_contract_semantics_tool","simulate_patch_tool","patch_contract_tool","rename_contract_id_tool","move_contract_tool","extract_contract_tool","infer_missing_relations_tool","map_runtime_trace_to_contracts_tool","scaffold_contract_tests_tool","search_contracts_tool","reindex_workspace_tool","prune_contract_metadata_tool","workspace_semantic_health_tool","trace_tests_for_contract_tool"]}}} \ No newline at end of file diff --git a/backend/delete_running_tasks.py b/backend/delete_running_tasks.py index cfe50162..a9eefac0 100644 --- a/backend/delete_running_tasks.py +++ b/backend/delete_running_tasks.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 -# [DEF:backend.delete_running_tasks:Module] +# [DEF:DeleteRunningTasksUtil:Module] # @PURPOSE: Script to delete tasks with RUNNING status from the database. # @LAYER: Utility # @SEMANTICS: maintenance, database, cleanup +# @RELATION: DEPENDS_ON ->[TasksSessionLocal] +# @RELATION: DEPENDS_ON ->[TaskRecord] from sqlalchemy.orm import Session from src.core.database import TasksSessionLocal @@ -41,4 +43,4 @@ def delete_running_tasks(): if __name__ == "__main__": delete_running_tasks() -# [/DEF:backend.delete_running_tasks:Module] +# [/DEF:DeleteRunningTasksUtil:Module] diff --git a/backend/src/__init__.py b/backend/src/__init__.py index 0f97a802..e791e586 100644 --- a/backend/src/__init__.py +++ b/backend/src/__init__.py @@ -1,3 +1,3 @@ -# [DEF:src:Package] +# [DEF:SrcRoot:Module] # @PURPOSE: Canonical backend package root for application, scripts, and tests. -# [/DEF:src:Package] +# [/DEF:SrcRoot:Module] diff --git a/backend/src/api/auth.py b/backend/src/api/auth.py index 86b671c4..7483352d 100755 --- a/backend/src/api/auth.py +++ b/backend/src/api/auth.py @@ -1,12 +1,12 @@ -# [DEF:backend.src.api.auth:Module] +# [DEF:AuthApi:Module] # # @COMPLEXITY: 3 # @SEMANTICS: api, auth, routes, login, logout # @PURPOSE: Authentication API endpoints. # @LAYER: API -# @RELATION: USES ->[backend.src.services.auth_service.AuthService] -# @RELATION: USES ->[backend.src.core.database.get_auth_db] -# +# @RELATION: USES ->[AuthService:Class] +# @RELATION: USES ->[get_auth_db:Function] +# @RELATION: DEPENDS_ON ->[AuthRepository:Class] # @INVARIANT: All auth endpoints must return consistent error codes. # [SECTION: IMPORTS] @@ -38,6 +38,8 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) # @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials. # @PARAM: db (Session) - Auth database session. # @RETURN: Token - The generated JWT token. +# @RELATION: CALLS -> [AuthService.authenticate_user] +# @RELATION: CALLS -> [AuthService.create_session] @router.post("/login", response_model=Token) async def login_for_access_token( form_data: OAuth2PasswordRequestForm = Depends(), @@ -64,6 +66,7 @@ async def login_for_access_token( # @POST: Returns the current user's data. # @PARAM: current_user (UserSchema) - The user extracted from the token. # @RETURN: UserSchema - The current user profile. +# @RELATION: DEPENDS_ON -> [get_current_user] @router.get("/me", response_model=UserSchema) async def read_users_me(current_user: UserSchema = Depends(get_current_user)): with belief_scope("api.auth.me"): @@ -75,6 +78,8 @@ async def read_users_me(current_user: UserSchema = Depends(get_current_user)): # @PURPOSE: Logs out the current user (placeholder for session revocation). # @PRE: Valid JWT token provided. # @POST: Returns success message. +# @PARAM: current_user (UserSchema) - The user extracted from the token. +# @RELATION: DEPENDS_ON -> [get_current_user] @router.post("/logout") async def logout(current_user: UserSchema = Depends(get_current_user)): with belief_scope("api.auth.logout"): @@ -88,6 +93,7 @@ async def logout(current_user: UserSchema = Depends(get_current_user)): # @COMPLEXITY: 3 # @PURPOSE: Initiates the ADFS OIDC login flow. # @POST: Redirects the user to ADFS. +# @RELATION: USES -> [is_adfs_configured] @router.get("/login/adfs") async def login_adfs(request: starlette.requests.Request): with belief_scope("api.auth.login_adfs"): @@ -104,6 +110,8 @@ async def login_adfs(request: starlette.requests.Request): # @COMPLEXITY: 3 # @PURPOSE: Handles the callback from ADFS after successful authentication. # @POST: Provisions user JIT and returns session token. +# @RELATION: CALLS -> [AuthService.provision_adfs_user] +# @RELATION: CALLS -> [AuthService.create_session] @router.get("/callback/adfs", name="auth_callback_adfs") async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)): with belief_scope("api.auth.callback_adfs"): @@ -122,4 +130,4 @@ async def auth_callback_adfs(request: starlette.requests.Request, db: Session = return auth_service.create_session(user) # [/DEF:auth_callback_adfs:Function] -# [/DEF:backend.src.api.auth:Module] \ No newline at end of file +# [/DEF:AuthApi:Module] \ No newline at end of file diff --git a/backend/src/api/routes/__tests__/test_git_status_route.py b/backend/src/api/routes/__tests__/test_git_status_route.py index 2bd8a8ef..acd61cbd 100644 --- a/backend/src/api/routes/__tests__/test_git_status_route.py +++ b/backend/src/api/routes/__tests__/test_git_status_route.py @@ -3,7 +3,7 @@ # @SEMANTICS: tests, git, api, status, no_repo # @PURPOSE: Validate status endpoint behavior for missing and error repository states. # @LAYER: Domain (Tests) -# @RELATION: CALLS -> src.api.routes.git.get_repository_status +# @RELATION: VERIFIES -> [backend.src.api.routes.git] from fastapi import HTTPException import pytest diff --git a/backend/src/api/routes/admin.py b/backend/src/api/routes/admin.py index 2ce813e9..c8a1fa34 100644 --- a/backend/src/api/routes/admin.py +++ b/backend/src/api/routes/admin.py @@ -1,4 +1,4 @@ -# [DEF:backend.src.api.routes.admin:Module] +# [DEF:AdminApi:Module] # # @COMPLEXITY: 3 # @SEMANTICS: api, admin, users, roles, permissions @@ -93,6 +93,12 @@ async def create_user( # [DEF:update_user:Function] # @COMPLEXITY: 3 # @PURPOSE: Updates an existing user. +# @PRE: Current user has 'Admin' role. +# @POST: User record is updated in the database. +# @PARAM: user_id (str) - Target user UUID. +# @PARAM: user_in (UserUpdate) - Updated user data. +# @PARAM: db (Session) - Auth database session. +# @RETURN: UserSchema - The updated user profile. @router.put("/users/{user_id}", response_model=UserSchema) async def update_user( user_id: str, @@ -128,6 +134,11 @@ async def update_user( # [DEF:delete_user:Function] # @COMPLEXITY: 3 # @PURPOSE: Deletes a user. +# @PRE: Current user has 'Admin' role. +# @POST: User record is removed from the database. +# @PARAM: user_id (str) - Target user UUID. +# @PARAM: db (Session) - Auth database session. +# @RETURN: None @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: str, @@ -331,4 +342,4 @@ async def create_ad_mapping( return new_mapping # [/DEF:create_ad_mapping:Function] -# [/DEF:backend.src.api.routes.admin:Module] \ No newline at end of file +# [/DEF:AdminApi:Module] \ No newline at end of file diff --git a/backend/src/api/routes/clean_release_v2.py b/backend/src/api/routes/clean_release_v2.py index 82943f44..d10409d8 100644 --- a/backend/src/api/routes/clean_release_v2.py +++ b/backend/src/api/routes/clean_release_v2.py @@ -1,8 +1,6 @@ # [DEF:backend.src.api.routes.clean_release_v2:Module] # @COMPLEXITY: 3 -# @SEMANTICS: api, clean-release, v2, headless # @PURPOSE: Redesigned clean release API for headless candidate lifecycle. -# @LAYER: API from fastapi import APIRouter, Depends, HTTPException, status from typing import List, Dict, Any @@ -18,17 +16,40 @@ from ...services.clean_release.dto import CandidateDTO, ManifestDTO router = APIRouter(prefix="/api/v2/clean-release", tags=["Clean Release V2"]) +# [DEF:ApprovalRequest:Class] +# @COMPLEXITY: 1 +# @PURPOSE: Schema for approval request payload. +# @RELATION: USES -> [CandidateDTO] class ApprovalRequest(dict): pass +# [/DEF:ApprovalRequest:Class] +# [DEF:PublishRequest:Class] +# @COMPLEXITY: 1 +# @PURPOSE: Schema for publication request payload. +# @RELATION: USES -> [CandidateDTO] class PublishRequest(dict): pass +# [/DEF:PublishRequest:Class] +# [DEF:RevokeRequest:Class] +# @COMPLEXITY: 1 +# @PURPOSE: Schema for revocation request payload. +# @RELATION: USES -> [CandidateDTO] class RevokeRequest(dict): pass +# [/DEF:RevokeRequest:Class] +# [DEF:register_candidate:Function] +# @COMPLEXITY: 3 +# @PURPOSE: Register a new release candidate. +# @PRE: Payload contains required fields (id, version, source_snapshot_ref, created_by). +# @POST: Candidate is saved in repository. +# @RETURN: CandidateDTO +# @RELATION: CALLS -> [CleanReleaseRepository.save_candidate] +# @RELATION: USES -> [CandidateDTO] @router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED) async def register_candidate( payload: Dict[str, Any], @@ -51,7 +72,14 @@ async def register_candidate( created_by=candidate.created_by, status=CandidateStatus(candidate.status) ) +# [/DEF:register_candidate:Function] +# [DEF:import_artifacts:Function] +# @COMPLEXITY: 3 +# @PURPOSE: Associate artifacts with a release candidate. +# @PRE: Candidate exists. +# @POST: Artifacts are processed (placeholder). +# @RELATION: CALLS -> [CleanReleaseRepository.get_candidate] @router.post("/candidates/{candidate_id}/artifacts") async def import_artifacts( candidate_id: str, @@ -75,7 +103,16 @@ async def import_artifacts( pass return {"status": "success"} +# [/DEF:import_artifacts:Function] +# [DEF:build_manifest:Function] +# @COMPLEXITY: 3 +# @PURPOSE: Generate distribution manifest for a candidate. +# @PRE: Candidate exists. +# @POST: Manifest is created and saved. +# @RETURN: ManifestDTO +# @RELATION: CALLS -> [CleanReleaseRepository.save_manifest] +# @RELATION: CALLS -> [CleanReleaseRepository.get_candidate] @router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED) async def build_manifest( candidate_id: str, @@ -109,7 +146,12 @@ async def build_manifest( source_snapshot_ref=manifest.source_snapshot_ref, content_json=manifest.content_json ) +# [/DEF:build_manifest:Function] +# [DEF:approve_candidate_endpoint:Function] +# @COMPLEXITY: 3 +# @PURPOSE: Endpoint to record candidate approval. +# @RELATION: CALLS -> [approve_candidate] @router.post("/candidates/{candidate_id}/approve") async def approve_candidate_endpoint( candidate_id: str, @@ -128,8 +170,13 @@ async def approve_candidate_endpoint( raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"}) return {"status": "ok", "decision": decision.decision, "decision_id": decision.id} +# [/DEF:approve_candidate_endpoint:Function] +# [DEF:reject_candidate_endpoint:Function] +# @COMPLEXITY: 3 +# @PURPOSE: Endpoint to record candidate rejection. +# @RELATION: CALLS -> [reject_candidate] @router.post("/candidates/{candidate_id}/reject") async def reject_candidate_endpoint( candidate_id: str, @@ -148,8 +195,13 @@ async def reject_candidate_endpoint( raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"}) return {"status": "ok", "decision": decision.decision, "decision_id": decision.id} +# [/DEF:reject_candidate_endpoint:Function] +# [DEF:publish_candidate_endpoint:Function] +# @COMPLEXITY: 3 +# @PURPOSE: Endpoint to publish an approved candidate. +# @RELATION: CALLS -> [publish_candidate] @router.post("/candidates/{candidate_id}/publish") async def publish_candidate_endpoint( candidate_id: str, @@ -181,8 +233,13 @@ async def publish_candidate_endpoint( "status": publication.status, }, } +# [/DEF:publish_candidate_endpoint:Function] +# [DEF:revoke_publication_endpoint:Function] +# @COMPLEXITY: 3 +# @PURPOSE: Endpoint to revoke a previous publication. +# @RELATION: CALLS -> [revoke_publication] @router.post("/publications/{publication_id}/revoke") async def revoke_publication_endpoint( publication_id: str, @@ -212,5 +269,6 @@ async def revoke_publication_endpoint( "status": publication.status, }, } +# [/DEF:revoke_publication_endpoint:Function] # [/DEF:backend.src.api.routes.clean_release_v2:Module] \ No newline at end of file diff --git a/backend/src/api/routes/dashboards.py b/backend/src/api/routes/dashboards.py index f1e0108e..f3ea445a 100644 --- a/backend/src/api/routes/dashboards.py +++ b/backend/src/api/routes/dashboards.py @@ -4,7 +4,7 @@ # @SEMANTICS: api, dashboards, resources, hub # @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status # @LAYER: API -# @RELATION: DEPENDS_ON ->[backend.src.dependencies] +# @RELATION: DEPENDS_ON ->[AppDependencies] # @RELATION: DEPENDS_ON ->[backend.src.services.resource_service.ResourceService] # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient] # diff --git a/backend/src/api/routes/datasets.py b/backend/src/api/routes/datasets.py index 99b42e60..a34273ea 100644 --- a/backend/src/api/routes/datasets.py +++ b/backend/src/api/routes/datasets.py @@ -4,14 +4,14 @@ # @SEMANTICS: api, datasets, resources, hub # @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress # @LAYER: API -# @RELATION: DEPENDS_ON ->[backend.src.dependencies] +# @RELATION: DEPENDS_ON ->[AppDependencies] # @RELATION: DEPENDS_ON ->[backend.src.services.resource_service.ResourceService] # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient] # # @INVARIANT: All dataset responses include last_task metadata # [SECTION: IMPORTS] -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from typing import List, Optional from pydantic import BaseModel, Field from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission diff --git a/backend/src/api/routes/migration.py b/backend/src/api/routes/migration.py index d9febca6..bccbd8b0 100644 --- a/backend/src/api/routes/migration.py +++ b/backend/src/api/routes/migration.py @@ -1,9 +1,9 @@ -# [DEF:backend.src.api.routes.migration:Module] +# [DEF:MigrationApi:Module] # @COMPLEXITY: 5 # @SEMANTICS: api, migration, dashboards, sync, dry-run # @PURPOSE: HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints. # @LAYER: Infra -# @RELATION: DEPENDS_ON ->[backend.src.dependencies] +# @RELATION: DEPENDS_ON ->[AppDependencies] # @RELATION: DEPENDS_ON ->[backend.src.core.database] # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient] # @RELATION: DEPENDS_ON ->[backend.src.core.migration.dry_run_orchestrator.MigrationDryRunService] @@ -315,4 +315,4 @@ async def trigger_sync_now( } # [/DEF:trigger_sync_now:Function] -# [/DEF:backend.src.api.routes.migration:Module] +# [/DEF:MigrationApi:Module] diff --git a/backend/src/api/routes/reports.py b/backend/src/api/routes/reports.py index 87a0cdea..3988c31d 100644 --- a/backend/src/api/routes/reports.py +++ b/backend/src/api/routes/reports.py @@ -4,7 +4,7 @@ # @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints. # @LAYER: UI (API) # @RELATION: DEPENDS_ON -> [backend.src.services.reports.report_service.ReportsService] -# @RELATION: DEPENDS_ON -> [backend.src.dependencies] +# @RELATION: DEPENDS_ON -> [AppDependencies] # @INVARIANT: Endpoints are read-only and do not trigger long-running tasks. # @PRE: Reports service and dependencies are initialized. # @POST: Router is configured and endpoints are ready for registration. diff --git a/backend/src/app.py b/backend/src/app.py index 91dbc2d1..eecaf96d 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -3,7 +3,7 @@ # @SEMANTICS: app, main, entrypoint, fastapi # @PURPOSE: The main entry point for the FastAPI application. It initializes the app, configures CORS, sets up dependencies, includes API routers, and defines the WebSocket endpoint for log streaming. # @LAYER: UI (API) -# @RELATION: DEPENDS_ON ->[backend.src.dependencies] +# @RELATION: DEPENDS_ON ->[AppDependencies] # @RELATION: DEPENDS_ON ->[backend.src.api.routes] # @INVARIANT: Only one FastAPI app instance exists per process. # @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect. @@ -69,6 +69,8 @@ async def shutdown_event(): scheduler.stop() # [/DEF:shutdown_event:Function] +# [DEF:app_middleware:Block] +# @PURPOSE: Configure application-wide middleware (Session, CORS). # Configure Session Middleware (required by Authlib for OAuth2 flow) from .core.auth.config import auth_config app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY) @@ -81,6 +83,7 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +# [/DEF:app_middleware:Block] # [DEF:network_error_handler:Function] @@ -129,6 +132,8 @@ async def log_requests(request: Request, call_next): ) # [/DEF:log_requests:Function] +# [DEF:api_routes:Block] +# @PURPOSE: Register all application API routers. # Include API routes app.include_router(auth.router) app.include_router(admin.router) @@ -150,6 +155,7 @@ app.include_router(clean_release.router) app.include_router(clean_release_v2.router) app.include_router(profile.router) app.include_router(health.router) +# [/DEF:api_routes:Block] # [DEF:api.include_routers:Action] diff --git a/backend/src/core/auth/repository.py b/backend/src/core/auth/repository.py index 06acfed2..0fb7db0d 100644 --- a/backend/src/core/auth/repository.py +++ b/backend/src/core/auth/repository.py @@ -1,59 +1,118 @@ # [DEF:AuthRepository:Module] -# # @TIER: CRITICAL # @COMPLEXITY: 5 # @SEMANTICS: auth, repository, database, user, role, permission -# @PURPOSE: Data access layer for authentication and user preference entities. -# @LAYER: Domain -# @PRE: SQLAlchemy session manager and auth models are available. -# @POST: Provides transactional access to Auth-related database entities. -# @SIDE_EFFECT: Performs database I/O via SQLAlchemy sessions. -# @DATA_CONTRACT: Input[Session] -> Model[User, Role, Permission, UserDashboardPreference] -# @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session] -# @RELATION: [DEPENDS_ON] ->[User:Class] -# @RELATION: [DEPENDS_ON] ->[Role:Class] -# @RELATION: [DEPENDS_ON] ->[Permission:Class] -# @RELATION: [DEPENDS_ON] ->[UserDashboardPreference:Class] -# @RELATION: [DEPENDS_ON] ->[belief_scope:Function] +# @PURPOSE: Data access layer for authentication and user preference entities. +# @LAYER: Domain +# @RELATION: DEPENDS_ON ->[sqlalchemy.orm.Session] +# @RELATION: DEPENDS_ON ->[User:Class] +# @RELATION: DEPENDS_ON ->[Role:Class] +# @RELATION: DEPENDS_ON ->[Permission:Class] +# @RELATION: DEPENDS_ON ->[UserDashboardPreference:Class] +# @RELATION: DEPENDS_ON ->[belief_scope:Function] # @INVARIANT: All database read/write operations must execute via the injected SQLAlchemy session boundary. -# +# @DATA_CONTRACT: Session -> [User | Role | Permission | UserDashboardPreference] + # [SECTION: IMPORTS] from typing import List, Optional - from sqlalchemy.orm import Session, selectinload - -from ...models.auth import Permission, Role, User +from ...models.auth import Permission, Role, User, ADGroupMapping from ...models.profile import UserDashboardPreference from ..logger import belief_scope, logger # [/SECTION] -# [DEF:AuthRepository:Module] -# -# @TIER: CRITICAL -# @COMPLEXITY: 5 -# @SEMANTICS: auth, repository, database, user, role, permission -# @PURPOSE: Data access layer for authentication and user preference entities. -# @LAYER: Domain -# @PRE: SQLAlchemy session manager and auth models are available. -# @POST: Provides transactional access to Auth-related database entities. -# @SIDE_EFFECT: Performs database I/O via SQLAlchemy sessions. -# @DATA_CONTRACT: Input[Session] -> Model[User, Role, Permission, UserDashboardPreference] -# @RELATION: [DEPENDS_ON] ->[User:Class] -# @RELATION: [DEPENDS_ON] ->[Role:Class] -# @RELATION: [DEPENDS_ON] ->[Permission:Class] -# @RELATION: [DEPENDS_ON] ->[UserDashboardPreference:Class] -# @RELATION: [DEPENDS_ON] ->[belief_scope:Function] -# @INVARIANT: All database read/write operations must execute via the injected SQLAlchemy session boundary. -# -# [SECTION: IMPORTS] -from typing import List, Optional +# [DEF:AuthRepository:Class] +# @PURPOSE: Provides low-level CRUD operations for identity and authorization records. +class AuthRepository: + # @PURPOSE: Initialize repository with database session. + def __init__(self, db: Session): + self.db = db -from sqlalchemy.orm import Session, selectinload + # [DEF:get_user_by_id:Function] + # @PURPOSE: Retrieve user by UUID. + # @PRE: user_id is a valid UUID string. + # @POST: Returns User object if found, else None. + def get_user_by_id(self, user_id: str) -> Optional[User]: + with belief_scope("AuthRepository.get_user_by_id"): + logger.reason(f"Fetching user by id: {user_id}") + result = self.db.query(User).filter(User.id == user_id).first() + logger.reflect(f"User found: {result is not None}") + return result + # [/DEF:get_user_by_id:Function] -from ...models.auth import Permission, Role, User -from ...models.profile import UserDashboardPreference -from ..logger import belief_scope, logger -# [/SECTION] + # [DEF:get_user_by_username:Function] + # @PURPOSE: Retrieve user by username. + # @PRE: username is a non-empty string. + # @POST: Returns User object if found, else None. + def get_user_by_username(self, username: str) -> Optional[User]: + with belief_scope("AuthRepository.get_user_by_username"): + logger.reason(f"Fetching user by username: {username}") + result = self.db.query(User).filter(User.username == username).first() + logger.reflect(f"User found: {result is not None}") + return result + # [/DEF:get_user_by_username:Function] + + # [DEF:get_role_by_id:Function] + # @PURPOSE: Retrieve role by UUID with permissions preloaded. + def get_role_by_id(self, role_id: str) -> Optional[Role]: + with belief_scope("AuthRepository.get_role_by_id"): + return self.db.query(Role).options(selectinload(Role.permissions)).filter(Role.id == role_id).first() + # [/DEF:get_role_by_id:Function] + + # [DEF:get_role_by_name:Function] + # @PURPOSE: Retrieve role by unique name. + def get_role_by_name(self, name: str) -> Optional[Role]: + with belief_scope("AuthRepository.get_role_by_name"): + return self.db.query(Role).filter(Role.name == name).first() + # [/DEF:get_role_by_name:Function] + + # [DEF:get_permission_by_id:Function] + # @PURPOSE: Retrieve permission by UUID. + def get_permission_by_id(self, permission_id: str) -> Optional[Permission]: + with belief_scope("AuthRepository.get_permission_by_id"): + return self.db.query(Permission).filter(Permission.id == permission_id).first() + # [/DEF:get_permission_by_id:Function] + + # [DEF:get_permission_by_resource_action:Function] + # @PURPOSE: Retrieve permission by resource and action tuple. + def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]: + with belief_scope("AuthRepository.get_permission_by_resource_action"): + return self.db.query(Permission).filter( + Permission.resource == resource, + Permission.action == action + ).first() + # [/DEF:get_permission_by_resource_action:Function] + + # [DEF:list_permissions:Function] + # @PURPOSE: List all system permissions. + def list_permissions(self) -> List[Permission]: + with belief_scope("AuthRepository.list_permissions"): + return self.db.query(Permission).all() + # [/DEF:list_permissions:Function] + + # [DEF:get_user_dashboard_preference:Function] + # @PURPOSE: Retrieve dashboard filters/preferences for a user. + def get_user_dashboard_preference(self, user_id: str) -> Optional[UserDashboardPreference]: + with belief_scope("AuthRepository.get_user_dashboard_preference"): + return self.db.query(UserDashboardPreference).filter( + UserDashboardPreference.user_id == user_id + ).first() + # [/DEF:get_user_dashboard_preference:Function] + + # [DEF:get_roles_by_ad_groups:Function] + # @PURPOSE: Retrieve roles that match a list of AD group names. + # @PRE: groups is a list of strings representing AD group identifiers. + # @POST: Returns a list of Role objects mapped to the provided AD groups. + def get_roles_by_ad_groups(self, groups: List[str]) -> List[Role]: + with belief_scope("AuthRepository.get_roles_by_ad_groups"): + logger.reason(f"Fetching roles for AD groups: {groups}") + if not groups: + return [] + return self.db.query(Role).join(ADGroupMapping).filter( + ADGroupMapping.ad_group.in_(groups) + ).all() + # [/DEF:get_roles_by_ad_groups:Function] + +# [/DEF:AuthRepository:Class] # [/DEF:AuthRepository:Module] -# [/DEF:AuthRepository:Module] diff --git a/backend/src/core/config_manager.py b/backend/src/core/config_manager.py index 943b2aaf..5d37e61b 100644 --- a/backend/src/core/config_manager.py +++ b/backend/src/core/config_manager.py @@ -1,6 +1,5 @@ # [DEF:ConfigManager:Module] # -# @TIER: CRITICAL # @COMPLEXITY: 5 # @SEMANTICS: config, manager, persistence, migration, postgresql # @PURPOSE: Manages application configuration persistence in DB with one-time migration from legacy JSON. @@ -9,13 +8,13 @@ # @POST: Configuration is loaded into memory and logger is configured. # @SIDE_EFFECT: Performs DB I/O and may update global logging level. # @DATA_CONTRACT: Input[json, record] -> Model[AppConfig] +# @INVARIANT: Configuration must always be representable by AppConfig and persisted under global record id. # @RELATION: [DEPENDS_ON] ->[AppConfig] # @RELATION: [DEPENDS_ON] ->[SessionLocal] # @RELATION: [DEPENDS_ON] ->[AppConfigRecord] # @RELATION: [DEPENDS_ON] ->[FileIO] # @RELATION: [CALLS] ->[logger] # @RELATION: [CALLS] ->[configure_logger] -# @INVARIANT: Configuration must always be representable by AppConfig and persisted under global record id. # import json import os @@ -31,7 +30,6 @@ from .logger import logger, configure_logger, belief_scope # [DEF:ConfigManager:Class] -# @TIER: CRITICAL # @COMPLEXITY: 5 # @PURPOSE: Handles application configuration load, validation, mutation, and persistence lifecycle. # @PRE: Database is accessible and AppConfigRecord schema is loaded. diff --git a/backend/src/core/superset_client.py b/backend/src/core/superset_client.py index 824b2e54..6626a9a1 100644 --- a/backend/src/core/superset_client.py +++ b/backend/src/core/superset_client.py @@ -57,7 +57,7 @@ class SupersetClient: ) self.delete_before_reimport: bool = False app_logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.") - # [/DEF:__init__:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.__init__:Function] # [DEF:backend.src.core.superset_client.SupersetClient.authenticate:Function] # @COMPLEXITY: 3 @@ -69,7 +69,7 @@ class SupersetClient: def authenticate(self) -> Dict[str, str]: with belief_scope("SupersetClient.authenticate"): return self.network.authenticate() - # [/DEF:authenticate:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.authenticate:Function] @property # [DEF:backend.src.core.superset_client.SupersetClient.headers:Function] @@ -80,7 +80,7 @@ class SupersetClient: def headers(self) -> dict: with belief_scope("headers"): return self.network.headers - # [/DEF:headers:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.headers:Function] # [SECTION: DASHBOARD OPERATIONS] @@ -116,7 +116,7 @@ class SupersetClient: total_count = len(paginated_data) app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count) return total_count, paginated_data - # [/DEF:get_dashboards:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dashboards:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_page:Function] # @COMPLEXITY: 3 @@ -153,7 +153,7 @@ class SupersetClient: result = response_json.get("result", []) total_count = response_json.get("count", len(result)) return total_count, result - # [/DEF:get_dashboards_page:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_page:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_summary:Function] # @COMPLEXITY: 3 @@ -238,7 +238,7 @@ class SupersetClient: f"sampled={min(len(result), max_debug_samples)})" ) return result - # [/DEF:get_dashboards_summary:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_summary:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_summary_page:Function] # @COMPLEXITY: 3 @@ -311,7 +311,7 @@ class SupersetClient: }) return total_count, result - # [/DEF:get_dashboards_summary_page:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_summary_page:Function] # [DEF:backend.src.core.superset_client.SupersetClient._extract_owner_labels:Function] # @COMPLEXITY: 1 @@ -339,7 +339,7 @@ class SupersetClient: if label and label not in normalized: normalized.append(label) return normalized - # [/DEF:_extract_owner_labels:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._extract_owner_labels:Function] # [DEF:backend.src.core.superset_client.SupersetClient._extract_user_display:Function] # @COMPLEXITY: 1 @@ -368,7 +368,7 @@ class SupersetClient: if email: return email return None - # [/DEF:_extract_user_display:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._extract_user_display:Function] # [DEF:backend.src.core.superset_client.SupersetClient._sanitize_user_text:Function] # @COMPLEXITY: 1 @@ -382,7 +382,7 @@ class SupersetClient: if not normalized: return None return normalized - # [/DEF:_sanitize_user_text:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._sanitize_user_text:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboard:Function] # @COMPLEXITY: 3 @@ -395,7 +395,7 @@ class SupersetClient: with belief_scope("SupersetClient.get_dashboard", f"id={dashboard_id}"): response = self.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}") return cast(Dict, response) - # [/DEF:get_dashboard:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dashboard:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_chart:Function] # @COMPLEXITY: 3 @@ -408,7 +408,7 @@ class SupersetClient: with belief_scope("SupersetClient.get_chart", f"id={chart_id}"): response = self.network.request(method="GET", endpoint=f"/chart/{chart_id}") return cast(Dict, response) - # [/DEF:get_chart:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_chart:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboard_detail:Function] # @COMPLEXITY: 3 @@ -426,6 +426,7 @@ class SupersetClient: charts: List[Dict] = [] datasets: List[Dict] = [] + # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboard_detail.extract_dataset_id_from_form_data:Function] def extract_dataset_id_from_form_data(form_data: Optional[Dict]) -> Optional[int]: if not isinstance(form_data, dict): return None @@ -448,6 +449,7 @@ class SupersetClient: return int(ds_id) if ds_id is not None else None except (TypeError, ValueError): return None + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dashboard_detail.extract_dataset_id_from_form_data:Function] # Canonical endpoints from Superset OpenAPI: # /dashboard/{id_or_slug}/charts and /dashboard/{id_or_slug}/datasets. @@ -603,7 +605,7 @@ class SupersetClient: "chart_count": len(unique_charts), "dataset_count": len(unique_datasets), } - # [/DEF:get_dashboard_detail:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dashboard_detail:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_charts:Function] # @COMPLEXITY: 3 @@ -623,7 +625,7 @@ class SupersetClient: pagination_options={"base_query": validated_query, "results_field": "result"}, ) return len(paginated_data), paginated_data - # [/DEF:get_charts:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_charts:Function] # [DEF:backend.src.core.superset_client.SupersetClient._extract_chart_ids_from_layout:Function] # @COMPLEXITY: 1 @@ -656,7 +658,7 @@ class SupersetClient: walk(payload) return found - # [/DEF:_extract_chart_ids_from_layout:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._extract_chart_ids_from_layout:Function] # [DEF:backend.src.core.superset_client.SupersetClient.export_dashboard:Function] # @COMPLEXITY: 3 @@ -681,7 +683,7 @@ class SupersetClient: filename = self._resolve_export_filename(response, dashboard_id) app_logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename) return response.content, filename - # [/DEF:export_dashboard:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.export_dashboard:Function] # [DEF:backend.src.core.superset_client.SupersetClient.import_dashboard:Function] # @COMPLEXITY: 3 @@ -713,7 +715,7 @@ class SupersetClient: self.delete_dashboard(target_id) app_logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id) return self._do_import(file_path) - # [/DEF:import_dashboard:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.import_dashboard:Function] # [DEF:backend.src.core.superset_client.SupersetClient.delete_dashboard:Function] # @COMPLEXITY: 3 @@ -731,11 +733,7 @@ class SupersetClient: app_logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id) else: app_logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response) - # [/DEF:delete_dashboard:Function] - - # [/SECTION] - - # [SECTION: DATASET OPERATIONS] + # [/DEF:backend.src.core.superset_client.SupersetClient.delete_dashboard:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_datasets:Function] # @COMPLEXITY: 3 @@ -756,7 +754,7 @@ class SupersetClient: total_count = len(paginated_data) app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count) return total_count, paginated_data - # [/DEF:get_datasets:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_datasets:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_datasets_summary:Function] # @COMPLEXITY: 3 @@ -781,7 +779,7 @@ class SupersetClient: "database": ds.get("database", {}).get("database_name", "Unknown") }) return result - # [/DEF:get_datasets_summary:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_datasets_summary:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_dataset_detail:Function] # @COMPLEXITY: 3 @@ -894,7 +892,7 @@ class SupersetClient: app_logger.info(f"[get_dataset_detail][Exit] Got dataset {dataset_id} with {len(column_info)} columns and {len(linked_dashboards)} linked dashboards") return result - # [/DEF:get_dataset_detail:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dataset_detail:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_dataset:Function] # @COMPLEXITY: 3 @@ -910,7 +908,7 @@ class SupersetClient: response = cast(Dict, response) app_logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id) return response - # [/DEF:get_dataset:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_dataset:Function] # [DEF:backend.src.core.superset_client.SupersetClient.update_dataset:Function] # @COMPLEXITY: 3 @@ -932,11 +930,7 @@ class SupersetClient: response = cast(Dict, response) app_logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id) return response - # [/DEF:update_dataset:Function] - - # [/SECTION] - - # [SECTION: DATABASE OPERATIONS] + # [/DEF:backend.src.core.superset_client.SupersetClient.update_dataset:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_databases:Function] # @COMPLEXITY: 3 @@ -959,7 +953,7 @@ class SupersetClient: total_count = len(paginated_data) app_logger.info("[get_databases][Exit] Found %d databases.", total_count) return total_count, paginated_data - # [/DEF:get_databases:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_databases:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_database:Function] # @COMPLEXITY: 3 @@ -975,7 +969,7 @@ class SupersetClient: response = cast(Dict, response) app_logger.info("[get_database][Exit] Got database %s.", database_id) return response - # [/DEF:get_database:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_database:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_databases_summary:Function] # @COMPLEXITY: 3 @@ -996,7 +990,7 @@ class SupersetClient: db['engine'] = db.pop('backend', None) return databases - # [/DEF:get_databases_summary:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_databases_summary:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_database_by_uuid:Function] # @COMPLEXITY: 3 @@ -1012,11 +1006,7 @@ class SupersetClient: } _, databases = self.get_databases(query=query) return databases[0] if databases else None - # [/DEF:get_database_by_uuid:Function] - - # [/SECTION] - - # [SECTION: HELPERS] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_database_by_uuid:Function] # [DEF:backend.src.core.superset_client.SupersetClient._resolve_target_id_for_delete:Function] # @COMPLEXITY: 1 @@ -1039,7 +1029,7 @@ class SupersetClient: except Exception as e: app_logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e) return None - # [/DEF:_resolve_target_id_for_delete:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._resolve_target_id_for_delete:Function] # [DEF:backend.src.core.superset_client.SupersetClient._do_import:Function] # @COMPLEXITY: 1 @@ -1061,7 +1051,7 @@ class SupersetClient: extra_data={"overwrite": "true"}, timeout=self.env.timeout * 2, ) - # [/DEF:_do_import:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._do_import:Function] # [DEF:backend.src.core.superset_client.SupersetClient._validate_export_response:Function] # @COMPLEXITY: 1 @@ -1075,7 +1065,7 @@ class SupersetClient: raise SupersetAPIError(f"Получен не ZIP-архив (Content-Type: {content_type})") if not response.content: raise SupersetAPIError("Получены пустые данные при экспорте") - # [/DEF:_validate_export_response:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._validate_export_response:Function] # [DEF:backend.src.core.superset_client.SupersetClient._resolve_export_filename:Function] # @COMPLEXITY: 1 @@ -1091,7 +1081,7 @@ class SupersetClient: filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip" app_logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename) return filename - # [/DEF:_resolve_export_filename:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._resolve_export_filename:Function] # [DEF:backend.src.core.superset_client.SupersetClient._validate_query_params:Function] # @COMPLEXITY: 1 @@ -1104,7 +1094,7 @@ class SupersetClient: # Using 100 avoids partial fetches when larger values are silently truncated. base_query = {"page": 0, "page_size": 100} return {**base_query, **(query or {})} - # [/DEF:_validate_query_params:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._validate_query_params:Function] # [DEF:backend.src.core.superset_client.SupersetClient._fetch_total_object_count:Function] # @COMPLEXITY: 1 @@ -1119,7 +1109,7 @@ class SupersetClient: query_params={"page": 0, "page_size": 1}, count_field="count", ) - # [/DEF:_fetch_total_object_count:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._fetch_total_object_count:Function] # [DEF:backend.src.core.superset_client.SupersetClient._fetch_all_pages:Function] # @COMPLEXITY: 1 @@ -1129,7 +1119,7 @@ class SupersetClient: def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]: with belief_scope("_fetch_all_pages"): return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options) - # [/DEF:_fetch_all_pages:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._fetch_all_pages:Function] # [DEF:backend.src.core.superset_client.SupersetClient._validate_import_file:Function] # @COMPLEXITY: 1 @@ -1146,7 +1136,7 @@ class SupersetClient: with zipfile.ZipFile(path, "r") as zf: if not any(n.endswith("metadata.yaml") for n in zf.namelist()): raise SupersetAPIError(f"Архив {zip_path} не содержит 'metadata.yaml'") - # [/DEF:_validate_import_file:Function] + # [/DEF:backend.src.core.superset_client.SupersetClient._validate_import_file:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_all_resources:Function] # @COMPLEXITY: 3 @@ -1170,12 +1160,8 @@ class SupersetClient: query = {"columns": config["columns"]} if since_dttm: - # Format to ISO 8601 string for Superset filter - # e.g. "2026-02-25T13:24:32.186" or integer milliseconds. - # Assuming standard ISO string works: - # The user's example had value: 0 (which might imply ms or int) but often it accepts strings. import math - # Use int milliseconds to be safe, as "0" was in the user example + # Use int milliseconds to be safe timestamp_ms = math.floor(since_dttm.timestamp() * 1000) query["filters"] = [ @@ -1185,7 +1171,6 @@ class SupersetClient: "value": timestamp_ms } ] - # Also we must request `changed_on_dttm` just in case, though API usually filters regardless of columns validated = self._validate_query_params(query) data = self._fetch_all_pages( @@ -1194,9 +1179,7 @@ class SupersetClient: ) app_logger.info("[get_all_resources][Exit] Fetched %d %s resources.", len(data), resource_type) return data - # [/DEF:get_all_resources:Function] - - # [/SECTION] + # [/DEF:backend.src.core.superset_client.SupersetClient.get_all_resources:Function] # [/DEF:backend.src.core.superset_client.SupersetClient:Class] diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index 3c57c730..687c0992 100755 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -1,9 +1,18 @@ -# [DEF:backend.src.dependencies:Module] +# [DEF:AppDependencies:Module] # @COMPLEXITY: 3 # @SEMANTICS: dependency, injection, singleton, factory, auth, jwt # @PURPOSE: Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports. # @LAYER: Core # @RELATION: Used by main app and API routers to get access to shared instances. +# @RELATION: CALLS ->[CleanReleaseRepository] +# @RELATION: CALLS ->[ConfigManager] +# @RELATION: CALLS ->[PluginLoader] +# @RELATION: CALLS ->[SchedulerService] +# @RELATION: CALLS ->[TaskManager] +# @RELATION: CALLS ->[get_all_plugin_configs] +# @RELATION: CALLS ->[get_db] +# @RELATION: CALLS ->[info] +# @RELATION: CALLS ->[init_db] from pathlib import Path from fastapi import Depends, HTTPException, status @@ -234,4 +243,4 @@ def has_permission(resource: str, action: str): return permission_checker # [/DEF:has_permission:Function] -# [/DEF:backend.src.dependencies:Module] +# [/DEF:AppDependencies:Module] diff --git a/backend/src/models/auth.py b/backend/src/models/auth.py index 0d8de21a..ed3c86f9 100644 --- a/backend/src/models/auth.py +++ b/backend/src/models/auth.py @@ -54,8 +54,10 @@ class User(Base): username = Column(String, unique=True, index=True, nullable=False) email = Column(String, unique=True, index=True, nullable=True) password_hash = Column(String, nullable=True) + full_name = Column(String, nullable=True) auth_source = Column(String, default="LOCAL") # LOCAL or ADFS is_active = Column(Boolean, default=True) + is_ad_user = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.utcnow) last_login = Column(DateTime, nullable=True) diff --git a/backend/src/services/auth_service.py b/backend/src/services/auth_service.py index 7064c9f2..4070465b 100644 --- a/backend/src/services/auth_service.py +++ b/backend/src/services/auth_service.py @@ -1,5 +1,4 @@ # [DEF:backend.src.services.auth_service:Module] -# # @COMPLEXITY: 5 # @SEMANTICS: auth, service, business-logic, login, jwt, adfs, jit-provisioning # @PURPOSE: Orchestrates credential authentication and ADFS JIT user provisioning. @@ -9,28 +8,29 @@ # @RELATION: [DEPENDS_ON] ->[backend.src.core.auth.jwt.create_access_token] # @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.User] # @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.Role] -# # @INVARIANT: Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles. # @PRE: Core auth models and security utilities available. # @POST: User identity verified and session tokens issued according to role scopes. # @SIDE_EFFECT: Writes last login timestamps and JIT-provisions external users. # @DATA_CONTRACT: [Credentials | ADFSClaims] -> [UserEntity | SessionToken] -# [SECTION: IMPORTS] -from typing import Dict, Any +from typing import Dict, Any, Optional, List +from datetime import datetime from sqlalchemy.orm import Session -from ..models.auth import User, Role + from ..core.auth.repository import AuthRepository from ..core.auth.security import verify_password from ..core.auth.jwt import create_access_token +from ..core.auth.logger import log_security_event +from ..models.auth import User, Role from ..core.logger import belief_scope -# [/SECTION] + # [DEF:AuthService:Class] # @COMPLEXITY: 3 # @PURPOSE: Provides high-level authentication services. class AuthService: - # [DEF:__init__:Function] + # [DEF:AuthService.__init__:Function] # @COMPLEXITY: 1 # @PURPOSE: Initializes the authentication service with repository access over an active DB session. # @PRE: db is a valid SQLAlchemy Session instance bound to the auth persistence context. @@ -39,10 +39,11 @@ class AuthService: # @DATA_CONTRACT: Input(Session) -> Model(AuthRepository) # @PARAM: db (Session) - SQLAlchemy session. def __init__(self, db: Session): + self.db = db self.repo = AuthRepository(db) - # [/DEF:__init__:Function] + # [/DEF:AuthService.__init__:Function] - # [DEF:authenticate_user:Function] + # [DEF:AuthService.authenticate_user:Function] # @COMPLEXITY: 3 # @PURPOSE: Validates credentials and account state for local username/password authentication. # @PRE: username and password are non-empty credential inputs. @@ -52,23 +53,24 @@ class AuthService: # @PARAM: username (str) - The username. # @PARAM: password (str) - The plain password. # @RETURN: Optional[User] - The authenticated user or None. - def authenticate_user(self, username: str, password: str): - with belief_scope("AuthService.authenticate_user"): + def authenticate_user(self, username: str, password: str) -> Optional[User]: + with belief_scope("auth.authenticate_user"): user = self.repo.get_user_by_username(username) - if not user: + if not user or not user.is_active: return None - if not user.is_active: - return None - - if not user.password_hash or not verify_password(password, user.password_hash): + if not verify_password(password, user.password_hash): return None - self.repo.update_last_login(user) + # Update last login + user.last_login = datetime.utcnow() + self.db.commit() + self.db.refresh(user) + return user - # [/DEF:authenticate_user:Function] + # [/DEF:AuthService.authenticate_user:Function] - # [DEF:create_session:Function] + # [DEF:AuthService.create_session:Function] # @COMPLEXITY: 3 # @PURPOSE: Issues an access token payload for an already authenticated user. # @PRE: user is a valid User entity containing username and iterable roles with role.name values. @@ -77,24 +79,16 @@ class AuthService: # @DATA_CONTRACT: Input(User) -> Output(Dict[str, str]{access_token, token_type}) # @PARAM: user (User) - The authenticated user. # @RETURN: Dict[str, str] - Session data. - def create_session(self, user) -> Dict[str, str]: - with belief_scope("AuthService.create_session"): - # Collect role names for scopes - scopes = [role.name for role in user.roles] - - token_data = { - "sub": user.username, - "scopes": scopes - } - - access_token = create_access_token(data=token_data) - return { - "access_token": access_token, - "token_type": "bearer" - } - # [/DEF:create_session:Function] + def create_session(self, user: User) -> Dict[str, str]: + with belief_scope("auth.create_session"): + roles = [role.name for role in user.roles] + access_token = create_access_token( + data={"sub": user.username, "scopes": roles} + ) + return {"access_token": access_token, "token_type": "bearer"} + # [/DEF:AuthService.create_session:Function] - # [DEF:provision_adfs_user:Function] + # [DEF:AuthService.provision_adfs_user:Function] # @COMPLEXITY: 3 # @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings. # @PRE: user_info contains identity claims where at least one of 'upn' or 'email' is present; 'groups' may be absent. @@ -104,32 +98,34 @@ class AuthService: # @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token. # @RETURN: User - The provisioned user. def provision_adfs_user(self, user_info: Dict[str, Any]) -> User: - with belief_scope("AuthService.provision_adfs_user"): + with belief_scope("auth.provision_adfs_user"): username = user_info.get("upn") or user_info.get("email") email = user_info.get("email") - ad_groups = user_info.get("groups", []) + groups = user_info.get("groups", []) user = self.repo.get_user_by_username(username) if not user: user = User( username=username, email=email, + full_name=user_info.get("name"), auth_source="ADFS", - is_active=True + is_active=True, + is_ad_user=True ) - self.repo.db.add(user) - - # Update roles based on group mappings - from ..models.auth import ADGroupMapping - mapped_roles = self.repo.db.query(Role).join(ADGroupMapping).filter( - ADGroupMapping.ad_group.in_(ad_groups) - ).all() + self.db.add(user) + log_security_event("USER_PROVISIONED", username, {"source": "ADFS"}) + # Sync roles from AD groups + mapped_roles = self.repo.get_roles_by_ad_groups(groups) user.roles = mapped_roles - self.repo.db.commit() - self.repo.db.refresh(user) + + user.last_login = datetime.utcnow() + self.db.commit() + self.db.refresh(user) + return user - # [/DEF:provision_adfs_user:Function] + # [/DEF:AuthService.provision_adfs_user:Function] # [/DEF:AuthService:Class] # [/DEF:backend.src.services.auth_service:Module] \ No newline at end of file diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index 8a0a06e0..a5147ef9 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -44,31 +44,35 @@ class GitService: # @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. + # @RELATION: CALLS -> [GitService._resolve_base_path] + # @RELATION: CALLS -> [GitService._ensure_base_path_exists] def __init__(self, base_path: str = "git_repos"): with belief_scope("GitService.__init__"): backend_root = Path(__file__).parents[2] self.legacy_base_path = str((backend_root / "git_repos").resolve()) self.base_path = self._resolve_base_path(base_path) self._ensure_base_path_exists() - # [/DEF:__init__:Function] + # [/DEF:backend.src.services.git_service.GitService.__init__:Function] - # [DEF:_ensure_base_path_exists:Function] + # [DEF:backend.src.services.git_service.GitService._ensure_base_path_exists:Function] # @PURPOSE: Ensure the repositories root directory exists and is a directory. # @PRE: self.base_path is resolved to filesystem path. # @POST: self.base_path exists as directory or raises ValueError. # @RETURN: None + # @RELATION: USES -> [self.base_path] def _ensure_base_path_exists(self) -> None: base = Path(self.base_path) if base.exists() and not base.is_dir(): raise ValueError(f"Git repositories base path is not a directory: {self.base_path}") base.mkdir(parents=True, exist_ok=True) - # [/DEF:_ensure_base_path_exists:Function] + # [/DEF:backend.src.services.git_service.GitService._ensure_base_path_exists:Function] # [DEF:backend.src.services.git_service.GitService._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 + # @RELATION: USES -> [AppConfigRecord] def _resolve_base_path(self, base_path: str) -> str: # Resolve relative to backend directory for backward compatibility. backend_root = Path(__file__).parents[2] @@ -104,24 +108,26 @@ class GitService: except Exception as e: logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}") return fallback_path - # [/DEF:_resolve_base_path:Function] + # [/DEF:backend.src.services.git_service.GitService._resolve_base_path:Function] - # [DEF:_normalize_repo_key:Function] + # [DEF:backend.src.services.git_service.GitService._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 + # @RELATION: USES -> [re.sub] def _normalize_repo_key(self, repo_key: Optional[str]) -> str: raw_key = str(repo_key or "").strip().lower() normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-") return normalized or "dashboard" - # [/DEF:_normalize_repo_key:Function] + # [/DEF:backend.src.services.git_service.GitService._normalize_repo_key:Function] - # [DEF:_update_repo_local_path:Function] + # [DEF:backend.src.services.git_service.GitService._update_repo_local_path:Function] # @PURPOSE: Persist repository local_path in GitRepository table when record exists. # @PRE: dashboard_id is valid integer. # @POST: local_path is updated for existing record. # @RETURN: None + # @RELATION: USES -> [GitRepository] def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None: try: session = SessionLocal() @@ -138,13 +144,14 @@ class GitService: session.close() except Exception as e: logger.warning(f"[_update_repo_local_path][Coherence:Failed] {e}") - # [/DEF:_update_repo_local_path:Function] + # [/DEF:backend.src.services.git_service.GitService._update_repo_local_path:Function] - # [DEF:_migrate_repo_directory:Function] + # [DEF:backend.src.services.git_service.GitService._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 + # @RELATION: CALLS -> [GitService._update_repo_local_path] 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) @@ -168,13 +175,14 @@ class GitService: f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}" ) return target_abs - # [/DEF:_migrate_repo_directory:Function] + # [/DEF:backend.src.services.git_service.GitService._migrate_repo_directory:Function] - # [DEF:_ensure_gitflow_branches:Function] + # [DEF:backend.src.services.git_service.GitService._ensure_gitflow_branches:Function] # @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin. # @PRE: repo is a valid GitPython Repo instance. # @POST: main, dev, preprod are available in local repository and pushed to origin when available. # @RETURN: None + # @RELATION: USES -> [Repo] def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None: with belief_scope("GitService._ensure_gitflow_branches"): required_branches = ["main", "dev", "preprod"] @@ -252,7 +260,7 @@ class GitService: logger.warning( f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}" ) - # [/DEF:_ensure_gitflow_branches:Function] + # [/DEF:backend.src.services.git_service.GitService._ensure_gitflow_branches:Function] # [DEF:backend.src.services.git_service.GitService._get_repo_path:Function] # @PURPOSE: Resolves the local filesystem path for a dashboard's repository. @@ -261,6 +269,9 @@ class GitService: # @PRE: dashboard_id is an integer. # @POST: Returns DB-local_path when present, otherwise base_path/. # @RETURN: str + # @RELATION: CALLS -> [GitService._normalize_repo_key] + # @RELATION: CALLS -> [GitService._migrate_repo_directory] + # @RELATION: CALLS -> [GitService._update_repo_local_path] def _get_repo_path(self, dashboard_id: int, repo_key: Optional[str] = None) -> str: with belief_scope("GitService._get_repo_path"): if dashboard_id is None: @@ -300,9 +311,9 @@ class GitService: self._update_repo_local_path(dashboard_id, target_path) return target_path - # [/DEF:_get_repo_path:Function] + # [/DEF:backend.src.services.git_service.GitService._get_repo_path:Function] - # [DEF:init_repo:Function] + # [DEF:backend.src.services.git_service.GitService.init_repo:Function] # @PURPOSE: Initialize or clone a repository for a dashboard. # @PARAM: dashboard_id (int) # @PARAM: remote_url (str) @@ -311,6 +322,8 @@ class GitService: # @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided. # @POST: Repository is cloned or opened at the local path. # @RETURN: Repo - GitPython Repo object. + # @RELATION: CALLS -> [GitService._get_repo_path] + # @RELATION: CALLS -> [GitService._ensure_gitflow_branches] def init_repo(self, dashboard_id: int, remote_url: str, pat: str, repo_key: Optional[str] = None) -> Repo: with belief_scope("GitService.init_repo"): self._ensure_base_path_exists() @@ -344,13 +357,14 @@ class GitService: repo = Repo.clone_from(auth_url, repo_path) self._ensure_gitflow_branches(repo, dashboard_id) return repo - # [/DEF:init_repo:Function] + # [/DEF:backend.src.services.git_service.GitService.init_repo:Function] - # [DEF:delete_repo:Function] + # [DEF:backend.src.services.git_service.GitService.delete_repo:Function] # @PURPOSE: Remove local repository and DB binding for a dashboard. # @PRE: dashboard_id is a valid integer. # @POST: Local path is deleted when present and GitRepository row is removed. # @RETURN: None + # @RELATION: CALLS -> [GitService._get_repo_path] def delete_repo(self, dashboard_id: int) -> None: with belief_scope("GitService.delete_repo"): repo_path = self._get_repo_path(dashboard_id) @@ -392,13 +406,14 @@ class GitService: raise HTTPException(status_code=500, detail=f"Failed to delete repository: {str(e)}") finally: session.close() - # [/DEF:delete_repo:Function] + # [/DEF:backend.src.services.git_service.GitService.delete_repo:Function] # [DEF:backend.src.services.git_service.GitService.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 + # @RELATION: CALLS -> [GitService._get_repo_path] def get_repo(self, dashboard_id: int) -> Repo: with belief_scope("GitService.get_repo"): repo_path = self._get_repo_path(dashboard_id) @@ -410,13 +425,14 @@ class GitService: except Exception as e: logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}") raise HTTPException(status_code=500, detail="Failed to open local Git repository") - # [/DEF:get_repo:Function] + # [/DEF:backend.src.services.git_service.GitService.get_repo:Function] - # [DEF:configure_identity:Function] + # [DEF:backend.src.services.git_service.GitService.configure_identity:Function] # @PURPOSE: Configure repository-local Git committer identity for user-scoped operations. # @PRE: dashboard_id repository exists; git_username/git_email may be empty. # @POST: Repository config has user.name and user.email when both identity values are provided. # @RETURN: None + # @RELATION: CALLS -> [GitService.get_repo] def configure_identity( self, dashboard_id: int, @@ -441,13 +457,14 @@ class GitService: except Exception as e: logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}") raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {str(e)}") - # [/DEF:configure_identity:Function] + # [/DEF:backend.src.services.git_service.GitService.configure_identity:Function] - # [DEF:list_branches:Function] + # [DEF:backend.src.services.git_service.GitService.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] + # @RELATION: CALLS -> [GitService.get_repo] def list_branches(self, dashboard_id: int) -> List[dict]: with belief_scope("GitService.list_branches"): repo = self.get_repo(dashboard_id) @@ -495,14 +512,15 @@ class GitService: }) return branches - # [/DEF:list_branches:Function] + # [/DEF:backend.src.services.git_service.GitService.list_branches:Function] - # [DEF:create_branch:Function] + # [DEF:backend.src.services.git_service.GitService.create_branch:Function] # @PURPOSE: Create a new branch from an existing one. # @PARAM: name (str) - New branch name. # @PARAM: from_branch (str) - Source branch. # @PRE: Repository exists; name is valid; from_branch exists or repo is empty. # @POST: A new branch is created in the repository. + # @RELATION: CALLS -> [GitService.get_repo] 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) @@ -531,25 +549,27 @@ class GitService: except Exception as e: logger.error(f"[create_branch][Coherence:Failed] {e}") raise - # [/DEF:create_branch:Function] + # [/DEF:backend.src.services.git_service.GitService.create_branch:Function] - # [DEF:checkout_branch:Function] + # [DEF:backend.src.services.git_service.GitService.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. + # @RELATION: CALLS -> [GitService.get_repo] def checkout_branch(self, dashboard_id: int, name: str): with belief_scope("GitService.checkout_branch"): repo = self.get_repo(dashboard_id) logger.info(f"[checkout_branch][Action] Checking out branch {name}") repo.git.checkout(name) - # [/DEF:checkout_branch:Function] + # [/DEF:backend.src.services.git_service.GitService.checkout_branch:Function] - # [DEF:commit_changes:Function] + # [DEF:backend.src.services.git_service.GitService.commit_changes:Function] # @PURPOSE: Stage and commit changes. # @PARAM: message (str) - Commit message. # @PARAM: files (List[str]) - Optional list of specific files to stage. # @PRE: Repository exists and has changes (dirty) or files are specified. # @POST: Changes are staged and a new commit is created. + # @RELATION: CALLS -> [GitService.get_repo] def commit_changes(self, dashboard_id: int, message: str, files: List[str] = None): with belief_scope("GitService.commit_changes"): repo = self.get_repo(dashboard_id) @@ -568,13 +588,14 @@ class GitService: repo.index.commit(message) logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}") - # [/DEF:commit_changes:Function] + # [/DEF:backend.src.services.git_service.GitService.commit_changes:Function] - # [DEF:_extract_http_host:Function] + # [DEF:backend.src.services.git_service.GitService._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] + # @RELATION: USES -> [urlparse] def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]: normalized = str(url_value or "").strip() if not normalized: @@ -591,13 +612,14 @@ class GitService: if parsed.port: return f"{host.lower()}:{parsed.port}" return host.lower() - # [/DEF:_extract_http_host:Function] + # [/DEF:backend.src.services.git_service.GitService._extract_http_host:Function] - # [DEF:_strip_url_credentials:Function] + # [DEF:backend.src.services.git_service.GitService._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 + # @RELATION: USES -> [urlparse] def _strip_url_credentials(self, url_value: str) -> str: normalized = str(url_value or "").strip() if not normalized: @@ -612,13 +634,14 @@ class GitService: if parsed.port: host = f"{host}:{parsed.port}" return parsed._replace(netloc=host).geturl() - # [/DEF:_strip_url_credentials:Function] + # [/DEF:backend.src.services.git_service.GitService._strip_url_credentials:Function] - # [DEF:_replace_host_in_url:Function] + # [DEF:backend.src.services.git_service.GitService._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] + # @RELATION: USES -> [urlparse] 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() @@ -650,13 +673,16 @@ class GitService: new_netloc = f"{auth_part}{target_host}" return source_parsed._replace(netloc=new_netloc).geturl() - # [/DEF:_replace_host_in_url:Function] + # [/DEF:backend.src.services.git_service.GitService._replace_host_in_url:Function] - # [DEF:_align_origin_host_with_config:Function] + # [DEF:backend.src.services.git_service.GitService._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] + # @RELATION: CALLS -> [GitService._extract_http_host] + # @RELATION: CALLS -> [GitService._replace_host_in_url] + # @RELATION: CALLS -> [GitService._strip_url_credentials] def _align_origin_host_with_config( self, dashboard_id: int, @@ -716,12 +742,14 @@ class GitService: ) return aligned_url - # [/DEF:_align_origin_host_with_config:Function] + # [/DEF:backend.src.services.git_service.GitService._align_origin_host_with_config:Function] - # [DEF:push_changes:Function] + # [DEF:backend.src.services.git_service.GitService.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. + # @RELATION: CALLS -> [GitService.get_repo] + # @RELATION: CALLS -> [GitService._align_origin_host_with_config] def push_changes(self, dashboard_id: int): with belief_scope("GitService.push_changes"): repo = self.get_repo(dashboard_id) @@ -829,12 +857,11 @@ class GitService: except Exception as e: logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}") raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}") - # [/DEF:push_changes:Function] + # [/DEF:backend.src.services.git_service.GitService.push_changes:Function] - # [DEF:pull_changes:Function] - # @PURPOSE: Pull changes from remote. - # @PRE: Repository exists and has an 'origin' remote. - # @POST: Changes from origin are pulled and merged into the active branch. + # [DEF:backend.src.services.git_service.GitService._read_blob_text:Function] + # @PURPOSE: Read text from a Git blob. + # @RELATION: USES -> [Blob] def _read_blob_text(self, blob: Blob) -> str: with belief_scope("GitService._read_blob_text"): if blob is None: @@ -843,14 +870,22 @@ class GitService: return blob.data_stream.read().decode("utf-8", errors="replace") except Exception: return "" + # [/DEF:backend.src.services.git_service.GitService._read_blob_text:Function] + # [DEF:backend.src.services.git_service.GitService._get_unmerged_file_paths:Function] + # @PURPOSE: List files with merge conflicts. + # @RELATION: USES -> [Repo] def _get_unmerged_file_paths(self, repo: Repo) -> List[str]: with belief_scope("GitService._get_unmerged_file_paths"): try: return sorted(list(repo.index.unmerged_blobs().keys())) except Exception: return [] + # [/DEF:backend.src.services.git_service.GitService._get_unmerged_file_paths:Function] + # [DEF:backend.src.services.git_service.GitService._build_unfinished_merge_payload:Function] + # @PURPOSE: Build payload for unfinished merge state. + # @RELATION: CALLS -> [GitService._get_unmerged_file_paths] 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") @@ -900,7 +935,12 @@ class GitService: "git merge --abort", ], } + # [/DEF:backend.src.services.git_service.GitService._build_unfinished_merge_payload:Function] + # [DEF:backend.src.services.git_service.GitService.get_merge_status:Function] + # @PURPOSE: Get current merge status for a dashboard repository. + # @RELATION: CALLS -> [GitService.get_repo] + # @RELATION: CALLS -> [GitService._build_unfinished_merge_payload] def get_merge_status(self, dashboard_id: int) -> Dict[str, Any]: with belief_scope("GitService.get_merge_status"): repo = self.get_repo(dashboard_id) @@ -930,7 +970,12 @@ class GitService: "merge_message_preview": payload["merge_message_preview"], "conflicts_count": int(payload.get("conflicts_count") or 0), } + # [/DEF:backend.src.services.git_service.GitService.get_merge_status:Function] + # [DEF:backend.src.services.git_service.GitService.get_merge_conflicts:Function] + # @PURPOSE: List all files with conflicts and their contents. + # @RELATION: CALLS -> [GitService.get_repo] + # @RELATION: CALLS -> [GitService._read_blob_text] 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) @@ -952,7 +997,11 @@ class GitService: } ) return sorted(conflicts, key=lambda item: item["file_path"]) + # [/DEF:backend.src.services.git_service.GitService.get_merge_conflicts:Function] + # [DEF:backend.src.services.git_service.GitService.resolve_merge_conflicts:Function] + # @PURPOSE: Resolve conflicts using specified strategy. + # @RELATION: CALLS -> [GitService.get_repo] 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) @@ -986,7 +1035,11 @@ class GitService: resolved_files.append(file_path) return resolved_files + # [/DEF:backend.src.services.git_service.GitService.resolve_merge_conflicts:Function] + # [DEF:backend.src.services.git_service.GitService.abort_merge:Function] + # @PURPOSE: Abort ongoing merge. + # @RELATION: CALLS -> [GitService.get_repo] def abort_merge(self, dashboard_id: int) -> Dict[str, Any]: with belief_scope("GitService.abort_merge"): repo = self.get_repo(dashboard_id) @@ -999,7 +1052,12 @@ class GitService: return {"status": "no_merge_in_progress"} raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}") return {"status": "aborted"} + # [/DEF:backend.src.services.git_service.GitService.abort_merge:Function] + # [DEF:backend.src.services.git_service.GitService.continue_merge:Function] + # @PURPOSE: Finalize merge after conflict resolution. + # @RELATION: CALLS -> [GitService.get_repo] + # @RELATION: CALLS -> [GitService._get_unmerged_file_paths] 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) @@ -1032,7 +1090,14 @@ class GitService: except Exception: commit_hash = "" return {"status": "committed", "commit_hash": commit_hash} + # [/DEF:backend.src.services.git_service.GitService.continue_merge:Function] + # [DEF:backend.src.services.git_service.GitService.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. + # @RELATION: CALLS -> [GitService.get_repo] + # @RELATION: CALLS -> [GitService._build_unfinished_merge_payload] def pull_changes(self, dashboard_id: int): with belief_scope("GitService.pull_changes"): repo = self.get_repo(dashboard_id) @@ -1110,13 +1175,14 @@ class GitService: except Exception as e: logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}") raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}") - # [/DEF:pull_changes:Function] + # [/DEF:backend.src.services.git_service.GitService.pull_changes:Function] # [DEF:backend.src.services.git_service.GitService.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 + # @RELATION: CALLS -> [GitService.get_repo] def get_status(self, dashboard_id: int) -> dict: with belief_scope("GitService.get_status"): repo = self.get_repo(dashboard_id) @@ -1186,15 +1252,16 @@ class GitService: "is_diverged": is_diverged, "sync_state": sync_state, } - # [/DEF:get_status:Function] + # [/DEF:backend.src.services.git_service.GitService.get_status:Function] - # [DEF:get_diff:Function] + # [DEF:backend.src.services.git_service.GitService.get_diff:Function] # @PURPOSE: Generate diff for a file or the whole repository. # @PARAM: file_path (str) - Optional specific file. # @PARAM: staged (bool) - Whether to show staged changes. # @PRE: Repository for dashboard_id exists. # @POST: Returns the diff text as a string. # @RETURN: str + # @RELATION: CALLS -> [GitService.get_repo] def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str: with belief_scope("GitService.get_diff"): repo = self.get_repo(dashboard_id) @@ -1205,14 +1272,15 @@ class GitService: if file_path: return repo.git.diff(*diff_args, "--", file_path) return repo.git.diff(*diff_args) - # [/DEF:get_diff:Function] + # [/DEF:backend.src.services.git_service.GitService.get_diff:Function] - # [DEF:get_commit_history:Function] + # [DEF:backend.src.services.git_service.GitService.get_commit_history:Function] # @PURPOSE: Retrieve commit history for a repository. # @PARAM: limit (int) - Max number of commits to return. # @PRE: Repository for dashboard_id exists. # @POST: Returns a list of dictionaries for each commit in history. # @RETURN: List[dict] + # @RELATION: CALLS -> [GitService.get_repo] def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]: with belief_scope("GitService.get_commit_history"): repo = self.get_repo(dashboard_id) @@ -1235,9 +1303,9 @@ class GitService: logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}") return [] return commits - # [/DEF:get_commit_history:Function] + # [/DEF:backend.src.services.git_service.GitService.get_commit_history:Function] - # [DEF:test_connection:Function] + # [DEF:backend.src.services.git_service.GitService.test_connection:Function] # @PURPOSE: Test connection to Git provider using PAT. # @PARAM: provider (GitProvider) # @PARAM: url (str) @@ -1245,6 +1313,7 @@ class GitService: # @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided. # @POST: Returns True if connection to the provider's API succeeds. # @RETURN: bool + # @RELATION: USES -> [httpx.AsyncClient] async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool: with belief_scope("GitService.test_connection"): # Check for offline mode or local-only URLs @@ -1285,9 +1354,9 @@ class GitService: except Exception as e: logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}") return False - # [/DEF:test_connection:Function] + # [/DEF:backend.src.services.git_service.GitService.test_connection:Function] - # [DEF:_normalize_git_server_url:Function] + # [DEF:backend.src.services.git_service.GitService._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. @@ -1297,9 +1366,9 @@ class GitService: if not normalized: raise HTTPException(status_code=400, detail="Git server URL is required") return normalized.rstrip("/") - # [/DEF:_normalize_git_server_url:Function] + # [/DEF:backend.src.services.git_service.GitService._normalize_git_server_url:Function] - # [DEF:_gitea_headers:Function] + # [DEF:backend.src.services.git_service.GitService._gitea_headers:Function] # @PURPOSE: Build Gitea API authorization headers. # @PRE: pat is provided. # @POST: Returns headers with token auth. @@ -1313,13 +1382,15 @@ class GitService: "Content-Type": "application/json", "Accept": "application/json", } - # [/DEF:_gitea_headers:Function] + # [/DEF:backend.src.services.git_service.GitService._gitea_headers:Function] - # [DEF:_gitea_request:Function] + # [DEF:backend.src.services.git_service.GitService._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 + # @RELATION: CALLS -> [GitService._normalize_git_server_url] + # @RELATION: CALLS -> [GitService._gitea_headers] async def _gitea_request( self, method: str, @@ -1361,26 +1432,28 @@ class GitService: if response.status_code == 204: return None return response.json() - # [/DEF:_gitea_request:Function] + # [/DEF:backend.src.services.git_service.GitService._gitea_request:Function] - # [DEF:get_gitea_current_user:Function] + # [DEF:backend.src.services.git_service.GitService.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 + # @RELATION: CALLS -> [GitService._gitea_request] async def get_gitea_current_user(self, server_url: str, pat: str) -> str: payload = await self._gitea_request("GET", server_url, pat, "/user") username = payload.get("login") or payload.get("username") if not username: raise HTTPException(status_code=500, detail="Failed to resolve Gitea username") return str(username) - # [/DEF:get_gitea_current_user:Function] + # [/DEF:backend.src.services.git_service.GitService.get_gitea_current_user:Function] - # [DEF:list_gitea_repositories:Function] + # [DEF:backend.src.services.git_service.GitService.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] + # @RELATION: CALLS -> [GitService._gitea_request] async def list_gitea_repositories(self, server_url: str, pat: str) -> List[dict]: payload = await self._gitea_request( "GET", @@ -1391,13 +1464,14 @@ class GitService: if not isinstance(payload, list): return [] return payload - # [/DEF:list_gitea_repositories:Function] + # [/DEF:backend.src.services.git_service.GitService.list_gitea_repositories:Function] - # [DEF:create_gitea_repository:Function] + # [DEF:backend.src.services.git_service.GitService.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 + # @RELATION: CALLS -> [GitService._gitea_request] async def create_gitea_repository( self, server_url: str, @@ -1427,12 +1501,13 @@ class GitService: if not isinstance(created, dict): raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository") return created - # [/DEF:create_gitea_repository:Function] + # [/DEF:backend.src.services.git_service.GitService.create_gitea_repository:Function] - # [DEF:delete_gitea_repository:Function] + # [DEF:backend.src.services.git_service.GitService.delete_gitea_repository:Function] # @PURPOSE: Delete repository in Gitea. # @PRE: owner and repo_name are non-empty. # @POST: Repository deleted on Gitea server. + # @RELATION: CALLS -> [GitService._gitea_request] async def delete_gitea_repository( self, server_url: str, @@ -1448,13 +1523,14 @@ class GitService: pat, f"/repos/{owner}/{repo_name}", ) - # [/DEF:delete_gitea_repository:Function] + # [/DEF:backend.src.services.git_service.GitService.delete_gitea_repository:Function] - # [DEF:_gitea_branch_exists:Function] + # [DEF:backend.src.services.git_service.GitService._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 + # @RELATION: CALLS -> [GitService._gitea_request] async def _gitea_branch_exists( self, server_url: str, @@ -1473,13 +1549,14 @@ class GitService: if exc.status_code == 404: return False raise - # [/DEF:_gitea_branch_exists:Function] + # [/DEF:backend.src.services.git_service.GitService._gitea_branch_exists:Function] - # [DEF:_build_gitea_pr_404_detail:Function] + # [DEF:backend.src.services.git_service.GitService._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] + # @RELATION: CALLS -> [GitService._gitea_branch_exists] async def _build_gitea_pr_404_detail( self, server_url: str, @@ -1508,13 +1585,14 @@ class GitService: if not target_exists: return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}" return None - # [/DEF:_build_gitea_pr_404_detail:Function] + # [/DEF:backend.src.services.git_service.GitService._build_gitea_pr_404_detail:Function] - # [DEF:create_github_repository:Function] + # [DEF:backend.src.services.git_service.GitService.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 + # @RELATION: CALLS -> [GitService._normalize_git_server_url] async def create_github_repository( self, server_url: str, @@ -1560,13 +1638,14 @@ class GitService: pass raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}") return response.json() - # [/DEF:create_github_repository:Function] + # [/DEF:backend.src.services.git_service.GitService.create_github_repository:Function] - # [DEF:create_gitlab_repository:Function] + # [DEF:backend.src.services.git_service.GitService.create_gitlab_repository:Function] # @PURPOSE: Create repository(project) in GitLab. # @PRE: PAT has api scope. # @POST: Returns created repository payload. # @RETURN: dict + # @RELATION: CALLS -> [GitService._normalize_git_server_url] async def create_gitlab_repository( self, server_url: str, @@ -1620,13 +1699,14 @@ class GitService: if "full_name" not in data: data["full_name"] = data.get("path_with_namespace") or data.get("name") return data - # [/DEF:create_gitlab_repository:Function] + # [/DEF:backend.src.services.git_service.GitService.create_gitlab_repository:Function] - # [DEF:_parse_remote_repo_identity:Function] + # [DEF:backend.src.services.git_service.GitService._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] + # @RELATION: USES -> [urlparse] def _parse_remote_repo_identity(self, remote_url: str) -> Dict[str, str]: normalized = str(remote_url or "").strip() if not normalized: @@ -1655,13 +1735,14 @@ class GitService: "namespace": namespace, "full_name": f"{namespace}/{repo}", } - # [/DEF:_parse_remote_repo_identity:Function] + # [/DEF:backend.src.services.git_service.GitService._parse_remote_repo_identity:Function] # [DEF:backend.src.services.git_service.GitService._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] + # @RELATION: USES -> [urlparse] 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@"): @@ -1677,13 +1758,14 @@ class GitService: if parsed.port: netloc = f"{netloc}:{parsed.port}" return f"{parsed.scheme}://{netloc}".rstrip("/") - # [/DEF:_derive_server_url_from_remote:Function] + # [/DEF:backend.src.services.git_service.GitService._derive_server_url_from_remote:Function] - # [DEF:promote_direct_merge:Function] + # [DEF:backend.src.services.git_service.GitService.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] + # @RELATION: CALLS -> [GitService.get_repo] def promote_direct_merge( self, dashboard_id: int, @@ -1742,13 +1824,18 @@ class GitService: "to_branch": target, "status": "merged", } - # [/DEF:promote_direct_merge:Function] + # [/DEF:backend.src.services.git_service.GitService.promote_direct_merge:Function] # [DEF:backend.src.services.git_service.GitService.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] + # @RELATION: CALLS -> [GitService._parse_remote_repo_identity] + # @RELATION: CALLS -> [GitService._gitea_request] + # @RELATION: CALLS -> [GitService._derive_server_url_from_remote] + # @RELATION: CALLS -> [GitService._normalize_git_server_url] + # @RELATION: CALLS -> [GitService._build_gitea_pr_404_detail] async def create_gitea_pull_request( self, server_url: str, @@ -1830,13 +1917,15 @@ class GitService: "url": data.get("html_url") or data.get("url"), "status": data.get("state") or "open", } - # [/DEF:create_gitea_pull_request:Function] + # [/DEF:backend.src.services.git_service.GitService.create_gitea_pull_request:Function] # [DEF:backend.src.services.git_service.GitService.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] + # @RELATION: CALLS -> [GitService._parse_remote_repo_identity] + # @RELATION: CALLS -> [GitService._normalize_git_server_url] async def create_github_pull_request( self, server_url: str, @@ -1884,13 +1973,15 @@ class GitService: "url": data.get("html_url") or data.get("url"), "status": data.get("state") or "open", } - # [/DEF:create_github_pull_request:Function] + # [/DEF:backend.src.services.git_service.GitService.create_github_pull_request:Function] # [DEF:backend.src.services.git_service.GitService.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] + # @RELATION: CALLS -> [GitService._parse_remote_repo_identity] + # @RELATION: CALLS -> [GitService._normalize_git_server_url] async def create_gitlab_merge_request( self, server_url: str, @@ -1938,7 +2029,7 @@ class GitService: "url": data.get("web_url") or data.get("url"), "status": data.get("state") or "opened", } - # [/DEF:create_gitlab_merge_request:Function] + # [/DEF:backend.src.services.git_service.GitService.create_gitlab_merge_request:Function] -# [/DEF:GitService:Class] +# [/DEF:backend.src.services.git_service.GitService:Class] # [/DEF:backend.src.services.git_service:Module]