chore: update semantic contracts and git merge handling

This commit is contained in:
2026-03-16 20:34:28 +03:00
parent c53c3f77cc
commit 7e4124bc3f
19 changed files with 480 additions and 257 deletions

View File

@@ -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"]}}} {"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"]}}}

View File

@@ -1,8 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# [DEF:backend.delete_running_tasks:Module] # [DEF:DeleteRunningTasksUtil:Module]
# @PURPOSE: Script to delete tasks with RUNNING status from the database. # @PURPOSE: Script to delete tasks with RUNNING status from the database.
# @LAYER: Utility # @LAYER: Utility
# @SEMANTICS: maintenance, database, cleanup # @SEMANTICS: maintenance, database, cleanup
# @RELATION: DEPENDS_ON ->[TasksSessionLocal]
# @RELATION: DEPENDS_ON ->[TaskRecord]
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.database import TasksSessionLocal from src.core.database import TasksSessionLocal
@@ -41,4 +43,4 @@ def delete_running_tasks():
if __name__ == "__main__": if __name__ == "__main__":
delete_running_tasks() delete_running_tasks()
# [/DEF:backend.delete_running_tasks:Module] # [/DEF:DeleteRunningTasksUtil:Module]

View File

@@ -1,3 +1,3 @@
# [DEF:src:Package] # [DEF:SrcRoot:Module]
# @PURPOSE: Canonical backend package root for application, scripts, and tests. # @PURPOSE: Canonical backend package root for application, scripts, and tests.
# [/DEF:src:Package] # [/DEF:SrcRoot:Module]

View File

@@ -1,12 +1,12 @@
# [DEF:backend.src.api.auth:Module] # [DEF:AuthApi:Module]
# #
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @SEMANTICS: api, auth, routes, login, logout # @SEMANTICS: api, auth, routes, login, logout
# @PURPOSE: Authentication API endpoints. # @PURPOSE: Authentication API endpoints.
# @LAYER: API # @LAYER: API
# @RELATION: USES ->[backend.src.services.auth_service.AuthService] # @RELATION: USES ->[AuthService:Class]
# @RELATION: USES ->[backend.src.core.database.get_auth_db] # @RELATION: USES ->[get_auth_db:Function]
# # @RELATION: DEPENDS_ON ->[AuthRepository:Class]
# @INVARIANT: All auth endpoints must return consistent error codes. # @INVARIANT: All auth endpoints must return consistent error codes.
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
@@ -38,6 +38,8 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials. # @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
# @PARAM: db (Session) - Auth database session. # @PARAM: db (Session) - Auth database session.
# @RETURN: Token - The generated JWT token. # @RETURN: Token - The generated JWT token.
# @RELATION: CALLS -> [AuthService.authenticate_user]
# @RELATION: CALLS -> [AuthService.create_session]
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login_for_access_token( async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(), form_data: OAuth2PasswordRequestForm = Depends(),
@@ -64,6 +66,7 @@ async def login_for_access_token(
# @POST: Returns the current user's data. # @POST: Returns the current user's data.
# @PARAM: current_user (UserSchema) - The user extracted from the token. # @PARAM: current_user (UserSchema) - The user extracted from the token.
# @RETURN: UserSchema - The current user profile. # @RETURN: UserSchema - The current user profile.
# @RELATION: DEPENDS_ON -> [get_current_user]
@router.get("/me", response_model=UserSchema) @router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)): async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.me"): 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). # @PURPOSE: Logs out the current user (placeholder for session revocation).
# @PRE: Valid JWT token provided. # @PRE: Valid JWT token provided.
# @POST: Returns success message. # @POST: Returns success message.
# @PARAM: current_user (UserSchema) - The user extracted from the token.
# @RELATION: DEPENDS_ON -> [get_current_user]
@router.post("/logout") @router.post("/logout")
async def logout(current_user: UserSchema = Depends(get_current_user)): async def logout(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.logout"): with belief_scope("api.auth.logout"):
@@ -88,6 +93,7 @@ async def logout(current_user: UserSchema = Depends(get_current_user)):
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Initiates the ADFS OIDC login flow. # @PURPOSE: Initiates the ADFS OIDC login flow.
# @POST: Redirects the user to ADFS. # @POST: Redirects the user to ADFS.
# @RELATION: USES -> [is_adfs_configured]
@router.get("/login/adfs") @router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request): async def login_adfs(request: starlette.requests.Request):
with belief_scope("api.auth.login_adfs"): with belief_scope("api.auth.login_adfs"):
@@ -104,6 +110,8 @@ async def login_adfs(request: starlette.requests.Request):
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Handles the callback from ADFS after successful authentication. # @PURPOSE: Handles the callback from ADFS after successful authentication.
# @POST: Provisions user JIT and returns session token. # @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") @router.get("/callback/adfs", name="auth_callback_adfs")
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)): async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
with belief_scope("api.auth.callback_adfs"): 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) return auth_service.create_session(user)
# [/DEF:auth_callback_adfs:Function] # [/DEF:auth_callback_adfs:Function]
# [/DEF:backend.src.api.auth:Module] # [/DEF:AuthApi:Module]

View File

@@ -3,7 +3,7 @@
# @SEMANTICS: tests, git, api, status, no_repo # @SEMANTICS: tests, git, api, status, no_repo
# @PURPOSE: Validate status endpoint behavior for missing and error repository states. # @PURPOSE: Validate status endpoint behavior for missing and error repository states.
# @LAYER: Domain (Tests) # @LAYER: Domain (Tests)
# @RELATION: CALLS -> src.api.routes.git.get_repository_status # @RELATION: VERIFIES -> [backend.src.api.routes.git]
from fastapi import HTTPException from fastapi import HTTPException
import pytest import pytest

View File

@@ -1,4 +1,4 @@
# [DEF:backend.src.api.routes.admin:Module] # [DEF:AdminApi:Module]
# #
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @SEMANTICS: api, admin, users, roles, permissions # @SEMANTICS: api, admin, users, roles, permissions
@@ -93,6 +93,12 @@ async def create_user(
# [DEF:update_user:Function] # [DEF:update_user:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Updates an existing user. # @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) @router.put("/users/{user_id}", response_model=UserSchema)
async def update_user( async def update_user(
user_id: str, user_id: str,
@@ -128,6 +134,11 @@ async def update_user(
# [DEF:delete_user:Function] # [DEF:delete_user:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Deletes a user. # @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) @router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user( async def delete_user(
user_id: str, user_id: str,
@@ -331,4 +342,4 @@ async def create_ad_mapping(
return new_mapping return new_mapping
# [/DEF:create_ad_mapping:Function] # [/DEF:create_ad_mapping:Function]
# [/DEF:backend.src.api.routes.admin:Module] # [/DEF:AdminApi:Module]

View File

@@ -1,8 +1,6 @@
# [DEF:backend.src.api.routes.clean_release_v2:Module] # [DEF:backend.src.api.routes.clean_release_v2:Module]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @SEMANTICS: api, clean-release, v2, headless
# @PURPOSE: Redesigned clean release API for headless candidate lifecycle. # @PURPOSE: Redesigned clean release API for headless candidate lifecycle.
# @LAYER: API
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Dict, Any 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"]) 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): class ApprovalRequest(dict):
pass pass
# [/DEF:ApprovalRequest:Class]
# [DEF:PublishRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Schema for publication request payload.
# @RELATION: USES -> [CandidateDTO]
class PublishRequest(dict): class PublishRequest(dict):
pass pass
# [/DEF:PublishRequest:Class]
# [DEF:RevokeRequest:Class]
# @COMPLEXITY: 1
# @PURPOSE: Schema for revocation request payload.
# @RELATION: USES -> [CandidateDTO]
class RevokeRequest(dict): class RevokeRequest(dict):
pass 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) @router.post("/candidates", response_model=CandidateDTO, status_code=status.HTTP_201_CREATED)
async def register_candidate( async def register_candidate(
payload: Dict[str, Any], payload: Dict[str, Any],
@@ -51,7 +72,14 @@ async def register_candidate(
created_by=candidate.created_by, created_by=candidate.created_by,
status=CandidateStatus(candidate.status) 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") @router.post("/candidates/{candidate_id}/artifacts")
async def import_artifacts( async def import_artifacts(
candidate_id: str, candidate_id: str,
@@ -75,7 +103,16 @@ async def import_artifacts(
pass pass
return {"status": "success"} 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) @router.post("/candidates/{candidate_id}/manifests", response_model=ManifestDTO, status_code=status.HTTP_201_CREATED)
async def build_manifest( async def build_manifest(
candidate_id: str, candidate_id: str,
@@ -109,7 +146,12 @@ async def build_manifest(
source_snapshot_ref=manifest.source_snapshot_ref, source_snapshot_ref=manifest.source_snapshot_ref,
content_json=manifest.content_json 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") @router.post("/candidates/{candidate_id}/approve")
async def approve_candidate_endpoint( async def approve_candidate_endpoint(
candidate_id: str, 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"}) raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"})
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id} 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") @router.post("/candidates/{candidate_id}/reject")
async def reject_candidate_endpoint( async def reject_candidate_endpoint(
candidate_id: str, 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"}) raise HTTPException(status_code=409, detail={"message": str(exc), "code": "APPROVAL_GATE_ERROR"})
return {"status": "ok", "decision": decision.decision, "decision_id": decision.id} 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") @router.post("/candidates/{candidate_id}/publish")
async def publish_candidate_endpoint( async def publish_candidate_endpoint(
candidate_id: str, candidate_id: str,
@@ -181,8 +233,13 @@ async def publish_candidate_endpoint(
"status": publication.status, "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") @router.post("/publications/{publication_id}/revoke")
async def revoke_publication_endpoint( async def revoke_publication_endpoint(
publication_id: str, publication_id: str,
@@ -212,5 +269,6 @@ async def revoke_publication_endpoint(
"status": publication.status, "status": publication.status,
}, },
} }
# [/DEF:revoke_publication_endpoint:Function]
# [/DEF:backend.src.api.routes.clean_release_v2:Module] # [/DEF:backend.src.api.routes.clean_release_v2:Module]

View File

@@ -4,7 +4,7 @@
# @SEMANTICS: api, dashboards, resources, hub # @SEMANTICS: api, dashboards, resources, hub
# @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status # @PURPOSE: API endpoints for the Dashboard Hub - listing dashboards with Git and task status
# @LAYER: API # @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.services.resource_service.ResourceService]
# @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient] # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient]
# #

View File

@@ -4,14 +4,14 @@
# @SEMANTICS: api, datasets, resources, hub # @SEMANTICS: api, datasets, resources, hub
# @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress # @PURPOSE: API endpoints for the Dataset Hub - listing datasets with mapping progress
# @LAYER: API # @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.services.resource_service.ResourceService]
# @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient] # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient]
# #
# @INVARIANT: All dataset responses include last_task metadata # @INVARIANT: All dataset responses include last_task metadata
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission from ...dependencies import get_config_manager, get_task_manager, get_resource_service, has_permission

View File

@@ -1,9 +1,9 @@
# [DEF:backend.src.api.routes.migration:Module] # [DEF:MigrationApi:Module]
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @SEMANTICS: api, migration, dashboards, sync, dry-run # @SEMANTICS: api, migration, dashboards, sync, dry-run
# @PURPOSE: HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints. # @PURPOSE: HTTP contract layer for migration orchestration, settings, dry-run, and mapping sync endpoints.
# @LAYER: Infra # @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.database]
# @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient] # @RELATION: DEPENDS_ON ->[backend.src.core.superset_client.SupersetClient]
# @RELATION: DEPENDS_ON ->[backend.src.core.migration.dry_run_orchestrator.MigrationDryRunService] # @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:trigger_sync_now:Function]
# [/DEF:backend.src.api.routes.migration:Module] # [/DEF:MigrationApi:Module]

View File

@@ -4,7 +4,7 @@
# @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints. # @PURPOSE: FastAPI router for unified task report list and detail retrieval endpoints.
# @LAYER: UI (API) # @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> [backend.src.services.reports.report_service.ReportsService] # @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. # @INVARIANT: Endpoints are read-only and do not trigger long-running tasks.
# @PRE: Reports service and dependencies are initialized. # @PRE: Reports service and dependencies are initialized.
# @POST: Router is configured and endpoints are ready for registration. # @POST: Router is configured and endpoints are ready for registration.

View File

@@ -3,7 +3,7 @@
# @SEMANTICS: app, main, entrypoint, fastapi # @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. # @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) # @LAYER: UI (API)
# @RELATION: DEPENDS_ON ->[backend.src.dependencies] # @RELATION: DEPENDS_ON ->[AppDependencies]
# @RELATION: DEPENDS_ON ->[backend.src.api.routes] # @RELATION: DEPENDS_ON ->[backend.src.api.routes]
# @INVARIANT: Only one FastAPI app instance exists per process. # @INVARIANT: Only one FastAPI app instance exists per process.
# @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect. # @INVARIANT: All WebSocket connections must be properly cleaned up on disconnect.
@@ -69,6 +69,8 @@ async def shutdown_event():
scheduler.stop() scheduler.stop()
# [/DEF:shutdown_event:Function] # [/DEF:shutdown_event:Function]
# [DEF:app_middleware:Block]
# @PURPOSE: Configure application-wide middleware (Session, CORS).
# Configure Session Middleware (required by Authlib for OAuth2 flow) # Configure Session Middleware (required by Authlib for OAuth2 flow)
from .core.auth.config import auth_config from .core.auth.config import auth_config
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY) app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
@@ -81,6 +83,7 @@ app.add_middleware(
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# [/DEF:app_middleware:Block]
# [DEF:network_error_handler:Function] # [DEF:network_error_handler:Function]
@@ -129,6 +132,8 @@ async def log_requests(request: Request, call_next):
) )
# [/DEF:log_requests:Function] # [/DEF:log_requests:Function]
# [DEF:api_routes:Block]
# @PURPOSE: Register all application API routers.
# Include API routes # Include API routes
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(admin.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(clean_release_v2.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(health.router) app.include_router(health.router)
# [/DEF:api_routes:Block]
# [DEF:api.include_routers:Action] # [DEF:api.include_routers:Action]

View File

@@ -1,59 +1,118 @@
# [DEF:AuthRepository:Module] # [DEF:AuthRepository:Module]
#
# @TIER: CRITICAL # @TIER: CRITICAL
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @SEMANTICS: auth, repository, database, user, role, permission # @SEMANTICS: auth, repository, database, user, role, permission
# @PURPOSE: Data access layer for authentication and user preference entities. # @PURPOSE: Data access layer for authentication and user preference entities.
# @LAYER: Domain # @LAYER: Domain
# @PRE: SQLAlchemy session manager and auth models are available. # @RELATION: DEPENDS_ON ->[sqlalchemy.orm.Session]
# @POST: Provides transactional access to Auth-related database entities. # @RELATION: DEPENDS_ON ->[User:Class]
# @SIDE_EFFECT: Performs database I/O via SQLAlchemy sessions. # @RELATION: DEPENDS_ON ->[Role:Class]
# @DATA_CONTRACT: Input[Session] -> Model[User, Role, Permission, UserDashboardPreference] # @RELATION: DEPENDS_ON ->[Permission:Class]
# @RELATION: [DEPENDS_ON] ->[sqlalchemy.orm.Session] # @RELATION: DEPENDS_ON ->[UserDashboardPreference:Class]
# @RELATION: [DEPENDS_ON] ->[User:Class] # @RELATION: DEPENDS_ON ->[belief_scope:Function]
# @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. # @INVARIANT: All database read/write operations must execute via the injected SQLAlchemy session boundary.
# # @DATA_CONTRACT: Session -> [User | Role | Permission | UserDashboardPreference]
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
from typing import List, Optional from typing import List, Optional
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from ...models.auth import Permission, Role, User, ADGroupMapping
from ...models.auth import Permission, Role, User
from ...models.profile import UserDashboardPreference from ...models.profile import UserDashboardPreference
from ..logger import belief_scope, logger from ..logger import belief_scope, logger
# [/SECTION] # [/SECTION]
# [DEF:AuthRepository:Module] # [DEF:AuthRepository:Class]
# # @PURPOSE: Provides low-level CRUD operations for identity and authorization records.
# @TIER: CRITICAL class AuthRepository:
# @COMPLEXITY: 5 # @PURPOSE: Initialize repository with database session.
# @SEMANTICS: auth, repository, database, user, role, permission def __init__(self, db: Session):
# @PURPOSE: Data access layer for authentication and user preference entities. self.db = db
# @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
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 # [DEF:get_user_by_username:Function]
from ...models.profile import UserDashboardPreference # @PURPOSE: Retrieve user by username.
from ..logger import belief_scope, logger # @PRE: username is a non-empty string.
# [/SECTION] # @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]
# [/DEF:AuthRepository:Module]

View File

@@ -1,6 +1,5 @@
# [DEF:ConfigManager:Module] # [DEF:ConfigManager:Module]
# #
# @TIER: CRITICAL
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @SEMANTICS: config, manager, persistence, migration, postgresql # @SEMANTICS: config, manager, persistence, migration, postgresql
# @PURPOSE: Manages application configuration persistence in DB with one-time migration from legacy JSON. # @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. # @POST: Configuration is loaded into memory and logger is configured.
# @SIDE_EFFECT: Performs DB I/O and may update global logging level. # @SIDE_EFFECT: Performs DB I/O and may update global logging level.
# @DATA_CONTRACT: Input[json, record] -> Model[AppConfig] # @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] ->[AppConfig]
# @RELATION: [DEPENDS_ON] ->[SessionLocal] # @RELATION: [DEPENDS_ON] ->[SessionLocal]
# @RELATION: [DEPENDS_ON] ->[AppConfigRecord] # @RELATION: [DEPENDS_ON] ->[AppConfigRecord]
# @RELATION: [DEPENDS_ON] ->[FileIO] # @RELATION: [DEPENDS_ON] ->[FileIO]
# @RELATION: [CALLS] ->[logger] # @RELATION: [CALLS] ->[logger]
# @RELATION: [CALLS] ->[configure_logger] # @RELATION: [CALLS] ->[configure_logger]
# @INVARIANT: Configuration must always be representable by AppConfig and persisted under global record id.
# #
import json import json
import os import os
@@ -31,7 +30,6 @@ from .logger import logger, configure_logger, belief_scope
# [DEF:ConfigManager:Class] # [DEF:ConfigManager:Class]
# @TIER: CRITICAL
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @PURPOSE: Handles application configuration load, validation, mutation, and persistence lifecycle. # @PURPOSE: Handles application configuration load, validation, mutation, and persistence lifecycle.
# @PRE: Database is accessible and AppConfigRecord schema is loaded. # @PRE: Database is accessible and AppConfigRecord schema is loaded.

View File

@@ -57,7 +57,7 @@ class SupersetClient:
) )
self.delete_before_reimport: bool = False self.delete_before_reimport: bool = False
app_logger.info("[SupersetClient.__init__][Exit] SupersetClient initialized.") 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] # [DEF:backend.src.core.superset_client.SupersetClient.authenticate:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -69,7 +69,7 @@ class SupersetClient:
def authenticate(self) -> Dict[str, str]: def authenticate(self) -> Dict[str, str]:
with belief_scope("SupersetClient.authenticate"): with belief_scope("SupersetClient.authenticate"):
return self.network.authenticate() return self.network.authenticate()
# [/DEF:authenticate:Function] # [/DEF:backend.src.core.superset_client.SupersetClient.authenticate:Function]
@property @property
# [DEF:backend.src.core.superset_client.SupersetClient.headers:Function] # [DEF:backend.src.core.superset_client.SupersetClient.headers:Function]
@@ -80,7 +80,7 @@ class SupersetClient:
def headers(self) -> dict: def headers(self) -> dict:
with belief_scope("headers"): with belief_scope("headers"):
return self.network.headers return self.network.headers
# [/DEF:headers:Function] # [/DEF:backend.src.core.superset_client.SupersetClient.headers:Function]
# [SECTION: DASHBOARD OPERATIONS] # [SECTION: DASHBOARD OPERATIONS]
@@ -116,7 +116,7 @@ class SupersetClient:
total_count = len(paginated_data) total_count = len(paginated_data)
app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count) app_logger.info("[get_dashboards][Exit] Found %d dashboards.", total_count)
return total_count, paginated_data 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_page:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -153,7 +153,7 @@ class SupersetClient:
result = response_json.get("result", []) result = response_json.get("result", [])
total_count = response_json.get("count", len(result)) total_count = response_json.get("count", len(result))
return total_count, 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_summary:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -238,7 +238,7 @@ class SupersetClient:
f"sampled={min(len(result), max_debug_samples)})" f"sampled={min(len(result), max_debug_samples)})"
) )
return result 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboards_summary_page:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -311,7 +311,7 @@ class SupersetClient:
}) })
return total_count, result 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] # [DEF:backend.src.core.superset_client.SupersetClient._extract_owner_labels:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -339,7 +339,7 @@ class SupersetClient:
if label and label not in normalized: if label and label not in normalized:
normalized.append(label) normalized.append(label)
return normalized 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] # [DEF:backend.src.core.superset_client.SupersetClient._extract_user_display:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -368,7 +368,7 @@ class SupersetClient:
if email: if email:
return email return email
return None 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] # [DEF:backend.src.core.superset_client.SupersetClient._sanitize_user_text:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -382,7 +382,7 @@ class SupersetClient:
if not normalized: if not normalized:
return None return None
return normalized 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboard:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -395,7 +395,7 @@ class SupersetClient:
with belief_scope("SupersetClient.get_dashboard", f"id={dashboard_id}"): with belief_scope("SupersetClient.get_dashboard", f"id={dashboard_id}"):
response = self.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}") response = self.network.request(method="GET", endpoint=f"/dashboard/{dashboard_id}")
return cast(Dict, response) 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_chart:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -408,7 +408,7 @@ class SupersetClient:
with belief_scope("SupersetClient.get_chart", f"id={chart_id}"): with belief_scope("SupersetClient.get_chart", f"id={chart_id}"):
response = self.network.request(method="GET", endpoint=f"/chart/{chart_id}") response = self.network.request(method="GET", endpoint=f"/chart/{chart_id}")
return cast(Dict, response) 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_dashboard_detail:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -426,6 +426,7 @@ class SupersetClient:
charts: List[Dict] = [] charts: List[Dict] = []
datasets: 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]: def extract_dataset_id_from_form_data(form_data: Optional[Dict]) -> Optional[int]:
if not isinstance(form_data, dict): if not isinstance(form_data, dict):
return None return None
@@ -448,6 +449,7 @@ class SupersetClient:
return int(ds_id) if ds_id is not None else None return int(ds_id) if ds_id is not None else None
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
# [/DEF:backend.src.core.superset_client.SupersetClient.get_dashboard_detail.extract_dataset_id_from_form_data:Function]
# Canonical endpoints from Superset OpenAPI: # Canonical endpoints from Superset OpenAPI:
# /dashboard/{id_or_slug}/charts and /dashboard/{id_or_slug}/datasets. # /dashboard/{id_or_slug}/charts and /dashboard/{id_or_slug}/datasets.
@@ -603,7 +605,7 @@ class SupersetClient:
"chart_count": len(unique_charts), "chart_count": len(unique_charts),
"dataset_count": len(unique_datasets), "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] # [DEF:backend.src.core.superset_client.SupersetClient.get_charts:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -623,7 +625,7 @@ class SupersetClient:
pagination_options={"base_query": validated_query, "results_field": "result"}, pagination_options={"base_query": validated_query, "results_field": "result"},
) )
return len(paginated_data), paginated_data 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] # [DEF:backend.src.core.superset_client.SupersetClient._extract_chart_ids_from_layout:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -656,7 +658,7 @@ class SupersetClient:
walk(payload) walk(payload)
return found 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] # [DEF:backend.src.core.superset_client.SupersetClient.export_dashboard:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -681,7 +683,7 @@ class SupersetClient:
filename = self._resolve_export_filename(response, dashboard_id) filename = self._resolve_export_filename(response, dashboard_id)
app_logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename) app_logger.info("[export_dashboard][Exit] Exported dashboard %s to %s.", dashboard_id, filename)
return response.content, 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] # [DEF:backend.src.core.superset_client.SupersetClient.import_dashboard:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -713,7 +715,7 @@ class SupersetClient:
self.delete_dashboard(target_id) self.delete_dashboard(target_id)
app_logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id) app_logger.info("[import_dashboard][State] Deleted dashboard ID %s, retrying import.", target_id)
return self._do_import(file_path) 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] # [DEF:backend.src.core.superset_client.SupersetClient.delete_dashboard:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -731,11 +733,7 @@ class SupersetClient:
app_logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id) app_logger.info("[delete_dashboard][Success] Dashboard %s deleted.", dashboard_id)
else: else:
app_logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response) app_logger.warning("[delete_dashboard][Warning] Unexpected response while deleting %s: %s", dashboard_id, response)
# [/DEF:delete_dashboard:Function] # [/DEF:backend.src.core.superset_client.SupersetClient.delete_dashboard:Function]
# [/SECTION]
# [SECTION: DATASET OPERATIONS]
# [DEF:backend.src.core.superset_client.SupersetClient.get_datasets:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_datasets:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -756,7 +754,7 @@ class SupersetClient:
total_count = len(paginated_data) total_count = len(paginated_data)
app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count) app_logger.info("[get_datasets][Exit] Found %d datasets.", total_count)
return total_count, paginated_data 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_datasets_summary:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -781,7 +779,7 @@ class SupersetClient:
"database": ds.get("database", {}).get("database_name", "Unknown") "database": ds.get("database", {}).get("database_name", "Unknown")
}) })
return result 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_dataset_detail:Function]
# @COMPLEXITY: 3 # @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") 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 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_dataset:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -910,7 +908,7 @@ class SupersetClient:
response = cast(Dict, response) response = cast(Dict, response)
app_logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id) app_logger.info("[get_dataset][Exit] Got dataset %s.", dataset_id)
return response 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] # [DEF:backend.src.core.superset_client.SupersetClient.update_dataset:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -932,11 +930,7 @@ class SupersetClient:
response = cast(Dict, response) response = cast(Dict, response)
app_logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id) app_logger.info("[update_dataset][Exit] Updated dataset %s.", dataset_id)
return response return response
# [/DEF:update_dataset:Function] # [/DEF:backend.src.core.superset_client.SupersetClient.update_dataset:Function]
# [/SECTION]
# [SECTION: DATABASE OPERATIONS]
# [DEF:backend.src.core.superset_client.SupersetClient.get_databases:Function] # [DEF:backend.src.core.superset_client.SupersetClient.get_databases:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -959,7 +953,7 @@ class SupersetClient:
total_count = len(paginated_data) total_count = len(paginated_data)
app_logger.info("[get_databases][Exit] Found %d databases.", total_count) app_logger.info("[get_databases][Exit] Found %d databases.", total_count)
return total_count, paginated_data 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_database:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -975,7 +969,7 @@ class SupersetClient:
response = cast(Dict, response) response = cast(Dict, response)
app_logger.info("[get_database][Exit] Got database %s.", database_id) app_logger.info("[get_database][Exit] Got database %s.", database_id)
return response 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_databases_summary:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -996,7 +990,7 @@ class SupersetClient:
db['engine'] = db.pop('backend', None) db['engine'] = db.pop('backend', None)
return databases 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_database_by_uuid:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -1012,11 +1006,7 @@ class SupersetClient:
} }
_, databases = self.get_databases(query=query) _, databases = self.get_databases(query=query)
return databases[0] if databases else None return databases[0] if databases else None
# [/DEF:get_database_by_uuid:Function] # [/DEF:backend.src.core.superset_client.SupersetClient.get_database_by_uuid:Function]
# [/SECTION]
# [SECTION: HELPERS]
# [DEF:backend.src.core.superset_client.SupersetClient._resolve_target_id_for_delete:Function] # [DEF:backend.src.core.superset_client.SupersetClient._resolve_target_id_for_delete:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -1039,7 +1029,7 @@ class SupersetClient:
except Exception as e: except Exception as e:
app_logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e) app_logger.warning("[_resolve_target_id_for_delete][Warning] Could not resolve slug '%s' to ID: %s", dash_slug, e)
return None 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] # [DEF:backend.src.core.superset_client.SupersetClient._do_import:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -1061,7 +1051,7 @@ class SupersetClient:
extra_data={"overwrite": "true"}, extra_data={"overwrite": "true"},
timeout=self.env.timeout * 2, 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] # [DEF:backend.src.core.superset_client.SupersetClient._validate_export_response:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -1075,7 +1065,7 @@ class SupersetClient:
raise SupersetAPIError(f"Получен не ZIP-архив (Content-Type: {content_type})") raise SupersetAPIError(f"Получен не ZIP-архив (Content-Type: {content_type})")
if not response.content: if not response.content:
raise SupersetAPIError("Получены пустые данные при экспорте") 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] # [DEF:backend.src.core.superset_client.SupersetClient._resolve_export_filename:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -1091,7 +1081,7 @@ class SupersetClient:
filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip" filename = f"dashboard_export_{dashboard_id}_{timestamp}.zip"
app_logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename) app_logger.warning("[_resolve_export_filename][Warning] Generated filename: %s", filename)
return 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] # [DEF:backend.src.core.superset_client.SupersetClient._validate_query_params:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -1104,7 +1094,7 @@ class SupersetClient:
# Using 100 avoids partial fetches when larger values are silently truncated. # Using 100 avoids partial fetches when larger values are silently truncated.
base_query = {"page": 0, "page_size": 100} base_query = {"page": 0, "page_size": 100}
return {**base_query, **(query or {})} 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] # [DEF:backend.src.core.superset_client.SupersetClient._fetch_total_object_count:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -1119,7 +1109,7 @@ class SupersetClient:
query_params={"page": 0, "page_size": 1}, query_params={"page": 0, "page_size": 1},
count_field="count", 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] # [DEF:backend.src.core.superset_client.SupersetClient._fetch_all_pages:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -1129,7 +1119,7 @@ class SupersetClient:
def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]: def _fetch_all_pages(self, endpoint: str, pagination_options: Dict) -> List[Dict]:
with belief_scope("_fetch_all_pages"): with belief_scope("_fetch_all_pages"):
return self.network.fetch_paginated_data(endpoint=endpoint, pagination_options=pagination_options) 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] # [DEF:backend.src.core.superset_client.SupersetClient._validate_import_file:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
@@ -1146,7 +1136,7 @@ class SupersetClient:
with zipfile.ZipFile(path, "r") as zf: with zipfile.ZipFile(path, "r") as zf:
if not any(n.endswith("metadata.yaml") for n in zf.namelist()): if not any(n.endswith("metadata.yaml") for n in zf.namelist()):
raise SupersetAPIError(f"Архив {zip_path} не содержит 'metadata.yaml'") 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] # [DEF:backend.src.core.superset_client.SupersetClient.get_all_resources:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
@@ -1170,12 +1160,8 @@ class SupersetClient:
query = {"columns": config["columns"]} query = {"columns": config["columns"]}
if since_dttm: 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 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) timestamp_ms = math.floor(since_dttm.timestamp() * 1000)
query["filters"] = [ query["filters"] = [
@@ -1185,7 +1171,6 @@ class SupersetClient:
"value": timestamp_ms "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) validated = self._validate_query_params(query)
data = self._fetch_all_pages( 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) app_logger.info("[get_all_resources][Exit] Fetched %d %s resources.", len(data), resource_type)
return data return data
# [/DEF:get_all_resources:Function] # [/DEF:backend.src.core.superset_client.SupersetClient.get_all_resources:Function]
# [/SECTION]
# [/DEF:backend.src.core.superset_client.SupersetClient:Class] # [/DEF:backend.src.core.superset_client.SupersetClient:Class]

View File

@@ -1,9 +1,18 @@
# [DEF:backend.src.dependencies:Module] # [DEF:AppDependencies:Module]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt # @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. # @PURPOSE: Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports.
# @LAYER: Core # @LAYER: Core
# @RELATION: Used by main app and API routers to get access to shared instances. # @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 pathlib import Path
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
@@ -234,4 +243,4 @@ def has_permission(resource: str, action: str):
return permission_checker return permission_checker
# [/DEF:has_permission:Function] # [/DEF:has_permission:Function]
# [/DEF:backend.src.dependencies:Module] # [/DEF:AppDependencies:Module]

View File

@@ -54,8 +54,10 @@ class User(Base):
username = Column(String, unique=True, index=True, nullable=False) username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, unique=True, index=True, nullable=True) email = Column(String, unique=True, index=True, nullable=True)
password_hash = Column(String, nullable=True) password_hash = Column(String, nullable=True)
full_name = Column(String, nullable=True)
auth_source = Column(String, default="LOCAL") # LOCAL or ADFS auth_source = Column(String, default="LOCAL") # LOCAL or ADFS
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_ad_user = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)

View File

@@ -1,5 +1,4 @@
# [DEF:backend.src.services.auth_service:Module] # [DEF:backend.src.services.auth_service:Module]
#
# @COMPLEXITY: 5 # @COMPLEXITY: 5
# @SEMANTICS: auth, service, business-logic, login, jwt, adfs, jit-provisioning # @SEMANTICS: auth, service, business-logic, login, jwt, adfs, jit-provisioning
# @PURPOSE: Orchestrates credential authentication and ADFS JIT user 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.core.auth.jwt.create_access_token]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.User] # @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.User]
# @RELATION: [DEPENDS_ON] ->[backend.src.models.auth.Role] # @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. # @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. # @PRE: Core auth models and security utilities available.
# @POST: User identity verified and session tokens issued according to role scopes. # @POST: User identity verified and session tokens issued according to role scopes.
# @SIDE_EFFECT: Writes last login timestamps and JIT-provisions external users. # @SIDE_EFFECT: Writes last login timestamps and JIT-provisions external users.
# @DATA_CONTRACT: [Credentials | ADFSClaims] -> [UserEntity | SessionToken] # @DATA_CONTRACT: [Credentials | ADFSClaims] -> [UserEntity | SessionToken]
# [SECTION: IMPORTS] from typing import Dict, Any, Optional, List
from typing import Dict, Any from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models.auth import User, Role
from ..core.auth.repository import AuthRepository from ..core.auth.repository import AuthRepository
from ..core.auth.security import verify_password from ..core.auth.security import verify_password
from ..core.auth.jwt import create_access_token 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 from ..core.logger import belief_scope
# [/SECTION]
# [DEF:AuthService:Class] # [DEF:AuthService:Class]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Provides high-level authentication services. # @PURPOSE: Provides high-level authentication services.
class AuthService: class AuthService:
# [DEF:__init__:Function] # [DEF:AuthService.__init__:Function]
# @COMPLEXITY: 1 # @COMPLEXITY: 1
# @PURPOSE: Initializes the authentication service with repository access over an active DB session. # @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. # @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) # @DATA_CONTRACT: Input(Session) -> Model(AuthRepository)
# @PARAM: db (Session) - SQLAlchemy session. # @PARAM: db (Session) - SQLAlchemy session.
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db
self.repo = AuthRepository(db) self.repo = AuthRepository(db)
# [/DEF:__init__:Function] # [/DEF:AuthService.__init__:Function]
# [DEF:authenticate_user:Function] # [DEF:AuthService.authenticate_user:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Validates credentials and account state for local username/password authentication. # @PURPOSE: Validates credentials and account state for local username/password authentication.
# @PRE: username and password are non-empty credential inputs. # @PRE: username and password are non-empty credential inputs.
@@ -52,23 +53,24 @@ class AuthService:
# @PARAM: username (str) - The username. # @PARAM: username (str) - The username.
# @PARAM: password (str) - The plain password. # @PARAM: password (str) - The plain password.
# @RETURN: Optional[User] - The authenticated user or None. # @RETURN: Optional[User] - The authenticated user or None.
def authenticate_user(self, username: str, password: str): def authenticate_user(self, username: str, password: str) -> Optional[User]:
with belief_scope("AuthService.authenticate_user"): with belief_scope("auth.authenticate_user"):
user = self.repo.get_user_by_username(username) user = self.repo.get_user_by_username(username)
if not user: if not user or not user.is_active:
return None return None
if not user.is_active: if not verify_password(password, user.password_hash):
return None
if not user.password_hash or not verify_password(password, user.password_hash):
return None 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 return user
# [/DEF:authenticate_user:Function] # [/DEF:AuthService.authenticate_user:Function]
# [DEF:create_session:Function] # [DEF:AuthService.create_session:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Issues an access token payload for an already authenticated user. # @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. # @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}) # @DATA_CONTRACT: Input(User) -> Output(Dict[str, str]{access_token, token_type})
# @PARAM: user (User) - The authenticated user. # @PARAM: user (User) - The authenticated user.
# @RETURN: Dict[str, str] - Session data. # @RETURN: Dict[str, str] - Session data.
def create_session(self, user) -> Dict[str, str]: def create_session(self, user: User) -> Dict[str, str]:
with belief_scope("AuthService.create_session"): with belief_scope("auth.create_session"):
# Collect role names for scopes roles = [role.name for role in user.roles]
scopes = [role.name for role in user.roles] access_token = create_access_token(
data={"sub": user.username, "scopes": roles}
token_data = { )
"sub": user.username, return {"access_token": access_token, "token_type": "bearer"}
"scopes": scopes # [/DEF:AuthService.create_session:Function]
}
access_token = create_access_token(data=token_data)
return {
"access_token": access_token,
"token_type": "bearer"
}
# [/DEF:create_session:Function]
# [DEF:provision_adfs_user:Function] # [DEF:AuthService.provision_adfs_user:Function]
# @COMPLEXITY: 3 # @COMPLEXITY: 3
# @PURPOSE: Performs ADFS Just-In-Time provisioning and role synchronization from AD group mappings. # @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. # @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. # @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
# @RETURN: User - The provisioned user. # @RETURN: User - The provisioned user.
def provision_adfs_user(self, user_info: Dict[str, Any]) -> 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") username = user_info.get("upn") or user_info.get("email")
email = 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) user = self.repo.get_user_by_username(username)
if not user: if not user:
user = User( user = User(
username=username, username=username,
email=email, email=email,
full_name=user_info.get("name"),
auth_source="ADFS", auth_source="ADFS",
is_active=True is_active=True,
is_ad_user=True
) )
self.repo.db.add(user) self.db.add(user)
log_security_event("USER_PROVISIONED", username, {"source": "ADFS"})
# 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()
# Sync roles from AD groups
mapped_roles = self.repo.get_roles_by_ad_groups(groups)
user.roles = mapped_roles 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 return user
# [/DEF:provision_adfs_user:Function] # [/DEF:AuthService.provision_adfs_user:Function]
# [/DEF:AuthService:Class] # [/DEF:AuthService:Class]
# [/DEF:backend.src.services.auth_service:Module] # [/DEF:backend.src.services.auth_service:Module]

View File

@@ -44,31 +44,35 @@ class GitService:
# @PARAM: base_path (str) - Root directory for all Git clones. # @PARAM: base_path (str) - Root directory for all Git clones.
# @PRE: base_path is a valid string path. # @PRE: base_path is a valid string path.
# @POST: GitService is initialized; base_path directory exists. # @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"): def __init__(self, base_path: str = "git_repos"):
with belief_scope("GitService.__init__"): with belief_scope("GitService.__init__"):
backend_root = Path(__file__).parents[2] backend_root = Path(__file__).parents[2]
self.legacy_base_path = str((backend_root / "git_repos").resolve()) self.legacy_base_path = str((backend_root / "git_repos").resolve())
self.base_path = self._resolve_base_path(base_path) self.base_path = self._resolve_base_path(base_path)
self._ensure_base_path_exists() 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. # @PURPOSE: Ensure the repositories root directory exists and is a directory.
# @PRE: self.base_path is resolved to filesystem path. # @PRE: self.base_path is resolved to filesystem path.
# @POST: self.base_path exists as directory or raises ValueError. # @POST: self.base_path exists as directory or raises ValueError.
# @RETURN: None # @RETURN: None
# @RELATION: USES -> [self.base_path]
def _ensure_base_path_exists(self) -> None: def _ensure_base_path_exists(self) -> None:
base = Path(self.base_path) base = Path(self.base_path)
if base.exists() and not base.is_dir(): if base.exists() and not base.is_dir():
raise ValueError(f"Git repositories base path is not a directory: {self.base_path}") raise ValueError(f"Git repositories base path is not a directory: {self.base_path}")
base.mkdir(parents=True, exist_ok=True) 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] # [DEF:backend.src.services.git_service.GitService._resolve_base_path:Function]
# @PURPOSE: Resolve base repository directory from explicit argument or global storage settings. # @PURPOSE: Resolve base repository directory from explicit argument or global storage settings.
# @PRE: base_path is a string path. # @PRE: base_path is a string path.
# @POST: Returns absolute path for Git repositories root. # @POST: Returns absolute path for Git repositories root.
# @RETURN: str # @RETURN: str
# @RELATION: USES -> [AppConfigRecord]
def _resolve_base_path(self, base_path: str) -> str: def _resolve_base_path(self, base_path: str) -> str:
# Resolve relative to backend directory for backward compatibility. # Resolve relative to backend directory for backward compatibility.
backend_root = Path(__file__).parents[2] backend_root = Path(__file__).parents[2]
@@ -104,24 +108,26 @@ class GitService:
except Exception as e: except Exception as e:
logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}") logger.warning(f"[_resolve_base_path][Coherence:Failed] Falling back to default path: {e}")
return fallback_path 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. # @PURPOSE: Convert user/dashboard-provided key to safe filesystem directory name.
# @PRE: repo_key can be None/empty. # @PRE: repo_key can be None/empty.
# @POST: Returns normalized non-empty key. # @POST: Returns normalized non-empty key.
# @RETURN: str # @RETURN: str
# @RELATION: USES -> [re.sub]
def _normalize_repo_key(self, repo_key: Optional[str]) -> str: def _normalize_repo_key(self, repo_key: Optional[str]) -> str:
raw_key = str(repo_key or "").strip().lower() raw_key = str(repo_key or "").strip().lower()
normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-") normalized = re.sub(r"[^a-z0-9._-]+", "-", raw_key).strip("._-")
return normalized or "dashboard" 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. # @PURPOSE: Persist repository local_path in GitRepository table when record exists.
# @PRE: dashboard_id is valid integer. # @PRE: dashboard_id is valid integer.
# @POST: local_path is updated for existing record. # @POST: local_path is updated for existing record.
# @RETURN: None # @RETURN: None
# @RELATION: USES -> [GitRepository]
def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None: def _update_repo_local_path(self, dashboard_id: int, local_path: str) -> None:
try: try:
session = SessionLocal() session = SessionLocal()
@@ -138,13 +144,14 @@ class GitService:
session.close() session.close()
except Exception as e: except Exception as e:
logger.warning(f"[_update_repo_local_path][Coherence:Failed] {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. # @PURPOSE: Move legacy repository directory to target path and sync DB metadata.
# @PRE: source_path exists. # @PRE: source_path exists.
# @POST: Repository content available at target_path. # @POST: Repository content available at target_path.
# @RETURN: str # @RETURN: str
# @RELATION: CALLS -> [GitService._update_repo_local_path]
def _migrate_repo_directory(self, dashboard_id: int, source_path: str, target_path: str) -> str: def _migrate_repo_directory(self, dashboard_id: int, source_path: str, target_path: str) -> str:
source_abs = os.path.abspath(source_path) source_abs = os.path.abspath(source_path)
target_abs = os.path.abspath(target_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}" f"[_migrate_repo_directory][Coherence:OK] Repository migrated for dashboard {dashboard_id}: {source_abs} -> {target_abs}"
) )
return 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. # @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
# @PRE: repo is a valid GitPython Repo instance. # @PRE: repo is a valid GitPython Repo instance.
# @POST: main, dev, preprod are available in local repository and pushed to origin when available. # @POST: main, dev, preprod are available in local repository and pushed to origin when available.
# @RETURN: None # @RETURN: None
# @RELATION: USES -> [Repo]
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None: def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
with belief_scope("GitService._ensure_gitflow_branches"): with belief_scope("GitService._ensure_gitflow_branches"):
required_branches = ["main", "dev", "preprod"] required_branches = ["main", "dev", "preprod"]
@@ -252,7 +260,7 @@ class GitService:
logger.warning( logger.warning(
f"[_ensure_gitflow_branches][Action] Could not checkout dev branch for dashboard {dashboard_id}: {e}" 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] # [DEF:backend.src.services.git_service.GitService._get_repo_path:Function]
# @PURPOSE: Resolves the local filesystem path for a dashboard's repository. # @PURPOSE: Resolves the local filesystem path for a dashboard's repository.
@@ -261,6 +269,9 @@ class GitService:
# @PRE: dashboard_id is an integer. # @PRE: dashboard_id is an integer.
# @POST: Returns DB-local_path when present, otherwise base_path/<normalized repo_key>. # @POST: Returns DB-local_path when present, otherwise base_path/<normalized repo_key>.
# @RETURN: str # @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: def _get_repo_path(self, dashboard_id: int, repo_key: Optional[str] = None) -> str:
with belief_scope("GitService._get_repo_path"): with belief_scope("GitService._get_repo_path"):
if dashboard_id is None: if dashboard_id is None:
@@ -300,9 +311,9 @@ class GitService:
self._update_repo_local_path(dashboard_id, target_path) self._update_repo_local_path(dashboard_id, target_path)
return 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. # @PURPOSE: Initialize or clone a repository for a dashboard.
# @PARAM: dashboard_id (int) # @PARAM: dashboard_id (int)
# @PARAM: remote_url (str) # @PARAM: remote_url (str)
@@ -311,6 +322,8 @@ class GitService:
# @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided. # @PRE: dashboard_id is int, remote_url is valid Git URL, pat is provided.
# @POST: Repository is cloned or opened at the local path. # @POST: Repository is cloned or opened at the local path.
# @RETURN: Repo - GitPython Repo object. # @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: def init_repo(self, dashboard_id: int, remote_url: str, pat: str, repo_key: Optional[str] = None) -> Repo:
with belief_scope("GitService.init_repo"): with belief_scope("GitService.init_repo"):
self._ensure_base_path_exists() self._ensure_base_path_exists()
@@ -344,13 +357,14 @@ class GitService:
repo = Repo.clone_from(auth_url, repo_path) repo = Repo.clone_from(auth_url, repo_path)
self._ensure_gitflow_branches(repo, dashboard_id) self._ensure_gitflow_branches(repo, dashboard_id)
return repo 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. # @PURPOSE: Remove local repository and DB binding for a dashboard.
# @PRE: dashboard_id is a valid integer. # @PRE: dashboard_id is a valid integer.
# @POST: Local path is deleted when present and GitRepository row is removed. # @POST: Local path is deleted when present and GitRepository row is removed.
# @RETURN: None # @RETURN: None
# @RELATION: CALLS -> [GitService._get_repo_path]
def delete_repo(self, dashboard_id: int) -> None: def delete_repo(self, dashboard_id: int) -> None:
with belief_scope("GitService.delete_repo"): with belief_scope("GitService.delete_repo"):
repo_path = self._get_repo_path(dashboard_id) 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)}") raise HTTPException(status_code=500, detail=f"Failed to delete repository: {str(e)}")
finally: finally:
session.close() 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] # [DEF:backend.src.services.git_service.GitService.get_repo:Function]
# @PURPOSE: Get Repo object for a dashboard. # @PURPOSE: Get Repo object for a dashboard.
# @PRE: Repository must exist on disk for the given dashboard_id. # @PRE: Repository must exist on disk for the given dashboard_id.
# @POST: Returns a GitPython Repo instance for the dashboard. # @POST: Returns a GitPython Repo instance for the dashboard.
# @RETURN: Repo # @RETURN: Repo
# @RELATION: CALLS -> [GitService._get_repo_path]
def get_repo(self, dashboard_id: int) -> Repo: def get_repo(self, dashboard_id: int) -> Repo:
with belief_scope("GitService.get_repo"): with belief_scope("GitService.get_repo"):
repo_path = self._get_repo_path(dashboard_id) repo_path = self._get_repo_path(dashboard_id)
@@ -410,13 +425,14 @@ class GitService:
except Exception as e: except Exception as e:
logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {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") 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. # @PURPOSE: Configure repository-local Git committer identity for user-scoped operations.
# @PRE: dashboard_id repository exists; git_username/git_email may be empty. # @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. # @POST: Repository config has user.name and user.email when both identity values are provided.
# @RETURN: None # @RETURN: None
# @RELATION: CALLS -> [GitService.get_repo]
def configure_identity( def configure_identity(
self, self,
dashboard_id: int, dashboard_id: int,
@@ -441,13 +457,14 @@ class GitService:
except Exception as e: except Exception as e:
logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {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)}") 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. # @PURPOSE: List all branches for a dashboard's repository.
# @PRE: Repository for dashboard_id exists. # @PRE: Repository for dashboard_id exists.
# @POST: Returns a list of branch metadata dictionaries. # @POST: Returns a list of branch metadata dictionaries.
# @RETURN: List[dict] # @RETURN: List[dict]
# @RELATION: CALLS -> [GitService.get_repo]
def list_branches(self, dashboard_id: int) -> List[dict]: def list_branches(self, dashboard_id: int) -> List[dict]:
with belief_scope("GitService.list_branches"): with belief_scope("GitService.list_branches"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -495,14 +512,15 @@ class GitService:
}) })
return branches 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. # @PURPOSE: Create a new branch from an existing one.
# @PARAM: name (str) - New branch name. # @PARAM: name (str) - New branch name.
# @PARAM: from_branch (str) - Source branch. # @PARAM: from_branch (str) - Source branch.
# @PRE: Repository exists; name is valid; from_branch exists or repo is empty. # @PRE: Repository exists; name is valid; from_branch exists or repo is empty.
# @POST: A new branch is created in the repository. # @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"): def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
with belief_scope("GitService.create_branch"): with belief_scope("GitService.create_branch"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -531,25 +549,27 @@ class GitService:
except Exception as e: except Exception as e:
logger.error(f"[create_branch][Coherence:Failed] {e}") logger.error(f"[create_branch][Coherence:Failed] {e}")
raise 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. # @PURPOSE: Switch to a specific branch.
# @PRE: Repository exists and the specified branch name exists. # @PRE: Repository exists and the specified branch name exists.
# @POST: The repository working directory is updated to the specified branch. # @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): def checkout_branch(self, dashboard_id: int, name: str):
with belief_scope("GitService.checkout_branch"): with belief_scope("GitService.checkout_branch"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
logger.info(f"[checkout_branch][Action] Checking out branch {name}") logger.info(f"[checkout_branch][Action] Checking out branch {name}")
repo.git.checkout(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. # @PURPOSE: Stage and commit changes.
# @PARAM: message (str) - Commit message. # @PARAM: message (str) - Commit message.
# @PARAM: files (List[str]) - Optional list of specific files to stage. # @PARAM: files (List[str]) - Optional list of specific files to stage.
# @PRE: Repository exists and has changes (dirty) or files are specified. # @PRE: Repository exists and has changes (dirty) or files are specified.
# @POST: Changes are staged and a new commit is created. # @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): def commit_changes(self, dashboard_id: int, message: str, files: List[str] = None):
with belief_scope("GitService.commit_changes"): with belief_scope("GitService.commit_changes"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -568,13 +588,14 @@ class GitService:
repo.index.commit(message) repo.index.commit(message)
logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {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. # @PURPOSE: Extract normalized host[:port] from HTTP(S) URL.
# @PRE: url_value may be empty. # @PRE: url_value may be empty.
# @POST: Returns lowercase host token or None. # @POST: Returns lowercase host token or None.
# @RETURN: Optional[str] # @RETURN: Optional[str]
# @RELATION: USES -> [urlparse]
def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]: def _extract_http_host(self, url_value: Optional[str]) -> Optional[str]:
normalized = str(url_value or "").strip() normalized = str(url_value or "").strip()
if not normalized: if not normalized:
@@ -591,13 +612,14 @@ class GitService:
if parsed.port: if parsed.port:
return f"{host.lower()}:{parsed.port}" return f"{host.lower()}:{parsed.port}"
return host.lower() 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. # @PURPOSE: Remove credentials from URL while preserving scheme/host/path.
# @PRE: url_value may contain credentials. # @PRE: url_value may contain credentials.
# @POST: Returns URL without username/password. # @POST: Returns URL without username/password.
# @RETURN: str # @RETURN: str
# @RELATION: USES -> [urlparse]
def _strip_url_credentials(self, url_value: str) -> str: def _strip_url_credentials(self, url_value: str) -> str:
normalized = str(url_value or "").strip() normalized = str(url_value or "").strip()
if not normalized: if not normalized:
@@ -612,13 +634,14 @@ class GitService:
if parsed.port: if parsed.port:
host = f"{host}:{parsed.port}" host = f"{host}:{parsed.port}"
return parsed._replace(netloc=host).geturl() 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. # @PURPOSE: Replace source URL host with host from configured server URL.
# @PRE: source_url and config_url are HTTP(S) URLs. # @PRE: source_url and config_url are HTTP(S) URLs.
# @POST: Returns source URL with updated host (credentials preserved) or None. # @POST: Returns source URL with updated host (credentials preserved) or None.
# @RETURN: Optional[str] # @RETURN: Optional[str]
# @RELATION: USES -> [urlparse]
def _replace_host_in_url(self, source_url: Optional[str], config_url: Optional[str]) -> Optional[str]: def _replace_host_in_url(self, source_url: Optional[str], config_url: Optional[str]) -> Optional[str]:
source = str(source_url or "").strip() source = str(source_url or "").strip()
config = str(config_url or "").strip() config = str(config_url or "").strip()
@@ -650,13 +673,16 @@ class GitService:
new_netloc = f"{auth_part}{target_host}" new_netloc = f"{auth_part}{target_host}"
return source_parsed._replace(netloc=new_netloc).geturl() 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. # @PURPOSE: Auto-align local origin host to configured Git server host when they drift.
# @PRE: origin remote exists. # @PRE: origin remote exists.
# @POST: origin URL host updated and DB binding normalized when mismatch detected. # @POST: origin URL host updated and DB binding normalized when mismatch detected.
# @RETURN: Optional[str] # @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( def _align_origin_host_with_config(
self, self,
dashboard_id: int, dashboard_id: int,
@@ -716,12 +742,14 @@ class GitService:
) )
return aligned_url 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. # @PURPOSE: Push local commits to remote.
# @PRE: Repository exists and has an 'origin' remote. # @PRE: Repository exists and has an 'origin' remote.
# @POST: Local branch commits are pushed to origin. # @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): def push_changes(self, dashboard_id: int):
with belief_scope("GitService.push_changes"): with belief_scope("GitService.push_changes"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -829,12 +857,11 @@ class GitService:
except Exception as e: except Exception as e:
logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {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)}") 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] # [DEF:backend.src.services.git_service.GitService._read_blob_text:Function]
# @PURPOSE: Pull changes from remote. # @PURPOSE: Read text from a Git blob.
# @PRE: Repository exists and has an 'origin' remote. # @RELATION: USES -> [Blob]
# @POST: Changes from origin are pulled and merged into the active branch.
def _read_blob_text(self, blob: Blob) -> str: def _read_blob_text(self, blob: Blob) -> str:
with belief_scope("GitService._read_blob_text"): with belief_scope("GitService._read_blob_text"):
if blob is None: if blob is None:
@@ -843,14 +870,22 @@ class GitService:
return blob.data_stream.read().decode("utf-8", errors="replace") return blob.data_stream.read().decode("utf-8", errors="replace")
except Exception: except Exception:
return "" 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]: def _get_unmerged_file_paths(self, repo: Repo) -> List[str]:
with belief_scope("GitService._get_unmerged_file_paths"): with belief_scope("GitService._get_unmerged_file_paths"):
try: try:
return sorted(list(repo.index.unmerged_blobs().keys())) return sorted(list(repo.index.unmerged_blobs().keys()))
except Exception: except Exception:
return [] 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]: def _build_unfinished_merge_payload(self, repo: Repo) -> Dict[str, Any]:
with belief_scope("GitService._build_unfinished_merge_payload"): with belief_scope("GitService._build_unfinished_merge_payload"):
merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD") merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD")
@@ -900,7 +935,12 @@ class GitService:
"git merge --abort", "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]: def get_merge_status(self, dashboard_id: int) -> Dict[str, Any]:
with belief_scope("GitService.get_merge_status"): with belief_scope("GitService.get_merge_status"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -930,7 +970,12 @@ class GitService:
"merge_message_preview": payload["merge_message_preview"], "merge_message_preview": payload["merge_message_preview"],
"conflicts_count": int(payload.get("conflicts_count") or 0), "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]]: def get_merge_conflicts(self, dashboard_id: int) -> List[Dict[str, Any]]:
with belief_scope("GitService.get_merge_conflicts"): with belief_scope("GitService.get_merge_conflicts"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -952,7 +997,11 @@ class GitService:
} }
) )
return sorted(conflicts, key=lambda item: item["file_path"]) 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]: def resolve_merge_conflicts(self, dashboard_id: int, resolutions: List[Dict[str, Any]]) -> List[str]:
with belief_scope("GitService.resolve_merge_conflicts"): with belief_scope("GitService.resolve_merge_conflicts"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -986,7 +1035,11 @@ class GitService:
resolved_files.append(file_path) resolved_files.append(file_path)
return resolved_files 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]: def abort_merge(self, dashboard_id: int) -> Dict[str, Any]:
with belief_scope("GitService.abort_merge"): with belief_scope("GitService.abort_merge"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -999,7 +1052,12 @@ class GitService:
return {"status": "no_merge_in_progress"} return {"status": "no_merge_in_progress"}
raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}") raise HTTPException(status_code=409, detail=f"Cannot abort merge: {details}")
return {"status": "aborted"} 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]: def continue_merge(self, dashboard_id: int, message: Optional[str] = None) -> Dict[str, Any]:
with belief_scope("GitService.continue_merge"): with belief_scope("GitService.continue_merge"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -1032,7 +1090,14 @@ class GitService:
except Exception: except Exception:
commit_hash = "" commit_hash = ""
return {"status": "committed", "commit_hash": 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): def pull_changes(self, dashboard_id: int):
with belief_scope("GitService.pull_changes"): with belief_scope("GitService.pull_changes"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -1110,13 +1175,14 @@ class GitService:
except Exception as e: except Exception as e:
logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {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)}") 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] # [DEF:backend.src.services.git_service.GitService.get_status:Function]
# @PURPOSE: Get current repository status (dirty files, untracked, etc.) # @PURPOSE: Get current repository status (dirty files, untracked, etc.)
# @PRE: Repository for dashboard_id exists. # @PRE: Repository for dashboard_id exists.
# @POST: Returns a dictionary representing the Git status. # @POST: Returns a dictionary representing the Git status.
# @RETURN: dict # @RETURN: dict
# @RELATION: CALLS -> [GitService.get_repo]
def get_status(self, dashboard_id: int) -> dict: def get_status(self, dashboard_id: int) -> dict:
with belief_scope("GitService.get_status"): with belief_scope("GitService.get_status"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -1186,15 +1252,16 @@ class GitService:
"is_diverged": is_diverged, "is_diverged": is_diverged,
"sync_state": sync_state, "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. # @PURPOSE: Generate diff for a file or the whole repository.
# @PARAM: file_path (str) - Optional specific file. # @PARAM: file_path (str) - Optional specific file.
# @PARAM: staged (bool) - Whether to show staged changes. # @PARAM: staged (bool) - Whether to show staged changes.
# @PRE: Repository for dashboard_id exists. # @PRE: Repository for dashboard_id exists.
# @POST: Returns the diff text as a string. # @POST: Returns the diff text as a string.
# @RETURN: str # @RETURN: str
# @RELATION: CALLS -> [GitService.get_repo]
def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str: def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str:
with belief_scope("GitService.get_diff"): with belief_scope("GitService.get_diff"):
repo = self.get_repo(dashboard_id) repo = self.get_repo(dashboard_id)
@@ -1205,14 +1272,15 @@ class GitService:
if file_path: if file_path:
return repo.git.diff(*diff_args, "--", file_path) return repo.git.diff(*diff_args, "--", file_path)
return repo.git.diff(*diff_args) 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. # @PURPOSE: Retrieve commit history for a repository.
# @PARAM: limit (int) - Max number of commits to return. # @PARAM: limit (int) - Max number of commits to return.
# @PRE: Repository for dashboard_id exists. # @PRE: Repository for dashboard_id exists.
# @POST: Returns a list of dictionaries for each commit in history. # @POST: Returns a list of dictionaries for each commit in history.
# @RETURN: List[dict] # @RETURN: List[dict]
# @RELATION: CALLS -> [GitService.get_repo]
def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]: def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]:
with belief_scope("GitService.get_commit_history"): with belief_scope("GitService.get_commit_history"):
repo = self.get_repo(dashboard_id) 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}") logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}")
return [] return []
return commits 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. # @PURPOSE: Test connection to Git provider using PAT.
# @PARAM: provider (GitProvider) # @PARAM: provider (GitProvider)
# @PARAM: url (str) # @PARAM: url (str)
@@ -1245,6 +1313,7 @@ class GitService:
# @PRE: provider is valid; url is a valid HTTP(S) URL; pat is provided. # @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. # @POST: Returns True if connection to the provider's API succeeds.
# @RETURN: bool # @RETURN: bool
# @RELATION: USES -> [httpx.AsyncClient]
async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool: async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool:
with belief_scope("GitService.test_connection"): with belief_scope("GitService.test_connection"):
# Check for offline mode or local-only URLs # Check for offline mode or local-only URLs
@@ -1285,9 +1354,9 @@ class GitService:
except Exception as e: except Exception as e:
logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}") logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}")
return False 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. # @PURPOSE: Normalize Git server URL for provider API calls.
# @PRE: raw_url is non-empty. # @PRE: raw_url is non-empty.
# @POST: Returns URL without trailing slash. # @POST: Returns URL without trailing slash.
@@ -1297,9 +1366,9 @@ class GitService:
if not normalized: if not normalized:
raise HTTPException(status_code=400, detail="Git server URL is required") raise HTTPException(status_code=400, detail="Git server URL is required")
return normalized.rstrip("/") 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. # @PURPOSE: Build Gitea API authorization headers.
# @PRE: pat is provided. # @PRE: pat is provided.
# @POST: Returns headers with token auth. # @POST: Returns headers with token auth.
@@ -1313,13 +1382,15 @@ class GitService:
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "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. # @PURPOSE: Execute HTTP request against Gitea API with stable error mapping.
# @PRE: method and endpoint are valid. # @PRE: method and endpoint are valid.
# @POST: Returns decoded JSON payload. # @POST: Returns decoded JSON payload.
# @RETURN: Any # @RETURN: Any
# @RELATION: CALLS -> [GitService._normalize_git_server_url]
# @RELATION: CALLS -> [GitService._gitea_headers]
async def _gitea_request( async def _gitea_request(
self, self,
method: str, method: str,
@@ -1361,26 +1432,28 @@ class GitService:
if response.status_code == 204: if response.status_code == 204:
return None return None
return response.json() 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. # @PURPOSE: Resolve current Gitea user for PAT.
# @PRE: server_url and pat are valid. # @PRE: server_url and pat are valid.
# @POST: Returns current username. # @POST: Returns current username.
# @RETURN: str # @RETURN: str
# @RELATION: CALLS -> [GitService._gitea_request]
async def get_gitea_current_user(self, server_url: str, pat: str) -> str: async def get_gitea_current_user(self, server_url: str, pat: str) -> str:
payload = await self._gitea_request("GET", server_url, pat, "/user") payload = await self._gitea_request("GET", server_url, pat, "/user")
username = payload.get("login") or payload.get("username") username = payload.get("login") or payload.get("username")
if not username: if not username:
raise HTTPException(status_code=500, detail="Failed to resolve Gitea username") raise HTTPException(status_code=500, detail="Failed to resolve Gitea username")
return str(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. # @PURPOSE: List repositories visible to authenticated Gitea user.
# @PRE: server_url and pat are valid. # @PRE: server_url and pat are valid.
# @POST: Returns repository list from Gitea. # @POST: Returns repository list from Gitea.
# @RETURN: List[dict] # @RETURN: List[dict]
# @RELATION: CALLS -> [GitService._gitea_request]
async def list_gitea_repositories(self, server_url: str, pat: str) -> List[dict]: async def list_gitea_repositories(self, server_url: str, pat: str) -> List[dict]:
payload = await self._gitea_request( payload = await self._gitea_request(
"GET", "GET",
@@ -1391,13 +1464,14 @@ class GitService:
if not isinstance(payload, list): if not isinstance(payload, list):
return [] return []
return payload 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. # @PURPOSE: Create repository in Gitea for authenticated user.
# @PRE: name is non-empty and PAT has repo creation permission. # @PRE: name is non-empty and PAT has repo creation permission.
# @POST: Returns created repository payload. # @POST: Returns created repository payload.
# @RETURN: dict # @RETURN: dict
# @RELATION: CALLS -> [GitService._gitea_request]
async def create_gitea_repository( async def create_gitea_repository(
self, self,
server_url: str, server_url: str,
@@ -1427,12 +1501,13 @@ class GitService:
if not isinstance(created, dict): if not isinstance(created, dict):
raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository") raise HTTPException(status_code=500, detail="Unexpected Gitea response while creating repository")
return created 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. # @PURPOSE: Delete repository in Gitea.
# @PRE: owner and repo_name are non-empty. # @PRE: owner and repo_name are non-empty.
# @POST: Repository deleted on Gitea server. # @POST: Repository deleted on Gitea server.
# @RELATION: CALLS -> [GitService._gitea_request]
async def delete_gitea_repository( async def delete_gitea_repository(
self, self,
server_url: str, server_url: str,
@@ -1448,13 +1523,14 @@ class GitService:
pat, pat,
f"/repos/{owner}/{repo_name}", 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. # @PURPOSE: Check whether a branch exists in Gitea repository.
# @PRE: owner/repo/branch are non-empty. # @PRE: owner/repo/branch are non-empty.
# @POST: Returns True when branch exists, False when 404. # @POST: Returns True when branch exists, False when 404.
# @RETURN: bool # @RETURN: bool
# @RELATION: CALLS -> [GitService._gitea_request]
async def _gitea_branch_exists( async def _gitea_branch_exists(
self, self,
server_url: str, server_url: str,
@@ -1473,13 +1549,14 @@ class GitService:
if exc.status_code == 404: if exc.status_code == 404:
return False return False
raise 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. # @PURPOSE: Build actionable error detail for Gitea PR 404 responses.
# @PRE: owner/repo/from_branch/to_branch are provided. # @PRE: owner/repo/from_branch/to_branch are provided.
# @POST: Returns specific branch-missing message when detected. # @POST: Returns specific branch-missing message when detected.
# @RETURN: Optional[str] # @RETURN: Optional[str]
# @RELATION: CALLS -> [GitService._gitea_branch_exists]
async def _build_gitea_pr_404_detail( async def _build_gitea_pr_404_detail(
self, self,
server_url: str, server_url: str,
@@ -1508,13 +1585,14 @@ class GitService:
if not target_exists: if not target_exists:
return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}" return f"Gitea branch not found: target branch '{to_branch}' in {owner}/{repo}"
return None 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. # @PURPOSE: Create repository in GitHub or GitHub Enterprise.
# @PRE: PAT has repository create permission. # @PRE: PAT has repository create permission.
# @POST: Returns created repository payload. # @POST: Returns created repository payload.
# @RETURN: dict # @RETURN: dict
# @RELATION: CALLS -> [GitService._normalize_git_server_url]
async def create_github_repository( async def create_github_repository(
self, self,
server_url: str, server_url: str,
@@ -1560,13 +1638,14 @@ class GitService:
pass pass
raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}") raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}")
return response.json() 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. # @PURPOSE: Create repository(project) in GitLab.
# @PRE: PAT has api scope. # @PRE: PAT has api scope.
# @POST: Returns created repository payload. # @POST: Returns created repository payload.
# @RETURN: dict # @RETURN: dict
# @RELATION: CALLS -> [GitService._normalize_git_server_url]
async def create_gitlab_repository( async def create_gitlab_repository(
self, self,
server_url: str, server_url: str,
@@ -1620,13 +1699,14 @@ class GitService:
if "full_name" not in data: if "full_name" not in data:
data["full_name"] = data.get("path_with_namespace") or data.get("name") data["full_name"] = data.get("path_with_namespace") or data.get("name")
return data 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. # @PURPOSE: Parse owner/repo from remote URL for Git server API operations.
# @PRE: remote_url is a valid git URL. # @PRE: remote_url is a valid git URL.
# @POST: Returns owner/repo tokens. # @POST: Returns owner/repo tokens.
# @RETURN: Dict[str, str] # @RETURN: Dict[str, str]
# @RELATION: USES -> [urlparse]
def _parse_remote_repo_identity(self, remote_url: str) -> Dict[str, str]: def _parse_remote_repo_identity(self, remote_url: str) -> Dict[str, str]:
normalized = str(remote_url or "").strip() normalized = str(remote_url or "").strip()
if not normalized: if not normalized:
@@ -1655,13 +1735,14 @@ class GitService:
"namespace": namespace, "namespace": namespace,
"full_name": f"{namespace}/{repo}", "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] # [DEF:backend.src.services.git_service.GitService._derive_server_url_from_remote:Function]
# @PURPOSE: Build API base URL from remote repository URL without credentials. # @PURPOSE: Build API base URL from remote repository URL without credentials.
# @PRE: remote_url may be any git URL. # @PRE: remote_url may be any git URL.
# @POST: Returns normalized http(s) base URL or None when derivation is impossible. # @POST: Returns normalized http(s) base URL or None when derivation is impossible.
# @RETURN: Optional[str] # @RETURN: Optional[str]
# @RELATION: USES -> [urlparse]
def _derive_server_url_from_remote(self, remote_url: str) -> Optional[str]: def _derive_server_url_from_remote(self, remote_url: str) -> Optional[str]:
normalized = str(remote_url or "").strip() normalized = str(remote_url or "").strip()
if not normalized or normalized.startswith("git@"): if not normalized or normalized.startswith("git@"):
@@ -1677,13 +1758,14 @@ class GitService:
if parsed.port: if parsed.port:
netloc = f"{netloc}:{parsed.port}" netloc = f"{netloc}:{parsed.port}"
return f"{parsed.scheme}://{netloc}".rstrip("/") 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. # @PURPOSE: Perform direct merge between branches in local repo and push target branch.
# @PRE: Repository exists and both branches are valid. # @PRE: Repository exists and both branches are valid.
# @POST: Target branch contains merged changes from source branch. # @POST: Target branch contains merged changes from source branch.
# @RETURN: Dict[str, Any] # @RETURN: Dict[str, Any]
# @RELATION: CALLS -> [GitService.get_repo]
def promote_direct_merge( def promote_direct_merge(
self, self,
dashboard_id: int, dashboard_id: int,
@@ -1742,13 +1824,18 @@ class GitService:
"to_branch": target, "to_branch": target,
"status": "merged", "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] # [DEF:backend.src.services.git_service.GitService.create_gitea_pull_request:Function]
# @PURPOSE: Create pull request in Gitea. # @PURPOSE: Create pull request in Gitea.
# @PRE: Config and remote URL are valid. # @PRE: Config and remote URL are valid.
# @POST: Returns normalized PR metadata. # @POST: Returns normalized PR metadata.
# @RETURN: Dict[str, Any] # @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( async def create_gitea_pull_request(
self, self,
server_url: str, server_url: str,
@@ -1830,13 +1917,15 @@ class GitService:
"url": data.get("html_url") or data.get("url"), "url": data.get("html_url") or data.get("url"),
"status": data.get("state") or "open", "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] # [DEF:backend.src.services.git_service.GitService.create_github_pull_request:Function]
# @PURPOSE: Create pull request in GitHub or GitHub Enterprise. # @PURPOSE: Create pull request in GitHub or GitHub Enterprise.
# @PRE: Config and remote URL are valid. # @PRE: Config and remote URL are valid.
# @POST: Returns normalized PR metadata. # @POST: Returns normalized PR metadata.
# @RETURN: Dict[str, Any] # @RETURN: Dict[str, Any]
# @RELATION: CALLS -> [GitService._parse_remote_repo_identity]
# @RELATION: CALLS -> [GitService._normalize_git_server_url]
async def create_github_pull_request( async def create_github_pull_request(
self, self,
server_url: str, server_url: str,
@@ -1884,13 +1973,15 @@ class GitService:
"url": data.get("html_url") or data.get("url"), "url": data.get("html_url") or data.get("url"),
"status": data.get("state") or "open", "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] # [DEF:backend.src.services.git_service.GitService.create_gitlab_merge_request:Function]
# @PURPOSE: Create merge request in GitLab. # @PURPOSE: Create merge request in GitLab.
# @PRE: Config and remote URL are valid. # @PRE: Config and remote URL are valid.
# @POST: Returns normalized MR metadata. # @POST: Returns normalized MR metadata.
# @RETURN: Dict[str, Any] # @RETURN: Dict[str, Any]
# @RELATION: CALLS -> [GitService._parse_remote_repo_identity]
# @RELATION: CALLS -> [GitService._normalize_git_server_url]
async def create_gitlab_merge_request( async def create_gitlab_merge_request(
self, self,
server_url: str, server_url: str,
@@ -1938,7 +2029,7 @@ class GitService:
"url": data.get("web_url") or data.get("url"), "url": data.get("web_url") or data.get("url"),
"status": data.get("state") or "opened", "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] # [/DEF:backend.src.services.git_service:Module]