feat(semantic): curator-driven protocol hardening — decision memory + relation repair

- Add @RATIONALE/@REJECTED to 103+ C4/C5 contracts across backend core, services, API routes, and frontend models
- Fix 109 unresolved @RELATION edges (Auth.*, SupersetClient.*, AgentChat.*, ADR cross-refs)
- Add 13 @ingroup tags for DSA/HCA attention grouping
- Repair 29 stale graph edges via index rebuild
- Update .kilo agent prompts and skills for GRACE-Poly v2.6 compliance
- Git integration: merge routes, branch lifecycle, remote providers, UX components
- 0 broken anchor pairs, index rebuilt with 0 parse warnings
This commit is contained in:
2026-07-02 08:53:19 +03:00
parent 87ac90bb8d
commit db998ce085
144 changed files with 10111 additions and 762 deletions

View File

@@ -340,7 +340,7 @@ async def prefetch_dashboards(env_id: str) -> str:
# @POST Conversation + messages saved via POST /api/agent/conversations/save.
# @SIDE_EFFECT HTTP POST to FastAPI; writes to AgentConversation and AgentMessage tables.
# @DATA_CONTRACT Input: (conv_id, user_text, user_id, assistant_text) -> Output: None (side-effect only)
# @RELATION DISPATCHES -> [Api.Agent.Conversations]
# @RELATION DISPATCHES -> [AgentChat.Api.Conversations]
# @RATIONALE Anonymous Gradio sessions (anon_ prefix) default to "admin" because the agent runs in an internal network behind an auth proxy — all users within the network are trusted.
# @REJECTED Requiring explicit authentication for Gradio was rejected — the agent is designed for internal-network use where the auth proxy handles auth; adding a separate auth layer would create unnecessary friction and duplicate the proxy's responsibility.
async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin", assistant_text: str = "") -> None:

View File

@@ -2,7 +2,7 @@
# #region AgentChat.Middleware [C:3] [TYPE Module] [SEMANTICS agent-chat,middleware,logging,audit]
# @defgroup AgentChat Audit logging and confirmation risk middleware for LangGraph agent.
# @BRIEF LoggingMiddleware writes tool-call events to assistant_audit table.
# @RELATION DEPENDS_ON -> [Models.AssistantAuditRecord]
# @RELATION DEPENDS_ON -> [AssistantAuditRecord]
# @RATIONALE FR-024: All agent interactions must be logged for auditability.
# @REJECTED ConfirmationRiskMiddleware rejected — LangGraph interrupt_before=DANGEROUS_TOOLS handles HITL natively.
@@ -19,7 +19,7 @@ from src.core.logger import logger
# @PRE agent event has 'event' key with type on_tool_start/on_tool_end/on_tool_error.
# @POST Audit record written to assistant_audit table (async, non-blocking).
# @SIDE_EFFECT Writes to assistant_audit table via FastAPI REST call.
# @RELATION DISPATCHES -> [Api.Assistant.Audit]
# @RELATION DISPATCHES -> [get_assistant_audit]
async def log_tool_event(event: dict, conversation_id: str) -> None:
"""Log a tool-call event to the audit trail.

View File

@@ -7,8 +7,8 @@
# @DATA_CONTRACT OAuth2PasswordRequestForm -> Token | User
# @INVARIANT All auth endpoints return consistent error codes (401/403/422).
# @RELATION DEPENDS_ON -> [Auth.Jwt]
# @RELATION DEPENDS_ON -> [Auth.Service]
# @RELATION DEPENDS_ON -> [Auth.OAuth]
# @RELATION DEPENDS_ON -> [auth_service]
# @RELATION DEPENDS_ON -> [AuthOauthModule]
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
@@ -39,7 +39,7 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
# @POST Returns Token(access_token, token_type) on success; 401 on failure.
# @SIDE_EFFECT DB read for user verification; writes security event LOGIN.
# @SIDE_EFFECT Molecular CoT: REASON on entry, REFLECT on success, EXPLORE on failure.
# @RELATION CALLS -> [Auth.Service]
# @RELATION CALLS -> [auth_service]
# @TEST_EDGE: invalid_credentials -> 401
# @TEST_EDGE: locked_account -> 423
# @TEST_EDGE: missing_fields -> 422
@@ -87,7 +87,7 @@ async def login_for_access_token(
# @PRE Valid JWT token in Authorization header.
# @POST Returns UserSchema with id, username, email, roles.
# @SIDE_EFFECT Reads current user from DB via auth middleware.
# @RELATION DEPENDS_ON -> [Auth.Dependency.GetCurrentUser]
# @RELATION DEPENDS_ON -> [get_current_user]
@router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
@@ -105,7 +105,7 @@ async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
# @POST Token added to blacklist; subsequent requests with same token rejected.
# @SIDE_EFFECT Writes security event LOGOUT; writes to token_blacklist table.
# @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers.
# @RELATION DEPENDS_ON -> [Auth.Dependency.GetCurrentUser]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION CALLS -> [Auth.Jwt.BlacklistToken]
# @TEST_EDGE: already_expired_token -> 200 (idempotent)
@@ -139,7 +139,7 @@ async def logout(
# @BRIEF Initiate ADFS OIDC login flow — redirects user to identity provider.
# @POST Redirects user to ADFS authorization endpoint.
# @SIDE_EFFECT Redirects browser to external OIDC provider.
# @RELATION CALLS -> [Auth.OAuth]
# @RELATION CALLS -> [AuthOauthModule]
@router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request):
@@ -162,7 +162,7 @@ async def login_adfs(request: starlette.requests.Request):
# @POST Provisions user in DB (JIT), creates auth session.
# @SIDE_EFFECT DB write for user provisioning; writes security event LOGIN_ADFS.
# @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers.
# @RELATION CALLS -> [Auth.Service]
# @RELATION CALLS -> [auth_service]
# @TEST_EDGE: adfs_timeout -> 504
# @TEST_EDGE: invalid_state -> 401

View File

@@ -374,20 +374,24 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
from src.api.routes.git_schemas import RepoInitRequest
# #region MockGitService [TYPE Class]
class MockGitService:
def init_repo(self, dashboard_id, remote_url, pat, repo_key, default_branch):
async def init_repo(self, dashboard_id, remote_url, pat, repo_key, default_branch):
self.init_called = True
def _get_repo_path(self, dashboard_id, repo_key):
async def _get_repo_path(self, dashboard_id, repo_key):
return f"/tmp/repos/{repo_key}"
# #endregion MockGitService
async def resolve_dashboard_id(*args, **kwargs):
return 123
async def resolve_repo_key(*args, **kwargs):
return "dashboard-123"
git_service_mock = MockGitService()
monkeypatch.setattr(git_routes, "git_service", git_service_mock)
monkeypatch.setattr(
git_routes, "_resolve_dashboard_id_from_ref", lambda *args, **kwargs: 123
git_routes, "_resolve_dashboard_id_from_ref", resolve_dashboard_id
)
monkeypatch.setattr(
git_routes,
"_resolve_repo_key_from_ref",
lambda *args, **kwargs: "dashboard-123",
resolve_repo_key,
)
existing_config = GitServerConfig(
id="config-1",
@@ -395,7 +399,7 @@ def test_init_repository_initializes_and_saves_binding(monkeypatch):
provider=GitProvider.GITLAB,
url="https://gitlab.com",
pat="token",
default_branch="main",
default_branch="prod",
)
db = DbMock([existing_config])
init_data = RepoInitRequest(

View File

@@ -8,7 +8,8 @@
# @RELATION DEPENDS_ON -> [has_permission]
#
# @INVARIANT All endpoints in this module require 'Admin' role or 'admin' scope.
# @RATIONALE Centralizes user and role management endpoints behind a permission-gated router because RBAC administration requires consistent enforcement of the 'Admin' role across all user/role/mapping operations, preventing privilege escalation through inconsistent authorization checks.
# @REJECTED Distributing admin endpoints across multiple route modules was rejected — it would make it harder to audit that all admin operations enforce the same 'Admin' role check consistently.
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

View File

@@ -115,7 +115,7 @@ async def agent_sqllab_estimate(
# @ingroup Api
# @BRIEF Create a new dashboard in Superset.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/dashboard/.
# @RELATION CALLS -> [SupersetDashboardsWriteMixin.create_dashboard]
# @RELATION CALLS -> [create_dashboard]
@router.post("/dashboards")
async def agent_dashboard_create(
environment_id: str = Query(...),
@@ -146,7 +146,7 @@ async def agent_dashboard_create(
# @ingroup Api
# @BRIEF Deep-copy a dashboard including all charts.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/dashboard/{id}/copy/.
# @RELATION CALLS -> [SupersetDashboardsWriteMixin.copy_dashboard]
# @RELATION CALLS -> [copy_dashboard]
@router.post("/dashboards/{dashboard_id}/copy")
async def agent_dashboard_copy(
dashboard_id: int,
@@ -166,7 +166,7 @@ async def agent_dashboard_copy(
# @ingroup Api
# @BRIEF Update an existing dashboard's properties.
# @SIDE_EFFECT HTTP PUT to Superset /api/v1/dashboard/{id}.
# @RELATION CALLS -> [SupersetDashboardsWriteMixin.update_dashboard]
# @RELATION CALLS -> [update_dashboard]
@router.put("/dashboards/{dashboard_id}")
async def agent_dashboard_update(
dashboard_id: int,
@@ -201,7 +201,7 @@ async def agent_dashboard_update(
# @ingroup Api
# @BRIEF Create a new dataset in Superset.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/dataset/.
# @RELATION CALLS -> [SupersetClient.CreateDataset]
# @RELATION CALLS -> [SupersetClientCreateDataset]
@router.post("/datasets")
async def agent_dataset_create(
environment_id: str = Query(...),
@@ -228,7 +228,7 @@ async def agent_dataset_create(
# @ingroup Api
# @BRIEF Delete a dataset from Superset by ID.
# @SIDE_EFFECT HTTP DELETE to Superset /api/v1/dataset/{id}.
# @RELATION CALLS -> [SupersetClient.DeleteDataset]
# @RELATION CALLS -> [SupersetClientDeleteDataset]
@router.delete("/datasets/{dataset_id}")
async def agent_dataset_delete(
dataset_id: int,
@@ -247,7 +247,7 @@ async def agent_dataset_delete(
# @ingroup Api
# @BRIEF Duplicate a dataset including columns and metrics.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/dataset/duplicate.
# @RELATION CALLS -> [SupersetClient.DuplicateDataset]
# @RELATION CALLS -> [SupersetClientDuplicateDataset]
@router.post("/datasets/{dataset_id}/duplicate")
async def agent_dataset_duplicate(
dataset_id: int,
@@ -267,7 +267,7 @@ async def agent_dataset_duplicate(
# @ingroup Api
# @BRIEF Rescan columns and types for a dataset from its source database.
# @SIDE_EFFECT HTTP PUT to Superset /api/v1/dataset/{id}/refresh.
# @RELATION CALLS -> [SupersetClient.RefreshDatasetSchema]
# @RELATION CALLS -> [SupersetClientRefreshDatasetSchema]
@router.post("/datasets/{dataset_id}/refresh")
async def agent_dataset_refresh(
dataset_id: int,

View File

@@ -33,7 +33,7 @@ async def _get_superset_client(environment_id: str) -> SupersetClient:
# @ingroup Api
# @BRIEF List all schemas for a database.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/database/{id}/schemas/.
# @RELATION CALLS -> [SupersetDatabasesMixin.get_database_schemas]
# @RELATION CALLS -> [SupersetClientGetDatabaseSchemas]
@router.get("/databases/{database_id}/schemas")
async def agent_database_schemas(
database_id: int,
@@ -52,7 +52,7 @@ async def agent_database_schemas(
# @ingroup Api
# @BRIEF List tables/views for a database schema.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/database/{id}/tables/.
# @RELATION CALLS -> [SupersetDatabasesMixin.get_database_tables]
# @RELATION CALLS -> [SupersetClientGetDatabaseTables]
@router.get("/databases/{database_id}/tables")
async def agent_database_tables(
database_id: int,
@@ -72,7 +72,7 @@ async def agent_database_tables(
# @ingroup Api
# @BRIEF Get table metadata: columns, types, indexes, primary keys.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/database/{id}/table_metadata/.
# @RELATION CALLS -> [SupersetDatabasesMixin.get_database_table_metadata]
# @RELATION CALLS -> [SupersetClientGetTableMetadata]
@router.get("/databases/{database_id}/table_metadata")
async def agent_database_table_metadata(
database_id: int,
@@ -97,7 +97,7 @@ async def agent_database_table_metadata(
# @ingroup Api
# @BRIEF Generate a SELECT * query template for a table.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/database/{id}/select_star/.
# @RELATION CALLS -> [SupersetDatabasesMixin.get_database_select_star]
# @RELATION CALLS -> [SupersetClientGetSelectStar]
@router.get("/databases/{database_id}/select_star")
async def agent_database_select_star(
database_id: int,
@@ -123,7 +123,7 @@ async def agent_database_select_star(
# @ingroup Api
# @BRIEF Validate SQL syntax for a database without executing.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/database/{id}/validate_sql/.
# @RELATION CALLS -> [SupersetDatabasesMixin.validate_sql]
# @RELATION CALLS -> [SupersetClientValidateSql]
@router.post("/databases/{database_id}/validate_sql")
async def agent_database_validate_sql(
database_id: int,
@@ -144,7 +144,7 @@ async def agent_database_validate_sql(
# @ingroup Api
# @BRIEF Test a database connection URI without creating a database entry.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/database/test_connection/.
# @RELATION CALLS -> [SupersetDatabasesMixin.test_database_connection]
# @RELATION CALLS -> [SupersetClientTestDatabaseConnection]
@router.post("/databases/test_connection")
async def agent_database_test_connection(
environment_id: str = Query(...),
@@ -173,7 +173,7 @@ async def agent_database_test_connection(
# @ingroup Api
# @BRIEF Audit access rights: user × dashboard × dataset × RLS permission matrix.
# @SIDE_EFFECT Multiple HTTP GETs to Superset security/dashboard/dataset APIs.
# @RELATION CALLS -> [SupersetAuditMixin.permissions_audit]
# @RELATION CALLS -> [SupersetAudit.PermissionsAudit]
@router.get("/audit/permissions")
async def agent_audit_permissions(
environment_id: str = Query(...),
@@ -204,7 +204,7 @@ async def agent_audit_permissions(
# @ingroup Api
# @BRIEF List all saved SQL queries.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/saved_query/.
# @RELATION CALLS -> [SupersetSavedQueriesMixin.get_saved_queries]
# @RELATION CALLS -> [SupersetSavedQueries.List]
@router.get("/saved_queries")
async def agent_saved_query_list(
environment_id: str = Query(...),
@@ -223,7 +223,7 @@ async def agent_saved_query_list(
# @ingroup Api
# @BRIEF Get a saved SQL query by ID.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/saved_query/{id}.
# @RELATION CALLS -> [SupersetSavedQueriesMixin.get_saved_query]
# @RELATION CALLS -> [SupersetSavedQueries.Get]
@router.get("/saved_queries/{query_id}")
async def agent_saved_query_get(
query_id: int,

View File

@@ -6,6 +6,8 @@
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @RELATION DEPENDS_ON -> [AssistantHistory]
# @INVARIANT Audit endpoint requires tasks:READ permission.
# @RATIONALE Admin operations are separated from user-facing assistant routes to enforce strict permission boundaries. The audit endpoint explicitly requires tasks:READ permission, preventing non-admin users from accessing other users' conversation histories and audit decisions.
# @REJECTED Embedding admin endpoints in the main AssistantRoutes module was rejected — it mixes user-facing and admin logic, making permission enforcement fragile and risking privilege escalation through route inclusion order or misconfigured RBAC.
from __future__ import annotations

View File

@@ -4,6 +4,8 @@
# @LAYER API
# @RELATION DEPENDS_ON -> [AssistantResolvers]
# @INVARIANT Every return path includes domain, operation, entities, confidence, risk_level, requires_confirmation.
# @RATIONALE A deterministic regex-based parser is kept as a cold-path fallback because LLM-based intent planning can fail (provider down, API error, ambiguous input). The parser covers 15+ frequent command patterns in RU/EN, ensuring core assistant functionality (status, deploy, migration, backup, help) works without any ML dependency — sub-millisecond response for common patterns.
# @REJECTED Relying solely on LLM intent planning was rejected — LLM providers can be unavailable, slow, or costly for trivial commands ("help", "status"). Full NLP-based parsing (spaCy/BERT) was rejected — it introduces non-deterministic behavior, model downloads, and GPU requirements incompatible with a lightweight fallback.
from __future__ import annotations
@@ -25,6 +27,8 @@ from ._resolvers import _extract_id, _is_production_env
# @PRE message contains raw user text and config manager resolves environments.
# @POST Returns intent dict with domain/operation/entities/confidence/risk fields.
# @INVARIANT every return path includes domain, operation, entities, confidence, risk_level, requires_confirmation.
# @RATIONALE Direct substring matching (not NLP) guarantees deterministic, auditable behavior across all known command patterns. Risk_level is pre-computed per operation+environment combination so the caller doesn't need domain-specific deployment knowledge to set safety gates.
# @REJECTED Regex-free full-text ML matching was rejected — it would make the parser non-deterministic and fragile to input variations. Computing risk_level dynamically in the caller was rejected — it would duplicate environment-awareness logic and risk inconsistent safety gates across callers.
def _parse_command(message: str, config_manager: ConfigManager) -> dict[str, Any]:
with belief_scope('_parse_command'):
logger.reason('Belief protocol reasoning checkpoint for _parse_command')

View File

@@ -6,6 +6,8 @@
# @RELATION DEPENDS_ON -> [DatasetReviewOrchestrator]
# @RELATION DEPENDS_ON -> [AssistantSchemas]
# @INVARIANT Dataset review dispatch requires valid session version for write operations.
# @RATIONALE Dataset review operations require optimistic-lock session version validation — the assistant cannot safely mutate session state without verifying the version hasn't changed. This module encapsulates version check + dispatch, ensuring all assistant-triggered dataset review mutations go through the same validation path as direct API calls.
# @REJECTED Direct assistant-to-repository calls without this dispatch layer were rejected — they would bypass optimistic-lock checking and allow stale-session mutations, causing silent concurrent-modification data loss. Embedding dispatch logic in the main AssistantDispatch module was rejected — it would couple generic assistant dispatch with domain-specific dataset review versioning concerns.
from __future__ import annotations
@@ -39,6 +41,7 @@ from ._schemas import (
# #region _dataset_review_conflict_http_exception [C:2] [TYPE Function]
# @ingroup AssistantApi
# @BRIEF Convert dataset-review optimistic-lock conflicts into shared 409 assistant semantics.
def _dataset_review_conflict_http_exception(
exc: DatasetReviewSessionVersionConflictError,
@@ -64,6 +67,8 @@ def _dataset_review_conflict_http_exception(
# @PRE context contains valid session data and user intent.
# @POST Returns a structured response with planned actions and confirmations.
# @SIDE_EFFECT May update session state and enqueue tasks.
# @RATIONALE Writes are gated by optimistic-lock session version to prevent concurrent modification conflicts between assistant and UI. SQL preview generation delegates to DatasetReviewOrchestrator.PreparePreviewCommand to ensure identical behavior regardless of trigger source.
# @REJECTED Allowing assistant operations to bypass session version checking was rejected — concurrent UI and assistant interactions would silently overwrite each other's changes. Inlining orchestrator preview logic (instead of delegating to PreparePreviewCommand) was rejected — it would diverge from the canonical orchestration path and create two code paths for the same operation.
async def _dispatch_dataset_review_intent(
intent: dict[str, Any],
current_user: User,

View File

@@ -10,6 +10,8 @@
# @INVARIANT Tool catalog is filtered by user permissions before being sent to LLM.
# @SIDE_EFFECT Filters tool catalog by user permissions
# @DATA_CONTRACT UserPermissions -> ToolCatalog
# @RATIONALE Tool catalog must be filtered by user permissions BEFORE being sent to the LLM — the LLM will suggest tools it sees, regardless of whether the user can execute them. Pre-filtering prevents the LLM from proposing unauthorized operations, eliminating confusing "I can't do that" responses for actions the LLM just suggested.
# @REJECTED Post-filtering (showing all tools then rejecting unauthorized intents after parsing) was rejected — it wastes LLM tokens generating intents for inaccessible operations and creates confusing UX where the assistant proposes an action then refuses. Permission-checking only at execution time (without catalog filtering) was rejected — it surfaces authorization errors only after the user has confirmed a risky operation, wasting the user's time.
from __future__ import annotations
@@ -28,6 +30,7 @@ from ._tool_registry import (
# #region _build_tool_catalog [C:2] [TYPE Function]
# @ingroup AssistantApi
# @BRIEF Build tool catalog from the central registry, appending dataset-review tools when a session is active.
# @PRE current_user is authenticated; config/db are available.
# @POST Returns list of executable tools filtered by permission and runtime availability.
@@ -104,6 +107,7 @@ def _build_tool_catalog(
# #region _coerce_intent_entities [C:2] [TYPE Function]
# @ingroup AssistantApi
# @BRIEF Normalize intent entity value types from LLM output to route-compatible values.
# @PRE intent contains entities dict or missing entities.
# @POST Returned intent has numeric ids coerced where possible and string values stripped.

View File

@@ -9,6 +9,8 @@
# @INVARIANT Production deployments always require confirmation.
# @SIDE_EFFECT Registers intent planning routes
# @DATA_CONTRACT UserIntent -> PlannedAction
# @RATIONALE Separating intent planning from authorization ensures the permission boundary is independently verifiable. The planner proposes actions without knowing permission rules; a separate authorization gate rejects unauthorized intents. This prevents the LLM from self-authorizing through prompt manipulation and keeps permission logic auditable in a single location.
# @REJECTED Merging authorization into AssistantLlmPlanner was rejected — it mixes logical concerns and creates a single point of failure where a bug in the planner could bypass all permission checks. Entrusting the LLM with authorization (via system prompt constraints) was rejected — LLMs cannot be reliably trusted to enforce security boundaries; deterministic code must gate all permission decisions.
from __future__ import annotations
@@ -39,6 +41,7 @@ from ._tool_registry import get_permission_checks
# #region _plan_intent_with_llm [C:2] [TYPE Function]
# @ingroup AssistantApi
# @BRIEF Use active LLM provider to select best tool/operation from dynamic catalog.
# @PRE tools list contains allowed operations for current user.
# @POST Returns normalized intent dict when planning succeeds; otherwise None.
@@ -164,6 +167,7 @@ async def _plan_intent_with_llm(
# #region _authorize_intent [C:2] [TYPE Function]
# @ingroup AssistantApi
# @BRIEF Validate user permissions for parsed intent before confirmation/dispatch.
# @PRE intent.operation is present for known assistant command domains.
# @POST Returns if authorized; raises HTTPException(403) when denied.

View File

@@ -10,6 +10,8 @@
# @RELATION DEPENDS_ON -> [AssistantDispatch]
# @RELATION DISPATCHES -> [AssistantAdminRoutes]
# @INVARIANT Risky operations are never executed without valid confirmation token.
# @RATIONALE Confirmation gating is required because the assistant executes arbitrary operations (including destructive ones) based on natural language parsing. The separation of intention detection → authorization → confirmation → execution prevents accidental or malicious operations triggered by misparsed user input.
# @REJECTED Auto-executing all parsed intents without confirmation was rejected — the assistant parses natural language which frequently misinterprets ambiguous user intent; requiring explicit user confirmation for non-safe operations prevents unintended destructive actions and provides an audit trail of user consent.
from __future__ import annotations
@@ -79,6 +81,8 @@ router = APIRouter(tags=["Assistant"])
# @PRE Authenticated user is available and message text is non-empty.
# @POST Response state is one of clarification/confirmation/started/success/denied/failed.
# @INVARIANT non-safe operations are gated with confirmation before execution from this endpoint.
# @RATIONALE Two-phase intent resolution — LLM first handles ambiguous/natural phrasing, then deterministic parser catches known commands the LLM might mis-identify. This maximizes coverage: LLM handles novel phrasings, deterministic parser ensures known operations are always recognized regardless of LLM quality.
# @REJECTED LLM-only parsing was rejected — LLM hallucinates tool names and parameters on ambiguous input; the deterministic fallback is essential for reliability. Deterministic-only parsing was rejected — it fails on natural language variations, forcing users into rigid command syntax.
async def send_message(request: AssistantMessageRequest, current_user: User=Depends(get_current_user), task_manager: TaskManager=Depends(get_task_manager), config_manager: ConfigManager=Depends(get_config_manager), db: Session=Depends(get_db)):
with belief_scope('send_message'):
logger.reason('Belief protocol reasoning checkpoint for send_message')

View File

@@ -53,7 +53,7 @@ async def handle_create_branch(
raise HTTPException(
status_code=422, detail="Missing dashboard_id/dashboard_ref or branch_name"
)
_get_git_service().create_branch(dashboard_id, branch_name, "main")
await _get_git_service().create_branch(dashboard_id, branch_name, "dev")
return (f"Ветка `{branch_name}` создана для дашборда {dashboard_id}.", None, [])

View File

@@ -31,15 +31,15 @@ from ._gitea_routes import create_gitea_repository, create_remote_repository, de
from ._helpers import _resolve_dashboard_id_from_ref, _resolve_dashboard_id_from_ref_async, _resolve_repo_key_from_ref # noqa: F401 — re-exported for test monkeypatch compatibility
# -- Merge routes --
from ._merge_routes import abort_merge, continue_merge, get_merge_conflicts, get_merge_status, resolve_merge_conflicts # noqa: F401
from ._merge_routes import abort_merge, continue_merge, get_merge_conflicts, get_merge_status, merge_branch, resolve_merge_conflicts # noqa: F401
# -- Repo lifecycle routes (sync, promote, deploy) --
from ._repo_lifecycle_routes import deploy_dashboard, promote_dashboard, sync_dashboard # noqa: F401
# -- Repo operations routes (commit, push, pull, status, diff, history, generate-message) --
from ._repo_operations_routes import commit_changes, generate_commit_message, get_history, get_repository_diff, get_repository_status, get_repository_status_batch, pull_changes, push_changes # noqa: F401
from ._repo_operations_routes import commit_changes, generate_commit_message, get_history, get_repository_diff, get_repository_status, get_repository_status_batch, pull_changes, push_changes, rollback_commit # noqa: F401
# -- Repo routes (core) --
from ._repo_routes import checkout_branch, create_branch, delete_repository, get_branches, get_repository_binding, init_repository # noqa: F401
from ._repo_routes import checkout_branch, create_branch, delete_branch, delete_repository, get_branch_protection_rules, get_branches, get_repository_binding, init_repository # noqa: F401
# #endregion GitPackage

View File

@@ -37,6 +37,10 @@ def _build_no_repo_status_payload() -> dict:
"is_diverged": False,
"sync_state": "NO_REPO",
"sync_status": "NO_REPO",
"last_commit_hash": None,
"last_commit_message": None,
"last_commit_author": None,
"last_commit_date": None,
"has_repo": False,
}
# #endregion _build_no_repo_status_payload

View File

@@ -7,6 +7,8 @@
from fastapi import Depends, HTTPException
from src.api.routes.git_schemas import (
MergeBranchRequest,
MergeBranchResponse,
MergeConflictFileSchema,
MergeContinueRequest,
MergeResolveRequest,
@@ -163,4 +165,43 @@ async def continue_merge(
except Exception as e:
_handle_unexpected_git_route_error("continue_merge", e)
# #endregion continue_merge
# #region merge_branch [C:3] [TYPE Function] [SEMANTICS git,merge,branch,api,feature,hotfix]
# @ingroup Api
# @BRIEF Merge source branch into target branch with conflict detection and optional auto-delete.
# @RELATION CALLS -> [GitService]
# @RATIONALE Separate from promote_direct_merge which is for GitFlow stage promotion.
# This endpoint is designed for feature/hotfix branch merge workflows.
@router.post(
"/repositories/{dashboard_ref}/merge",
response_model=MergeBranchResponse,
)
async def merge_branch(
dashboard_ref: str,
merge_data: MergeBranchRequest,
env_id: str | None = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("merge_branch"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
return await _gs.merge_branch(
dashboard_id,
merge_data.source_branch,
merge_data.target_branch,
merge_data.message,
merge_data.auto_delete_source,
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("merge_branch", e)
# #endregion merge_branch
# #endregion GitMergeRoutes

View File

@@ -80,110 +80,116 @@ async def promote_dashboard(
_gs = get_git_service()
with belief_scope("promote_dashboard"):
from . import _resolve_dashboard_id_from_ref
from ._helpers import _handle_unexpected_git_route_error
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if not db_repo:
raise HTTPException(
status_code=404,
detail=f"Repository for dashboard {dashboard_ref} is not initialized",
try:
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
config = _get_git_config_or_404(db, db_repo.config_id)
from_branch = payload.from_branch.strip()
to_branch = payload.to_branch.strip()
if not from_branch or not to_branch:
raise HTTPException(
status_code=400, detail="from_branch and to_branch are required"
db_repo = (
db.query(GitRepository)
.filter(GitRepository.dashboard_id == dashboard_id)
.first()
)
if from_branch == to_branch:
raise HTTPException(
status_code=400, detail="from_branch and to_branch must be different"
)
mode = (payload.mode or "mr").strip().lower()
if mode == "direct":
reason = (payload.reason or "").strip()
if not reason:
if not db_repo:
raise HTTPException(
status_code=400, detail="Direct promote requires non-empty reason"
status_code=404,
detail=f"Repository for dashboard {dashboard_ref} is not initialized",
)
logger.warning(
"[promote_dashboard][PolicyViolation] Direct promote without MR by actor=unknown dashboard_ref=%s from=%s to=%s reason=%s",
dashboard_ref,
from_branch,
to_branch,
reason,
)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
result = await _gs.promote_direct_merge(
dashboard_id=dashboard_id,
from_branch=from_branch,
to_branch=to_branch,
)
config = _get_git_config_or_404(db, db_repo.config_id)
from_branch = payload.from_branch.strip()
to_branch = payload.to_branch.strip()
if not from_branch or not to_branch:
raise HTTPException(
status_code=400, detail="from_branch and to_branch are required"
)
if from_branch == to_branch:
raise HTTPException(
status_code=400, detail="from_branch and to_branch must be different"
)
mode = (payload.mode or "mr").strip().lower()
if mode == "direct":
reason = (payload.reason or "").strip()
if not reason:
raise HTTPException(
status_code=400, detail="Direct promote requires non-empty reason"
)
logger.warning(
"[promote_dashboard][PolicyViolation] Direct promote without MR by actor=unknown dashboard_ref=%s from=%s to=%s reason=%s",
dashboard_ref,
from_branch,
to_branch,
reason,
)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
result = await _gs.promote_direct_merge(
dashboard_id=dashboard_id,
from_branch=from_branch,
to_branch=to_branch,
)
return PromoteResponse(
mode="direct",
from_branch=from_branch,
to_branch=to_branch,
status=result.get("status", "merged"),
policy_violation=True,
)
title = (payload.title or "").strip() or f"Promote {from_branch} -> {to_branch}"
description = payload.description
if config.provider == GitProvider.GITEA:
pr = await _gs.create_gitea_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
)
elif config.provider == GitProvider.GITHUB:
pr = await _gs.create_github_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
draft=payload.draft,
)
elif config.provider == GitProvider.GITLAB:
pr = await _gs.create_gitlab_merge_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
remove_source_branch=payload.remove_source_branch,
)
else:
raise HTTPException(
status_code=501,
detail=f"Provider {config.provider} does not support promotion API",
)
return PromoteResponse(
mode="direct",
mode="mr",
from_branch=from_branch,
to_branch=to_branch,
status=result.get("status", "merged"),
policy_violation=True,
status=pr.get("status", "opened"),
url=pr.get("url"),
reference_id=str(pr.get("id")) if pr.get("id") is not None else None,
policy_violation=False,
)
title = (payload.title or "").strip() or f"Promote {from_branch} -> {to_branch}"
description = payload.description
if config.provider == GitProvider.GITEA:
pr = await _gs.create_gitea_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
)
elif config.provider == GitProvider.GITHUB:
pr = await _gs.create_github_pull_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
draft=payload.draft,
)
elif config.provider == GitProvider.GITLAB:
pr = await _gs.create_gitlab_merge_request(
server_url=config.url,
pat=config.pat,
remote_url=db_repo.remote_url,
from_branch=from_branch,
to_branch=to_branch,
title=title,
description=description,
remove_source_branch=payload.remove_source_branch,
)
else:
raise HTTPException(
status_code=501,
detail=f"Provider {config.provider} does not support promotion API",
)
return PromoteResponse(
mode="mr",
from_branch=from_branch,
to_branch=to_branch,
status=pr.get("status", "opened"),
url=pr.get("url"),
reference_id=str(pr.get("id")) if pr.get("id") is not None else None,
policy_violation=False,
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("promote_dashboard", e)
# #endregion promote_dashboard

View File

@@ -12,6 +12,7 @@ from src.api.routes.git_schemas import (
CommitSchema,
RepoStatusBatchRequest,
RepoStatusBatchResponse,
RollbackCommitRequest,
)
from src.core.database import get_db
from src.core.logger import belief_scope, logger
@@ -62,6 +63,40 @@ async def commit_changes(
# #endregion commit_changes
# #region rollback_commit [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Roll back a commit by creating a revert commit in the dashboard repository.
@router.post("/repositories/{dashboard_ref}/rollback")
async def rollback_commit(
dashboard_ref: str,
rollback_data: RollbackCommitRequest,
env_id: str | None = None,
config_manager=Depends(get_config_manager),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("rollback_commit"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
await _apply_git_identity_from_profile(dashboard_id, db, current_user)
return await _gs.rollback_commit(
dashboard_id,
rollback_data.commit_hash,
rollback_data.reason,
)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("rollback_commit", e)
# #endregion rollback_commit
# #region push_changes [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Push local commits to the remote repository.

View File

@@ -11,6 +11,7 @@ from src.api.routes.git_schemas import (
BranchCheckout,
BranchCreate,
BranchSchema,
DeleteBranchRequest,
RepoInitRequest,
RepositoryBindingSchema,
)
@@ -201,6 +202,32 @@ async def get_branches(
# #endregion get_branches
# #region get_branch_protection_rules [C:2] [TYPE Function] [SEMANTICS git,branch,protection,api]
# @ingroup Api
# @BRIEF Return branch protection rules for environment branches.
@router.get("/repositories/{dashboard_ref}/branches/protection")
async def get_branch_protection_rules(
dashboard_ref: str,
env_id: str | None = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
with belief_scope("get_branch_protection_rules"):
from . import _resolve_dashboard_id_from_ref
try:
await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
_gs = get_git_service()
return _gs.get_branch_protection_rules()
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("get_branch_protection_rules", e)
# #endregion get_branch_protection_rules
# #region create_branch [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Create a new branch in the dashboard's repository.
@@ -260,4 +287,36 @@ async def checkout_branch(
except Exception as e:
_handle_unexpected_git_route_error("checkout_branch", e)
# #endregion checkout_branch
# #region delete_branch [C:3] [TYPE Function] [SEMANTICS git,branch,delete,api]
# @ingroup Api
# @BRIEF Delete a branch (local and remote) with protection for environment branches.
# @RELATION CALLS -> [GitService]
# @RATIONALE Environment branches (dev/preprod/prod) are protected from accidental deletion
# unless force=True is specified (for automated cleanup after merge).
@router.delete("/repositories/{dashboard_ref}/branches/{branch_name}")
async def delete_branch(
dashboard_ref: str,
branch_name: str,
delete_data: DeleteBranchRequest | None = None,
env_id: str | None = None,
config_manager=Depends(get_config_manager),
_=Depends(has_permission("plugin:git", "EXECUTE")),
):
_gs = get_git_service()
with belief_scope("delete_branch"):
from . import _resolve_dashboard_id_from_ref
try:
dashboard_id = await _resolve_dashboard_id_from_ref(
dashboard_ref, config_manager, env_id
)
force = delete_data.force if delete_data else False
return await _gs.delete_branch(dashboard_id, branch_name, force=force)
except HTTPException:
raise
except Exception as e:
_handle_unexpected_git_route_error("delete_branch", e)
# #endregion delete_branch
# #endregion GitRepoRoutes

View File

@@ -7,6 +7,7 @@
# @INVARIANT All schemas must be compatible with the FastAPI router.
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
@@ -23,7 +24,7 @@ class GitServerConfigBase(BaseModel):
pat: str = Field(..., description="Personal Access Token")
pat: str = Field(..., description="Personal Access Token")
default_repository: str | None = Field(None, description="Default repository path (org/repo)")
default_branch: str | None = Field("main", description="Default branch logic/name")
default_branch: str | None = Field("prod", description="Default branch logic/name")
# #endregion GitServerConfigBase
# #region GitServerConfigUpdate [TYPE Class]
@@ -124,6 +125,16 @@ class CommitCreate(BaseModel):
files: list[str]
# #endregion CommitCreate
# #region RollbackCommitRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for reverting a dashboard repository commit.
class RollbackCommitRequest(BaseModel):
"""Schema for rollback-by-revert requests."""
commit_hash: str
reason: str | None = None
# #endregion RollbackCommitRequest
# #region ConflictResolution [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema for resolving merge conflicts.
@@ -254,7 +265,7 @@ class GiteaRepoCreateRequest(BaseModel):
private: bool = True
description: str | None = None
auto_init: bool = True
default_branch: str | None = "main"
default_branch: str | None = "prod"
# #endregion GiteaRepoCreateRequest
@@ -281,7 +292,7 @@ class RemoteRepoCreateRequest(BaseModel):
private: bool = True
description: str | None = None
auto_init: bool = True
default_branch: str | None = "main"
default_branch: str | None = "prod"
# #endregion RemoteRepoCreateRequest
@@ -313,4 +324,74 @@ class PromoteResponse(BaseModel):
policy_violation: bool = False
# #endregion PromoteResponse
# #region MergeBranchRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for merging one branch into another.
class MergeBranchRequest(BaseModel):
source_branch: str = Field(..., min_length=1, max_length=255, description="Source branch to merge from")
target_branch: str = Field(..., min_length=1, max_length=255, description="Target branch to merge into")
message: str | None = Field(None, description="Optional merge commit message")
auto_delete_source: bool = Field(False, description="Delete source branch after successful merge")
# #endregion MergeBranchRequest
# #region MergeBranchResponse [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Response schema for merge branch operation result.
class MergeBranchResponse(BaseModel):
source_branch: str
target_branch: str
status: str = Field(..., description="success, conflicts, already_merged, or error")
commit_hash: str | None = None
conflicts: list[str] = Field(default_factory=list, description="List of conflicted file paths")
error_message: str | None = None
source_deleted: bool = False
# #endregion MergeBranchResponse
# #region DeleteBranchRequest [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Request schema for branch deletion.
class DeleteBranchRequest(BaseModel):
force: bool = Field(False, description="Force deletion bypassing protection rules")
# #endregion DeleteBranchRequest
# #region BranchTypeEnum [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Enum-like classification for branch types used in grouping.
class BranchTypeEnum(StrEnum):
ENVIRONMENT = "environment"
FEATURE = "feature"
HOTFIX = "hotfix"
BUGFIX = "bugfix"
LEGACY = "legacy"
REMOTE_REF = "remote_ref"
OTHER = "other"
# #endregion BranchTypeEnum
# #region BranchSchemaExtended [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Extended branch schema with branch_type for UI grouping.
class BranchSchemaExtended(BranchSchema):
branch_type: BranchTypeEnum = Field(default=BranchTypeEnum.OTHER, description="Classification for UI grouping")
# #endregion BranchSchemaExtended
# #region BranchProtectionRule [TYPE Class]
# @defgroup Api Module group.
# @BRIEF Schema representing a branch protection rule.
class BranchProtectionRule(BaseModel):
branch_name: str
require_mr: bool = False
require_review: bool = False
allow_force_push: bool = False
allow_direct_delete: bool = False
# #endregion BranchProtectionRule
# #endregion GitSchemas

View File

@@ -21,6 +21,8 @@
# @TEST_EDGE [invalid_type] ->[validation_error]
# @TEST_EDGE [external_fail] ->[HTTP_500]
# @TEST_INVARIANT [EnvironmentValidationBeforeAction] -> VERIFIED_BY: [invalid_environment, valid_execution]
# @RATIONALE Separates API concerns (routing, permission checks, request/response serialization) from core migration business logic, enabling independent evolution of HTTP contract and domain logic.
# @REJECTED Embedding API logic directly in the core migration layer was rejected — it would couple HTTP concerns with business logic, making both layers harder to test, version, and maintain independently.
from typing import Any, cast
@@ -83,6 +85,8 @@ async def get_dashboards(
# @RELATION CALLS -> [create_task]
# @RELATION DEPENDS_ON -> [DashboardSelection]
# @INVARIANT Migration task dispatch never occurs before source and target environment ids pass guard validation.
# @RATIONALE Delegates to the async task manager to avoid blocking the HTTP request thread during potentially long-running migration operations that may involve many dashboards.
# @REJECTED Synchronous execution within the HTTP request handler was rejected — it would block the worker process and cause client timeouts for large migrations.
@router.post("/migration/execute")
async def execute_migration(
selection: DashboardSelection,

View File

@@ -11,6 +11,8 @@
# @POST Router is configured and endpoints are ready for registration.
# @SIDE_EFFECT None
# @DATA_CONTRACT [ReportQuery] -> [ReportCollection | ReportDetailView]
# @RATIONALE Provides a unified read-only query layer across heterogeneous task types (migration, translation, clean release) because each task system stores result data differently — the reports module normalizes these into a consistent API surface for the frontend table view.
# @REJECTED Building separate report endpoints per task system (migration reports, translation reports, clean-release reports) was rejected — it would force the frontend to orchestrate multiple API calls and merge inconsistent payload shapes.
from datetime import datetime

View File

@@ -13,7 +13,8 @@
# @DATA_CONTRACT Input -> ConfigUpdateRequest, Output -> AppConfig, LoggingConfigResponse
# @INVARIANT All settings changes must be persisted via ConfigManager.
# @PUBLIC_API router
# @RATIONALE Consolidates all application settings, environment, logging, storage, connection, and automation endpoints into one module because settings management requires consistent cross-cutting concerns: password masking, connection validation, and permission checks that would be duplicated if spread across multiple modules.
# @REJECTED Splitting settings into per-domain route modules (environments.py, logging.py, connections.py) was rejected — it would duplicate the password-masking and connection-validation patterns across files, creating inconsistency risk.
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
@@ -72,6 +73,7 @@ def _normalize_superset_env_url(raw_url: str) -> str:
# #region _validate_superset_connection_fast [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Run lightweight Superset connectivity validation without full pagination scan.
# @PRE env contains valid URL and credentials.
# @POST Raises on auth/API failures; returns None on success.
@@ -762,6 +764,7 @@ def _get_connection_service(config_manager: ConfigManager = Depends(get_config_m
# #region list_connections [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF List all database connections with masked passwords.
@router.get("/connections")
async def list_connections(
@@ -776,6 +779,7 @@ async def list_connections(
# #region get_connection [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Get a single connection by id. Password is masked in response.
@router.get("/connections/{connection_id}")
async def get_connection(
@@ -797,6 +801,7 @@ async def get_connection(
# #region create_connection [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Create a new database connection. Password is encrypted before storage.
@router.post("/connections", status_code=201)
async def create_connection(
@@ -820,6 +825,7 @@ async def create_connection(
# #region update_connection [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Update an existing connection. Empty/masked password = keep existing.
@router.put("/connections/{connection_id}")
async def update_connection(
@@ -844,6 +850,7 @@ async def update_connection(
# #region delete_connection [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Delete a connection. Blocked if referenced by active TranslationJobs.
@router.delete("/connections/{connection_id}")
async def delete_connection(
@@ -869,6 +876,7 @@ async def delete_connection(
# #region test_connection [C:2] [TYPE Function]
# @ingroup Api
# @BRIEF Test connectivity to a saved connection. Runs SELECT 1 and returns latency + version or error.
@router.post("/connections/{connection_id}/test")
async def test_connection(

View File

@@ -7,6 +7,8 @@
# @RELATION DEPENDS_ON -> [StoragePlugin]
#
# @INVARIANT All paths must be validated against path traversal.
# @RATIONALE Provides a plugin-backed file management API (list, upload, download, delete) because storage operations require consistent path traversal protection, permission checks, and plugin lifecycle management that would be unsafe to implement ad-hoc in each caller.
# @REJECTED Direct filesystem access from frontend or other API routes was rejected — it would bypass the StoragePlugin's path traversal validation and permission layer, creating a security surface for arbitrary file access.
from pathlib import Path

View File

@@ -10,6 +10,11 @@
# @SIDE_EFFECT Starts background scheduler and binds network ports for HTTP/WS traffic.
# @DATA_CONTRACT [HTTP Request | WS Message] -> [HTTP Response | JSON Log Stream]
# @RATIONALE Duplicate @RELATION and @INVARIANT lines removed from header. Import sorting unified via ruff isort (I) rule across src/ — 139 fixes applied.
# @REJECTED Monolithic route registration in app.py was rejected — route groups are now modular
# in separate router modules under api/routes/ to keep AppModule focused on middleware,
# lifespan, and WebSocket endpoint wiring. Embedding lifespan logic directly in module
# scope was rejected — the asynccontextmanager pattern ensures clean startup/shutdown
# lifecycle without module-level race conditions.
import asyncio
from contextlib import asynccontextmanager
@@ -384,7 +389,7 @@ async def log_requests(request: Request, call_next):
# @BRIEF Register all FastAPI route groups exposed by the application entrypoint.
# @RELATION DEPENDS_ON -> [FastAPI_App]
# @RELATION DEPENDS_ON -> [Route_Group_Contracts]
# @RELATION DEPENDS_ON -> [AuthApi]
# @RELATION DEPENDS_ON -> [Api.Auth]
# @RELATION DEPENDS_ON -> [AdminApi]
# @RELATION DEPENDS_ON -> [PluginsRouter]
# @RELATION DEPENDS_ON -> [TasksRouter]
@@ -432,7 +437,7 @@ app.include_router(maintenance.maintenance_router)
# @BRIEF Authenticate a WebSocket connection via JWT or API key from query param `token`.
# @PRE websocket is a live Starlette WebSocket before accept().
# @POST Returns True if token is valid, logs reason; returns False if rejected.
# @RELATION DEPENDS_ON -> [AuthJwtModule]
# @RELATION DEPENDS_ON -> [Auth.Jwt]
# @RELATION DEPENDS_ON -> [APIKeyModel]
# @SIDE_EFFECT Performs DB read to validate API key hash.
async def _authenticate_websocket(websocket: WebSocket, endpoint_name: str) -> bool:
@@ -506,6 +511,14 @@ async def _authenticate_websocket(websocket: WebSocket, endpoint_name: str) -> b
# @DATA_CONTRACT [task_id: str, source: str, level: str] -> [JSON log entry objects]
# @INVARIANT Every accepted WebSocket subscription is unsubscribed exactly once even when streaming fails or the client disconnects.
# @UX_STATE Connecting -> Streaming -> (Disconnected)
# @RATIONALE Uses asyncio.wait with two queues (log + status) to multiplex task logs and status
# updates over a single WebSocket connection — eliminates the need for the client to
# open a second socket or poll /api/tasks for status changes. Server-side source and
# level filters reduce bandwidth by discarding irrelevant log entries before transmission.
# @REJECTED Separate WebSocket connections for logs vs status was rejected — doubles connection
# overhead and complicates client-side synchronization. Client-side filtering was rejected
# — sends unnecessary data over the network when the server can discard early. Polling
# /api/tasks for status changes was rejected — introduces latency and load.
#
# @TEST_CONTRACT WebSocketLogStreamApi ->
# {
@@ -725,6 +738,13 @@ async def websocket_endpoint(
# @RELATION CALLS -> [TaskManagerPackage]
# @PRE WebSocket must be authenticated via `token` query param.
# @POST WebSocket streams task status events until disconnect.
# @RATIONALE Push-based global task event stream replaces per-task status polling — a single
# WebSocket delivers status changes for all tasks, enabling real-time dashboards and
# notification UIs without repeated /api/tasks queries.
# @REJECTED Per-task WebSocket subscriptions was rejected — the task-manager already publishes
# global events, so subscribing per-task duplicates the subscription overhead.
# Polling /api/tasks was rejected — introduces latency, load on the DB, and
# misses events between polls.
@app.websocket("/ws/task-events")
async def task_events_websocket(websocket: WebSocket):
"""
@@ -773,6 +793,12 @@ async def task_events_websocket(websocket: WebSocket):
# @RELATION CALLS -> [TaskManagerPackage]
# @PRE WebSocket must be authenticated via `token` query param.
# @POST WebSocket streams maintenance events until disconnect.
# @RATIONALE Push-based maintenance event notification replaces polling /api/maintenance/events —
# maintenance windows are time-sensitive (banner visibility, creation/ending); push
# ensures sub-second delivery without DB load from polling.
# @REJECTED Polling /api/maintenance/events was rejected — maintenance state changes are
# infrequent but time-critical; polling wastes resources during long idle periods
# and still incurs latency.
@app.websocket("/ws/maintenance/events")
async def maintenance_events_websocket(websocket: WebSocket):
"""

View File

@@ -1,8 +1,8 @@
# #region Auth.Jwt [C:5] [TYPE Module] [SEMANTICS auth,jwt,token,security]
# @defgroup Auth JWT token generation, validation, and revocation.
# @LAYER Core
# @RELATION DEPENDS_ON -> [Auth.Config]
# @RELATION DEPENDS_ON -> [Auth.TokenBlacklist]
# @RELATION DEPENDS_ON -> [AuthConfigModule]
# @RELATION DEPENDS_ON -> [TokenBlacklist]
# @INVARIANT Tokens must include aud, iss, iat, jti, exp, and sub claims.
# @INVARIANT Revoked tokens are rejected even if not expired.
# @PRE JWT secret configured in environment.
@@ -29,7 +29,7 @@ from .config import auth_config
# @PRE data dict contains 'sub' (user_id) and optional 'scopes'.
# @POST Returns a signed JWT string with unique jti for revocation.
# @SIDE_EFFECT None (pure function).
# @RELATION DEPENDS_ON -> [Auth.Config]
# @RELATION DEPENDS_ON -> [auth_config]
#
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
with belief_scope("create_access_token"):
@@ -63,9 +63,15 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
# @BRIEF Decodes and validates a JWT token — verifies aud, iss, exp, and blacklist.
# @PRE token is a signed JWT string.
# @POST Returns decoded payload if valid. Raises JWTError if invalid/revoked.
# @RELATION DEPENDS_ON -> [Auth.Config]
# @RELATION DEPENDS_ON -> [auth_config]
# @RELATION CALLS -> [Auth.Jwt.IsTokenBlacklisted]
#
# @RATIONALE Uses python-jose jwt.decode() with explicit audience, issuer, and required-claims
# verification to enforce all standard JWT validations in a single call — avoids
# manual per-claim checking that would be error-prone and inconsistent.
# @REJECTED Skipping audience/issuer verification was rejected — weakens token security against
# token injection from other services using the same secret. Manual decode with
# base64 + HMAC was rejected — duplicates library logic and introduces signature-
# verification bugs.
def decode_token(token: str) -> dict:
with belief_scope("decode_token"):
payload = jwt.decode(
@@ -116,8 +122,8 @@ def _prune_blacklist(db: Session) -> None:
# @PRE token was issued by this service and hasn't expired.
# @POST Token is blacklisted; subsequent decode attempts will reject it.
# @SIDE_EFFECT Writes to token_blacklist table; prunes expired entries.
# @RELATION DEPENDS_ON -> [Auth.TokenBlacklist]
# @RELATION CALLS -> [Auth.Jwt.HashToken]
# @RELATION DEPENDS_ON -> [TokenBlacklist]
# @RELATION CALLS -> [Auth.Jwt._HashToken]
# @TEST_EDGE: already_expired -> skips blacklisting
def blacklist_token(token: str, db: Session) -> None:
with belief_scope("blacklist_token"):
@@ -158,7 +164,7 @@ def blacklist_token(token: str, db: Session) -> None:
# @BRIEF Check if a JWT token hash exists in the blacklist.
# @PRE db is a valid SQLAlchemy session.
# @POST Returns True if token is blacklisted, False otherwise.
# @RELATION DEPENDS_ON -> [Auth.TokenBlacklist]
# @RELATION DEPENDS_ON -> [TokenBlacklist]
def is_token_blacklisted(token: str, db: Session) -> bool:
with belief_scope("is_token_blacklisted"):
try:

View File

@@ -283,7 +283,7 @@ def _ensure_git_server_configs_columns(bind_engine):
alter_statements = []
if "default_branch" not in existing_columns:
alter_statements.append(
"ALTER TABLE git_server_configs ADD COLUMN default_branch VARCHAR NOT NULL DEFAULT 'main'"
"ALTER TABLE git_server_configs ADD COLUMN default_branch VARCHAR NOT NULL DEFAULT 'prod'"
)
if not alter_statements:

View File

@@ -11,6 +11,11 @@
# @DATA_CONTRACT Input[str] -> Encrypted[str] -> Output[str]
# @RATIONALE Extracted from services/llm_provider.py to decouple core encryption from LLM domain
# and enable reuse by ConfigManager for Superset password encryption (see [SEC:C-1]).
# @REJECTED Rolling custom AES from PyCryptodome was rejected — Fernet (AES-128-CBC + HMAC-SHA256
# in encrypt-then-MAC construction) provides authenticated encryption out of the box,
# eliminating common AEAD composition mistakes. Using a cloud KMS was rejected — the
# key must be available offline during startup; Fernet with an env-var key is the
# simplest correct solution for reversible local secret storage.
import base64
import os
@@ -121,6 +126,7 @@ _encryption_manager: EncryptionManager | None = None
# #region is_fernet_token [C:2] [TYPE Function] [SEMANTICS encryption,validation]
# @ingroup Core
# @BRIEF Check whether a string looks like a valid Fernet-encrypted token.
# @PRE value is a string.
# @POST Returns True if value is base64-decodable with decoded length >= 32 bytes.
@@ -152,6 +158,11 @@ def is_fernet_token(value: str) -> bool:
# @RATIONALE Eliminates redundant ENCRYPTION_KEY reads and Fernet key validation —
# LLMProviderService was creating ~90 EncryptionManager instances per
# translation run (2 per batch). The singleton reduces this to 1.
# @REJECTED Per-call EncryptionManager instantiation was rejected — measured at ~90 instances
# per translation run, each re-reading ENCRYPTION_KEY from env and re-validating the
# Fernet key (O(1) but accumulates syscall overhead). Dependency injection container
# was rejected — singleton scope with a module-level variable is sufficient for a
# stateless, non-threaded process.
def get_encryption_manager() -> EncryptionManager:
"""Return the process-wide EncryptionManager singleton.

View File

@@ -13,6 +13,7 @@
# @TEST_DATA: mock_superset_resources -> {'chart': [{'id': 42, 'uuid': '1234', 'slice_name': 'test'}], 'dataset': [{'id': 99, 'uuid': '5678', 'table_name': 'data'}]}
#
# @INVARIANT sync_environment must handle remote API failures gracefully.
# @RATIONALE Centralizes UUID-to-integer ID resolution for Superset resources because the Superset API uses different ID schemes across endpoints (UUIDs for import/export, integer IDs for CRUD operations), enabling cross-environment migration with consistent resource references.
# @REJECTED BackgroundScheduler — was never started; replaced by AsyncJobRunner for async/sync bridge.
from datetime import UTC, datetime

View File

@@ -11,6 +11,8 @@
# @POST Dry-run diff returned without mutation
# @SIDE_EFFECT Reads source environment (read-only)
# @DATA_CONTRACT EnvironmentConfig -> MigrationDiffReport
# @RATIONALE Dry-run orchestrator exists as a dedicated pre-flight pipeline because migration diff computation requires coordinated multi-step orchestration (export → transform → extract → diff → risk) that must remain read-only and separate from execution to prevent accidental mutation.
# @REJECTED Merging dry-run and execution into a single code path was rejected — it would create an unacceptable risk of accidentally mutating the target environment during what should be a read-only analysis.
from datetime import UTC, datetime
import json
from typing import Any

View File

@@ -24,6 +24,8 @@
# @UX_FEEDBACK [N/A] -> [No direct UI side effects in this module]
# @UX_RECOVERY [N/A] -> [Caller-level retry/recovery]
# @UX_REACTIVITY [N/A] -> [Backend synchronous function contracts]
# @RATIONALE Isolated computation module for deterministic migration risk scoring (bounded 0-100 score with severity weights) because risk assessment has well-defined mathematical invariants that benefit from independent testing without mocking the full dry-run orchestra pipeline.
# @REJECTED Embedding risk assessment inline within the dry-run orchestrator was rejected — it would couple scoring logic with orchestration concerns, making both harder to test and evolve independently.
from typing import Any

View File

@@ -12,6 +12,8 @@
# @SIDE_EFFECT Reads and writes temporary archive contents during transformation workflows and emits structured belief-state logs.
# @DATA_CONTRACT Input[zip_path, output_path, db_mapping, target_env_id?, fix_cross_filters?] -> Output[Transformed Superset archive]
# @INVARIANT ZIP structure and non-targeted metadata must remain valid after transformation.
# @RATIONALE Dedicated module for Superset export ZIP transformation (extract → transform → re-package) because archive manipulation has distinct lifecycle requirements that benefit from isolation from API routing and task orchestration layers.
# @REJECTED Performing ZIP transformations inline within API route handlers was rejected — it would duplicate extraction/packaging logic across endpoints and make archive corruption handling inconsistent.
import json
import os
from pathlib import Path

View File

@@ -171,6 +171,8 @@ class SchedulerService:
# @SIDE_EFFECT Mutates APScheduler state; calls execute_scheduled_translation on trigger.
# @RELATION DEPENDS_ON -> [execute_scheduled_translation]
# @RELATION DEPENDS_ON -> [SessionLocal]
# @RATIONALE Registers translation schedules with APScheduler because the scheduler service acts as a central job registry — adding translation jobs here ensures all scheduled work follows the same lifecycle (start/stop/reload) regardless of job type.
# @REJECTED Having translation schedules self-register directly with APScheduler was rejected — it would bypass centralized lifecycle management and create split-brain state when the scheduler is stopped or reloaded.
def add_translation_job(self, schedule_id: str, job_id: str, cron_expression: str, timezone: str = "UTC", execution_mode: str = "full"):
with belief_scope(
"SchedulerService.add_translation_job",
@@ -206,6 +208,8 @@ class SchedulerService:
# @PRE schedule_id is a valid string.
# @POST The APScheduler job is removed if it exists; silently ignored otherwise.
# @SIDE_EFFECT Mutates APScheduler state.
# @RATIONALE Provides a clean removal path that mirrors the add operation, ensuring APScheduler state stays consistent with the database when schedules are deactivated or deleted.
# @REJECTED Relying on APScheduler auto-cleanup or the next load_schedules call to overwrite stale jobs was rejected — it would leave transient job registrations that could fire unexpected executions after schedule deactivation.
def remove_translation_job(self, schedule_id: str):
with belief_scope(
"SchedulerService.remove_translation_job",

View File

@@ -203,7 +203,7 @@ class SupersetSqlLabMixin:
# @ingroup Core
# @BRIEF Get query execution history (recent SQL Lab queries).
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API для пагинации.
# @RELATION CALLS -> [SupersetClientBase._fetch_all_pages]
# @RELATION CALLS -> [SupersetClientFetchAllPages]
async def get_query_history(self, query: dict | None = None) -> tuple[int, list[dict]]:
with belief_scope("SupersetSqlLabMixin.get_query_history"):
app_logger.reason("Fetching query history")

View File

@@ -26,6 +26,15 @@ from .task_logger import TaskLogger
# @SIDE_EFFECT Emits structured task logs through TaskLogger on plugin interactions.
# @DATA_CONTRACT Input[task_id, add_log_fn, params, default_source, background_tasks] -> Output[TaskContext]
# @UX_STATE Idle -> Active -> Complete
# @RATIONALE Wraps per-task resources (logger, params, background_tasks) into a single container
# passed to plugin.execute() — avoids forcing plugins to import global state or manage
# their own logger lifecycle. TaskLogger delegates writes through the TaskManager's
# add_log callback, ensuring all logs flow through the same event bus regardless of
# which plugin or sub-context emitted them.
# @REJECTED Passing logger and params as separate function arguments to plugin.execute() was
# rejected — adding a new resource (e.g., background_tasks) would break all existing
# plugin signatures. A global singleton logger was rejected — it cannot scope log
# entries to individual task executions when multiple tasks run concurrently.
#
# @TEST_CONTRACT TaskContextContract ->
# {

View File

@@ -10,6 +10,16 @@
# @POST Task models exported with immutable IDs
# @SIDE_EFFECT Defines task data schema
# @DATA_CONTRACT TaskInput -> TaskModel
# @RATIONALE Uses Pydantic v2 BaseModel for all domain data models — provides built-in field
# validation, JSON serialization, and schema generation without additional libraries.
# Task IDs auto-generated via uuid4 for uniqueness across restarts without relying on
# DB sequence generation. The status enum (TaskStatus) encodes the complete lifecycle
# in a closed set, preventing invalid state transitions at the type level.
# @REJECTED SQLAlchemy ORM models for task domain objects was rejected — Pydantic models are
# lightweight, don't require DB session management, and can be safely passed across
# async boundaries (no detached-instance issues). Python dataclasses were rejected —
# lack JSON schema generation, field-level validation, and the ConfigDict serialization
# hooks (from_attributes) used by the persistence layer.
from datetime import UTC, datetime
from enum import Enum
from typing import Any

View File

@@ -10,6 +10,14 @@
# @RELATION DEPENDS_ON -> [TaskGraph]
# @RELATION DEPENDS_ON -> [TasksSessionLocal]
# @INVARIANT Database schema must match the TaskRecord model structure.
# @RATIONALE Uses SQLAlchemy ORM with separate TaskRecord and TaskLogRecord tables — task metadata
# and high-volume log entries are stored in distinct tables so log queries don't scan
# large task records, and task loads don't pull unnecessary log blobs. Batch-insert
# pattern for logs reduces per-row commit overhead during burst task execution.
# @REJECTED In-memory-only task storage was rejected — all task state is lost on backend restart,
# breaking long-running plugin tasks. JSON file persistence was rejected — no query
# capability (filtering, pagination, aggregation). Single-row-per-log commits were
# rejected — unacceptable I/O overhead during log bursts (~100+ entries/second).
from datetime import UTC, datetime
import json
import re
@@ -310,6 +318,13 @@ class TaskPersistenceService:
# @RELATION DEPENDS_ON -> [TaskManager]
# @RELATION DEPENDS_ON -> [EventBus]
# @INVARIANT Log entries are batch-inserted for performance.
# @RATIONALE Separate persistence service for logs isolated from task metadata — task logs are
# high-volume (100+ entries per task run) and need efficient batch insert, paginated
# queries, and aggregation (by level/source) without impacting task CRUD performance.
# @REJECTED Storing logs as JSON arrays in TaskRecord was rejected — log rows scale
# independently of tasks; embedding them bloats the task_records table and prevents
# efficient SQL filtering/aggregation. File-per-task logging was rejected — loses
# cross-task query capability and complicates lifecycle cleanup.
#
# @TEST_CONTRACT TaskLogPersistenceContract ->
# {

View File

@@ -546,8 +546,8 @@ oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_e
# #region get_current_user [C:3] [TYPE Function]
# @ingroup Module
# @RELATION CALLS -> AuthRepository
# @RELATION CALLS -> [is_token_blacklisted]
# @RELATION CALLS -> [AuthRepository]
# @RELATION CALLS -> [Auth.Jwt.IsTokenBlacklisted]
# @BRIEF Dependency for retrieving currently authenticated user from a JWT.
# Supports dual identity (FR-007/FR-019): when X-User-JWT header is present,
# it takes precedence over the Authorization Bearer token for user identity.

View File

@@ -36,7 +36,7 @@ class GitServerConfig(Base):
url = Column(String(255), nullable=False)
pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN
default_repository = Column(String(255), nullable=True)
default_branch = Column(String(255), default="main")
default_branch = Column(String(255), default="prod")
status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN)
last_validated = Column(DateTime, default=lambda: datetime.now(UTC))
# #endregion GitServerConfig

View File

@@ -11,6 +11,8 @@
# @RELATION DEPENDS_ON -> [TaskContext]
# @INVARIANT All LLM interactions must be executed as asynchronous tasks.
# @DATA_CONTRACT AnalysisRequest -> AnalysisResult
# @RATIONALE The plugin architecture separates LLM analysis into two independent paths — Path A (multimodal screenshot analysis for vision-capable providers) and Path B (structural analysis via REST API for text-only providers). This dual-path design maximizes compatibility across LLM providers while allowing each path to be independently maintained and tested.
# @REJECTED A single monolithic analysis function was rejected — it would interleave screenshot capture, API calls, and dataset health checking into unmaintainable code. Using only Path A (screenshots) was rejected — it requires Playwright and a multimodal LLM, excluding text-only providers. Using only Path B (API structure) was rejected — it cannot detect visual rendering issues that are only visible in screenshots.
from datetime import UTC, datetime, timedelta
import json
@@ -164,6 +166,8 @@ def _update_run_status(db, run_id: str | None) -> None:
# @BRIEF Plugin for automated dashboard health analysis using LLMs.
# @SIDE_EFFECT Captures screenshots; calls LLM APIs; writes DB records; sends notifications.
# @RELATION IMPLEMENTS -> [PluginBase]
# @RATIONALE Dual-path execution enables robust validation regardless of provider capability — Path A (multimodal) detects visual rendering issues invisible to structural analysis, while Path B (structural) works with any text-only provider. Error in one path does not block the other, maximizing validation coverage.
# @REJECTED Single-path validation was rejected — using only screenshots would fail on non-multimodal providers; using only structural analysis would miss visual rendering bugs like overlapping charts or missing data labels that only a screenshot can reveal.
class DashboardValidationPlugin(PluginBase):
@property
def id(self) -> str:
@@ -852,6 +856,8 @@ class DashboardValidationPlugin(PluginBase):
# @BRIEF Plugin for automated dataset documentation using LLMs.
# @SIDE_EFFECT Calls LLM API; updates dataset metadata in Superset.
# @RELATION IMPLEMENTS -> [PluginBase]
# @RATIONALE Automated dataset documentation fills a critical gap where datasets accumulate technical column names without business context. The LLM generates human-readable descriptions for each column, which are then pushed back to Superset's metadata store so they appear in the Explore UI for all users.
# @REJECTED Manual documentation was rejected — it doesn't scale to hundreds of datasets and is never kept up to date. Using static templates without LLM was rejected — dataset semantics vary too much across domains for templated descriptions to be useful.
class DocumentationPlugin(PluginBase):
@property
def id(self) -> str:

View File

@@ -6,6 +6,7 @@
# @INVARIANT Screenshots must be 1920px width and capture full page height.
# @DATA_CONTRACT DashboardSpec -> Screenshot + Analysis
# @RATIONALE Extracted all hardcoded timeouts into named module-level constants (PLAYWRIGHT_NAVIGATION_TIMEOUT_MS, PLAYWRIGHT_WAIT_TIMEOUT_MS, PLAYWRIGHT_SHORT_TIMEOUT_MS, HTTP_REQUEST_TIMEOUT_MS, SCREENSHOT_SERVICE_TIMEOUT_MS, LLM_HTTP_TIMEOUT_S) and DEFAULT_USER_AGENT. Zero remaining numeric timeout literals.
# @REJECTED Keeping inline numeric timeout literals was rejected — they create a maintenance hazard where any timeout adjustment requires grep-and-fix across 1700+ lines; named constants centralize tuning and make timeout configuration auditable.
import asyncio
import base64
@@ -855,6 +856,8 @@ class ScreenshotService:
# @defgroup LLMAnalysis Module group.
# @BRIEF Wrapper for LLM provider APIs.
# @SIDE_EFFECT Makes HTTP calls to LLM provider APIs.
# @RATIONALE The LLMClient abstracts provider-specific quirks behind a uniform interface: JSON response format detection, SSL verification for self-hosted providers, connection error diagnostics, image optimization for multimodal models, and payload size estimation. This encapsulation ensures analysis logic (DashboardValidationPlugin) is provider-agnostic.
# @REJECTED Direct httpx/openai calls in plugin code was rejected — it would duplicate SSL/retry/error-handling logic across every analysis path. A thin wrapper without provider-specific adaptations was rejected — providers have incompatible JSON mode support and SSL requirements; the client must normalize these differences.
class LLMClient:
# #region LLMClient.__init__ [TYPE Function]
# @PURPOSE Initializes the LLMClient with provider settings.

View File

@@ -10,6 +10,8 @@
# @RELATION DEPENDS_ON -> [TranslationResultAggregator]
# @RELATION DEPENDS_ON -> [TranslationEventLog]
# @RELATION DEPENDS_ON -> [orchestrator_run_completion]
# @RATIONALE Thin orchestrator separating execution lifecycle (dispatch → completion → failure paths) from the actual translation logic in TranslationExecutor. After execution, it coordinates SQL generation, event logging at phase boundaries, and per-language stats initialization — keeping the executor focused on LLM batching.
# @REJECTED Embedding completion/failure logic inside TranslationExecutor was rejected — it would couple LLM batching with SQL generation and Superset submission. Running execution and SQL insert as a single monolithic step was rejected — it prevents partial result recovery if SQL generation fails after successful LLM execution.
from collections.abc import Callable
import uuid

View File

@@ -10,6 +10,7 @@
# @RELATION DEPENDS_ON -> [TranslationExecutor]
# @RELATION DEPENDS_ON -> [TranslationEventLog]
# @RATIONALE Cancel and retry-insert extracted to orchestrator_cancel module for INV_7 compliance.
# @REJECTED Keeping cancel and retry-insert logic in the same class was rejected — the module exceeded INV_7 (400 lines); extracting cancel to orchestrator_cancel keeps each module independently maintainable and testable.
from datetime import UTC, datetime

View File

@@ -4,7 +4,7 @@
# @LAYER Domain
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION DEPENDS_ON -> [verify_password]
# @RELATION DEPENDS_ON -> [create_access_token]
# @RELATION DEPENDS_ON -> [Auth.Jwt.CreateAccessToken]
# @RELATION DEPENDS_ON -> [User]
# @RELATION DEPENDS_ON -> [Role]
# @INVARIANT Authentication succeeds only for active users with valid credentials; issued sessions encode subject and scopes from assigned roles.
@@ -82,7 +82,7 @@ class AuthService:
# @POST Returns session dict with non-empty access_token and token_type='bearer'.
# @SIDE_EFFECT Generates signed JWT via auth JWT provider.
# @DATA_CONTRACT Input(User) -> Output(Dict[str, str]{access_token, token_type})
# @RELATION CALLS ->[create_access_token]
# @RELATION CALLS -> [Auth.Jwt.CreateAccessToken]
# @RELATION DEPENDS_ON ->[User]
# @RELATION DEPENDS_ON ->[Role]
# @PARAM user (User) - The authenticated user.

View File

@@ -10,6 +10,8 @@
# @POST Approval decision appended; candidate lifecycle advanced
# @SIDE_EFFECT Persists approval decisions, transitions candidate status
# @DATA_CONTRACT ApprovalRequest -> ApprovalDecision
# @RATIONALE Isolates approval gate enforcement as a separate module because approval decisions are append-only, cryptographically-auditable records with strict preconditions (PASSED report required) that must be enforced independently of compliance execution or manifest building.
# @REJECTED Embedding approval logic in the compliance orchestrator or API handlers was rejected — it would make the approval invariants (append-only, PASSED-only, no double-approve) impossible to verify independently from other compliance concerns.
from __future__ import annotations

View File

@@ -11,6 +11,8 @@
# @SIDE_EFFECT Persists runs, stage results, violations, and reports through repository adapters and audit helpers.
# @DATA_CONTRACT Input[candidate_id, requested_by, manifest_id?, policy snapshots, stage results] -> Output[ComplianceExecutionResult]
# @INVARIANT A run binds to exactly one candidate/manifest/policy/registry snapshot set.
# @RATIONALE Separates execution of compliance stages from the orchestrator's lifecycle management because the execution service owns stage iteration, violation collection, and report persistence which are distinct concerns from orchestrator state transitions (start → execute → finalize).
# @REJECTED Merging execution logic into the orchestrator class was rejected — it would violate the Single Responsibility Principle, making the orchestrator responsible for both lifecycle coordination AND stage execution, impossible to test stage iteration independently.
from __future__ import annotations

View File

@@ -16,6 +16,8 @@
# @POST OrchestrationResult with compliance status
# @SIDE_EFFECT Triggers compliance checks; may modify manifest state
# @DATA_CONTRACT Manifest -> ComplianceReport
# @RATIONALE Separates compliance orchestration (stage lifecycle: start → execute → finalize) from individual stage logic because compliance verification has a multi-step lifecycle with state transitions that would create temporal coupling if mixed with stage implementations.
# @REJECTED Embedding orchestrator lifecycle directly within the CleanReleaseRepository or API handlers was rejected — it would make the compliance flow impossible to test independently or reuse across API, scheduler, and CLI invocation contexts.
from __future__ import annotations

View File

@@ -6,6 +6,8 @@
# @INVARIANT Demo and real namespaces must never collide for generated physical identifiers.
# @SIDE_EFFECT Writes demo entities to repository
# @DATA_CONTRACT SeedConfig -> DemoEntities
# @RATIONALE Demo data service isolates namespace resolution and repository creation from the seed logic because deterministic namespace separation (demo vs real) is a cross-cutting concern that must be consistent across repository instances, compliance checks, and approval gates — centralizing it prevents namespace drift.
# @REJECTED Embedding namespace resolution inside each repository consumer was rejected — it would create inconsistent namespace strings across the codebase, risking demo/real namespace collision that violates the INV_6 invariant.
from __future__ import annotations

View File

@@ -6,6 +6,8 @@
# @INVARIANT Equal semantic artifact sets produce identical deterministic hash values.
# @SIDE_EFFECT Computes hash of artifact set
# @DATA_CONTRACT ArtifactSet -> Manifest
# @RATIONALE Manifest building is separated into its own module because manifest generation requires deterministic hashing (SHA-256 over sorted artifact payloads) that is independent from compliance checking or approval — separating it ensures the hash algorithm can evolve without affecting other clean-release subsystems.
# @REJECTED Embedding manifest generation logic inside the compliance execution service was rejected — it would couple the artifact classification and hashing logic with stage execution, making both harder to test and evolve independently.
from __future__ import annotations

View File

@@ -10,6 +10,8 @@
# @INVARIANT Existing manifests are never mutated.
# @SIDE_EFFECT May modify manifest state during processing
# @DATA_CONTRACT Manifest -> ManifestRecord; Candidate -> ManifestRecord
# @RATIONALE Manifest service wraps manifest builder with persistence, version increment, and candidate lifecycle transitions — separating builder (pure computation) from service (persistence + side effects) allows the hashing logic to be tested without a database.
# @REJECTED Combining builder logic and persistence in a single function was rejected — it would make it impossible to unit-test manifest digest computation without a repository mock.
from __future__ import annotations

View File

@@ -7,6 +7,8 @@
# @RELATION DEPENDS_ON -> [CleanReleaseModels]
# @RELATION DEPENDS_ON -> [AuditService]
# @INVARIANT Publication records are append-only snapshots; revoke mutates only publication status for targeted record.
# @RATIONALE Publication records are append-only to maintain a complete audit trail — revoking a publication hides it from release channels but never deletes the record. This ensures every publication event is permanently traceable, supporting compliance audits and rollback forensics.
# @REJECTED Mutable publication records (updating in-place) were rejected — they destroy the audit trail, making it impossible to determine what was published when. Soft-deleting publications (DELETE flag) was rejected — append-only with status mutation is simpler and maintains record identity across the publication lifecycle.
from __future__ import annotations

View File

@@ -8,6 +8,8 @@
# @POST Source isolation violations identified
# @SIDE_EFFECT None (read-only check)
# @DATA_CONTRACT SourceURL -> ViolationReport
# @RATIONALE Source isolation validation is a pure function module (endpoint → allowed_hosts → violation) with no database or service dependencies — keeping it stateless makes it trivially testable and reusable across compliance stages and CLI verification without mock setup.
# @REJECTED Embedding endpoint validation logic inside compliance stage implementations was rejected — it would duplicate the allowed-hosts matching logic across stages and make it impossible to unit-test the isolation check independently.
from __future__ import annotations

View File

@@ -7,6 +7,8 @@
# @INVARIANT Stage order remains deterministic for all compliance runs.
# @SIDE_EFFECT Registers compliance stages
# @DATA_CONTRACT StagePipeline -> ComplianceResult
# @RATIONALE Compliance stages follow a fixed deterministic execution order because stages have sequential dependencies — data purity must pass before source validation, which must pass before manifest consistency. The pipeline order is explicit (MANDATORY_STAGE_ORDER) to guarantee reproducible compliance evaluations across runs.
# @REJECTED Dynamic stage ordering (inferring execution order from stage metadata or dependency declarations) was rejected — it introduces non-determinism and makes compliance results impossible to reproduce. Parallel stage execution was rejected — chain dependencies between stages require sequential processing; e.g., data purity must identify files before internal_sources_only can validate them.
from __future__ import annotations

View File

@@ -7,6 +7,8 @@
# @INVARIANT prohibited_detected_count > 0 always yields BLOCKED stage decision.
# @SIDE_EFFECT None (read-only validation)
# @DATA_CONTRACT Manifest -> DataPurityVerdict
# @RATIONALE Data purity is the first compliance gate because it is the cheapest check with the highest impact — detecting prohibited data artifacts early prevents wasted downstream processing. The invariant `prohibited_detected_count > 0 → BLOCKED` ensures no release can proceed with known-prohibited data regardless of other passing stages.
# @REJECTED Non-blocking data purity warnings were rejected — prohibited artifacts are a hard legal compliance boundary; warnings would allow releases with known licensing risk. Running data purity last was rejected — it is the most likely stage to fail; placing it first saves compute on downstream stages that would otherwise process an already-invalid release.
from __future__ import annotations

View File

@@ -7,6 +7,8 @@
# @INVARIANT Any source host outside allowed_hosts yields BLOCKED decision with at least one violation.
# @SIDE_EFFECT None (read-only validation)
# @DATA_CONTRACT Sources -> SourceViolationReport
# @RATIONALE Internal source validation enforces supply-chain security — all dependencies must originate from a curated set of trusted internal registries. This prevents accidental inclusion of external/unvetted packages that could introduce vulnerabilities, licensing issues, or compliance violations.
# @REJECTED Allowlisting external registries (e.g., public PyPI/npm) alongside internal ones was rejected — it creates a porous supply-chain boundary where a compromised external package could enter the release. Running source validation before data purity was rejected — data purity detects prohibited artifact types and is cheaper; source validation is more expensive and should only execute if the data is clean.
from __future__ import annotations

View File

@@ -1,11 +1,12 @@
# #region GitServiceBranchMixin [C:4] [TYPE Module] [SEMANTICS git, branch, checkout, list, namespace, lock]
# @defgroup Services Module group.
# @LAYER Infrastructure
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes (all concurrent-safe via per-dashboard locks).
# @BRIEF Branch and commit operations for GitService — gitflow branches, list/create/checkout branches, commit changes, delete, validate, classify (all concurrent-safe via per-dashboard locks).
# @RELATION CALLED_BY -> [GitService]
from datetime import UTC, datetime
import os
import re
from fastapi import HTTPException
from git import Repo
@@ -13,40 +14,51 @@ from git.exc import GitCommandError
from src.core.logger import belief_scope, logger
# Environment branch names — protected from deletion
_ENV_BRANCHES = frozenset({"dev", "preprod", "prod"})
# Legacy branch names — recognized but not primary
_LEGACY_BRANCHES = frozenset({"main", "master"})
# Valid branch name pattern: alphanumeric, ., _, /, - ; no ".."; no leading /
_BRANCH_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._\/\-]*$")
# Feature/hotfix/bugfix prefix patterns
_FEATURE_PREFIX = "feature/"
_HOTFIX_PREFIX = "hotfix/"
_BUGFIX_PREFIX = "bugfix/"
# #region GitServiceBranchMixin [C:3] [TYPE Class]
# @defgroup Services Module group.
# @BRIEF Mixin providing branch and commit operations for GitService.
class GitServiceBranchMixin:
# region _ensure_gitflow_branches [C:4] [TYPE Function] [SEMANTICS git,gitflow,branch,lock]
# @PURPOSE: Ensure standard GitFlow branches (main/dev/preprod) exist locally and on origin.
# @PURPOSE: Ensure standard GitFlow branches (prod/dev/preprod) exist locally and on origin.
# @PRE repo is a valid GitPython Repo instance.
# @POST main, dev, preprod are available in local repository and pushed to origin when available.
# @POST prod, dev, preprod are available in local repository and pushed to origin when available.
# Active branch unchanged (no spurious checkout).
def _ensure_gitflow_branches(self, repo: Repo, dashboard_id: int) -> None:
with belief_scope("GitService._ensure_gitflow_branches"):
required_branches = ["main", "dev", "preprod"]
required_branches = ["prod", "dev", "preprod"]
local_heads = {head.name: head for head in getattr(repo, "heads", [])}
base_commit = None
try:
base_commit = repo.head.commit
except Exception:
base_commit = None
if "main" in local_heads:
base_commit = local_heads["main"].commit
if "prod" in local_heads:
base_commit = local_heads["prod"].commit
if base_commit is None:
logger.reason(
f"Skipping branch bootstrap for dashboard {dashboard_id}: repository has no commits",
extra={"src": "_ensure_gitflow_branches"},
)
return
if "main" not in local_heads:
local_heads["main"] = repo.create_head("main", base_commit)
logger.reason(f"Created local branch main for dashboard {dashboard_id}", extra={"src": "_ensure_gitflow_branches"})
if "prod" not in local_heads:
local_heads["prod"] = repo.create_head("prod", base_commit)
logger.reason(f"Created local branch prod for dashboard {dashboard_id}", extra={"src": "_ensure_gitflow_branches"})
for branch_name in ("dev", "preprod"):
if branch_name in local_heads:
continue
local_heads[branch_name] = repo.create_head(branch_name, local_heads["main"].commit)
local_heads[branch_name] = repo.create_head(branch_name, local_heads["prod"].commit)
logger.reason(
f"Created local branch {branch_name} for dashboard {dashboard_id}",
extra={"src": "_ensure_gitflow_branches"},
@@ -96,10 +108,11 @@ class GitServiceBranchMixin:
logger.reason(f"Could not checkout dev branch for dashboard {dashboard_id}: {e}", extra={"src": "_ensure_gitflow_branches"})
# endregion _ensure_gitflow_branches
# region list_branches [C:4] [TYPE Function] [SEMANTICS git,branch,list,lock]
# @PURPOSE: List all branches (excluding tags) for a dashboard's repository, concurrent-safe.
# region list_branches [C:4] [TYPE Function] [SEMANTICS git,branch,list,lock,classify]
# @PURPOSE: List all branches (excluding tags) for a dashboard's repository, concurrent-safe,
# with branch_type classification for UI grouping.
# @PRE Repository for dashboard_id exists.
# @POST Returns a list of branch metadata dictionaries (no tag refs).
# @POST Returns a list of branch metadata dictionaries (no tag refs), each with branch_type.
# @RETURN List[dict]
async def list_branches(self, dashboard_id: int) -> list[dict]:
with self._locked(dashboard_id), belief_scope("GitService.list_branches"):
@@ -115,11 +128,13 @@ class GitServiceBranchMixin:
name = ref_name.replace('refs/heads/', '').replace('refs/remotes/origin/', '')
if any(b['name'] == name for b in branches):
continue
is_remote = ref.is_remote() if hasattr(ref, 'is_remote') else False
branches.append({
"name": name,
"commit_hash": ref.commit.hexsha if hasattr(ref, 'commit') else "0000000",
"is_remote": ref.is_remote() if hasattr(ref, 'is_remote') else False,
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.now(UTC)
"is_remote": is_remote,
"last_updated": datetime.fromtimestamp(ref.commit.committed_date) if hasattr(ref, 'commit') else datetime.now(UTC),
"branch_type": self._classify_branch_type(name, is_remote),
})
except Exception as e:
logger.reason(f"Skipping ref {ref}: {e}", extra={"src": "list_branches"})
@@ -128,29 +143,33 @@ class GitServiceBranchMixin:
if not any(b['name'] == active_name for b in branches):
branches.append({
"name": active_name, "commit_hash": "0000000",
"is_remote": False, "last_updated": datetime.now(UTC)
"is_remote": False, "last_updated": datetime.now(UTC),
"branch_type": self._classify_branch_type(active_name, False),
})
except Exception as e:
logger.reason(f"Could not determine active branch: {e}", extra={"src": "list_branches"})
if not branches:
branches.append({
"name": "dev", "commit_hash": "0000000",
"is_remote": False, "last_updated": datetime.now(UTC)
"is_remote": False, "last_updated": datetime.now(UTC),
"branch_type": "environment",
})
return branches
# endregion list_branches
# region create_branch [C:4] [TYPE Function] [SEMANTICS git,branch,create,lock]
# region create_branch [C:4] [TYPE Function] [SEMANTICS git,branch,create,lock,validate]
# @PURPOSE: Create a new branch from an existing one (concurrent-safe).
# @PARAM name (str) - New branch name.
# @PARAM from_branch (str) - Source branch.
# @PRE Repository exists; name is valid; from_branch exists or repo is empty.
# @POST A new branch is created in the repository.
async def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"):
async def create_branch(self, dashboard_id: int, name: str, from_branch: str = "dev"):
with self._locked(dashboard_id):
with belief_scope("GitService.create_branch"):
repo = await self.get_repo(dashboard_id)
logger.reason(f"Creating branch {name} from {from_branch}", extra={"src": "create_branch"})
# Validate branch name
self.validate_branch_name(name)
if not repo.heads and not repo.remotes:
logger.reason("Repository is empty. Creating initial commit to enable branching.", extra={"src": "create_branch"})
readme_path = os.path.join(repo.working_dir, "README.md")
@@ -254,5 +273,160 @@ class GitServiceBranchMixin:
repo.index.commit(message)
logger.reflect("Committed changes", extra={"src": "commit_changes"}, payload={"message": message})
# endregion commit_changes
# region _classify_branch_type [C:2] [TYPE Function] [SEMANTICS git,branch,classify,type]
# @PURPOSE: Classify a branch name into its semantic type for UI grouping.
# @RETURN str — one of: environment, feature, hotfix, bugfix, legacy, remote_ref, other
# @PRE branch_name is a non-empty string.
@staticmethod
def _classify_branch_type(branch_name: str, is_remote: bool = False) -> str:
name = str(branch_name).strip()
if not name:
return "other"
if is_remote or name.startswith("origin/"):
return "remote_ref"
if name in _ENV_BRANCHES:
return "environment"
if name in _LEGACY_BRANCHES:
return "legacy"
if name.startswith(_FEATURE_PREFIX):
return "feature"
if name.startswith(_HOTFIX_PREFIX):
return "hotfix"
if name.startswith(_BUGFIX_PREFIX):
return "bugfix"
return "other"
# endregion _classify_branch_type
# region validate_branch_name [C:2] [TYPE Function] [SEMANTICS git,branch,validate,regex]
# @PURPOSE: Validate a branch name against allowed pattern.
# @PRE name is non-empty string.
# @POST Returns True if valid; raises HTTPException(400) if invalid.
# @RATIONALE Explicit validation prevents footguns like branch names with ".." or leading "/"
# that can break Git operations or path traversal security.
@staticmethod
def validate_branch_name(name: str) -> bool:
if not name or not str(name).strip():
raise HTTPException(status_code=400, detail="Branch name must not be empty")
name = str(name).strip()
if ".." in name:
raise HTTPException(
status_code=400,
detail="Branch name must not contain '..' (path traversal protection)",
)
if name.startswith("/") or name.endswith("/"):
raise HTTPException(
status_code=400,
detail="Branch name must not start or end with '/'",
)
if name.startswith("-"):
raise HTTPException(
status_code=400,
detail="Branch name must not start with '-'",
)
if not _BRANCH_NAME_RE.match(name):
raise HTTPException(
status_code=400,
detail=(
"Branch name must contain only alphanumeric characters, "
"dots, underscores, forward slashes, and hyphens "
"(example: feature/my-branch_v2)"
),
)
if len(name) > 255:
raise HTTPException(
status_code=400,
detail="Branch name must be 255 characters or fewer",
)
return True
# endregion validate_branch_name
# region delete_branch [C:4] [TYPE Function] [SEMANTICS git,branch,delete,protection,lock]
# @PURPOSE: Delete a branch (local and remote if origin exists) with protection for environment branches.
# @PRE Repository exists; branch is not a protected environment branch (unless force=True).
# @POST Branch is deleted both locally and on origin.
# @SIDE_EFFECT Permanently deletes the branch from local repo and remote origin.
# @RATIONALE Environment branches (dev/preprod/prod) are protected from accidental deletion.
# force=True bypasses protection for automated cleanup (e.g., auto-delete after merge).
async def delete_branch(self, dashboard_id: int, name: str, force: bool = False):
with self._locked(dashboard_id), belief_scope("GitService.delete_branch"):
repo = await self.get_repo(dashboard_id)
branch_name = str(name).strip()
if not branch_name:
raise HTTPException(status_code=400, detail="Branch name must not be empty")
if not force and branch_name in _ENV_BRANCHES:
raise HTTPException(
status_code=403,
detail=f"Environment branch '{branch_name}' is protected from deletion",
)
# Verify branch exists
all_heads = {head.name: head for head in repo.heads}
if branch_name not in all_heads:
raise HTTPException(status_code=404, detail=f"Branch '{branch_name}' not found")
# Don't delete the currently active branch
active_branch = None
try:
active_branch = repo.active_branch.name
except Exception:
pass
if active_branch == branch_name:
# Switch to dev or prod before deleting
fallback = "dev" if "dev" in all_heads else "prod"
if fallback in all_heads and fallback != branch_name:
repo.git.checkout(fallback)
else:
raise HTTPException(
status_code=409,
detail=f"Cannot delete active branch '{branch_name}'. Switch to another branch first.",
)
# Delete local branch
try:
repo.delete_head(branch_name)
logger.reason(f"Deleted local branch {branch_name} for dashboard {dashboard_id}", extra={"src": "delete_branch"})
except Exception as e:
logger.explore("Failed to delete local branch", extra={"src": "delete_branch"}, error=str(e))
raise HTTPException(status_code=500, detail=f"Failed to delete local branch '{branch_name}': {e!s}")
# Delete remote branch if origin exists
try:
origin = repo.remote(name="origin")
origin.push(refspec=f":{branch_name}")
logger.reason(f"Deleted remote branch {branch_name} for dashboard {dashboard_id}", extra={"src": "delete_branch"})
except ValueError:
logger.debug("No remote origin configured for dashboard %d, skipping remote delete", dashboard_id)
except Exception as e:
logger.explore("Failed to delete remote branch", extra={"src": "delete_branch"}, error=str(e))
# Non-fatal: local delete already succeeded
return {"status": "deleted", "branch_name": branch_name}
# endregion delete_branch
# region get_branch_protection_rules [C:2] [TYPE Function] [SEMANTICS git,branch,protection,rules]
# @PURPOSE: Return protection rules for environment branches.
# @RETURN List[dict] — protection rules keyed by branch name.
@staticmethod
def get_branch_protection_rules() -> list[dict[str, object]]:
return [
{
"branch_name": "prod",
"require_mr": False, # Optional: can enable in future
"require_review": False,
"allow_force_push": False,
"allow_direct_delete": False,
},
{
"branch_name": "dev",
"require_mr": False,
"require_review": False,
"allow_force_push": False,
"allow_direct_delete": False,
},
{
"branch_name": "preprod",
"require_mr": False,
"require_review": False,
"allow_force_push": False,
"allow_direct_delete": False,
},
]
# endregion get_branch_protection_rules
# #endregion GitServiceBranchMixin
# #endregion GitServiceBranchMixin

View File

@@ -137,7 +137,7 @@ class GitServiceGiteaMixin:
# @RETURN dict
async def create_gitea_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: str | None = None, auto_init: bool = True, default_branch: str | None = "main",
description: str | None = None, auto_init: bool = True, default_branch: str | None = "prod",
) -> dict[str, Any]:
payload: dict[str, Any] = {"name": name, "private": bool(private), "auto_init": bool(auto_init)}
if description:

View File

@@ -298,5 +298,171 @@ class GitServiceMergeMixin:
logger.debug("Could not restore original branch %s after direct promote", original_branch)
return {"mode": "direct", "from_branch": source, "to_branch": target, "status": "merged"}
# endregion promote_direct_merge
# region merge_branch [C:4] [TYPE Function] [SEMANTICS git,merge,branch,feature,hotfix,isolation]
# @PURPOSE: Merge source_branch into target_branch with branch isolation, conflict detection, and optional auto-delete.
# @PRE Repository exists and both branches are valid and different.
# @POST Target branch contains merged changes from source. Active branch restored to original.
# On conflict: merge is aborted, conflicted files returned, no state change.
# @SIDE_EFFECT Changes local branch state during merge; restores original branch in finally block.
# May optionally delete source branch after successful merge.
# @RETURN Dict[str, Any] — status, commit_hash, conflicts, error_message, source_deleted
# @RATIONALE Separate from promote_direct_merge because feature/hotfix merging has different semantics:
# optional auto-delete, explicit conflict file listing, and lighter logging for routine use.
async def merge_branch(
self,
dashboard_id: int,
source_branch: str,
target_branch: str,
message: str | None = None,
auto_delete_source: bool = False,
) -> dict[str, Any]:
with self._locked(dashboard_id), belief_scope("GitService.merge_branch"):
source = source_branch.strip()
target = target_branch.strip()
if not source or not target:
raise HTTPException(status_code=400, detail="source_branch and target_branch are required")
if source == target:
raise HTTPException(status_code=400, detail="source_branch and target_branch must be different")
repo = await self.get_repo(dashboard_id)
original_branch = None
try:
original_branch = repo.active_branch.name
except Exception:
original_branch = None
# Validate branches exist
all_head_names = [head.name for head in repo.heads]
if source not in all_head_names:
raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found")
if target not in all_head_names:
raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found")
# Pull latest for target
try:
origin = repo.remote(name="origin")
origin.fetch()
repo.git.checkout(target)
try:
origin.pull(target)
except Exception:
logger.debug("Could not pull target branch %s before merge", target)
except ValueError:
logger.debug("No remote origin configured for dashboard %d", dashboard_id)
merge_success = False
merge_commit_hash = ""
conflicts: list[str] = []
error_message = ""
source_deleted = False
try:
merge_msg = str(message or f"chore(merge): merge {source} -> {target}")
repo.git.merge(source, "-m", merge_msg)
merge_success = True
try:
merge_commit_hash = repo.head.commit.hexsha
except Exception:
merge_commit_hash = ""
logger.reflect(
"Branch merged successfully",
extra={"src": "merge_branch"},
payload={"source": source, "target": target, "commit": merge_commit_hash},
)
except GitCommandError as e:
error_str = str(e)
if "CONFLICT" in error_str.upper():
# Abort the merge immediately — return conflict info
try:
repo.git.merge("--abort")
except Exception:
logger.debug("Could not abort merge for dashboard %d", dashboard_id)
conflicts = self._get_unmerged_file_paths(repo) or []
error_message = f"Merge conflict: {source} -> {target}"
logger.explore(
"Merge conflicts detected, aborted",
extra={"src": "merge_branch"},
payload={"source": source, "target": target, "conflict_files": conflicts},
error=error_str,
)
return {
"source_branch": source,
"target_branch": target,
"status": "conflicts",
"commit_hash": None,
"conflicts": conflicts,
"error_message": error_message,
"source_deleted": False,
}
# Check if already up-to-date
if "already up to date" in error_str.lower() or "already up-to-date" in error_str.lower():
merge_success = True
try:
merge_commit_hash = repo.head.commit.hexsha
except Exception:
merge_commit_hash = ""
logger.reflect(
"Branch already up-to-date",
extra={"src": "merge_branch"},
payload={"source": source, "target": target},
)
else:
error_message = f"Merge failed: {error_str}"
logger.explore(
"Merge failed",
extra={"src": "merge_branch"},
error=error_str,
)
raise HTTPException(status_code=500, detail=error_message)
except HTTPException:
raise
except Exception as e:
error_message = str(e)
logger.explore("Unexpected merge error", extra={"src": "merge_branch"}, error=error_message)
raise HTTPException(status_code=500, detail=f"Merge operation failed: {error_message}")
finally:
# Restore original branch
if original_branch:
try:
repo.git.checkout(original_branch)
except Exception:
logger.debug("Could not restore original branch %s after merge", original_branch)
# Push target branch to remote after successful merge
if merge_success:
try:
origin = repo.remote(name="origin")
origin.push(refspec=f"{target}:{target}")
logger.reason(f"Pushed merged {target} to origin", extra={"src": "merge_branch"})
except Exception:
logger.debug("Could not push target branch %s after merge", target)
# Auto-delete source branch if requested
if auto_delete_source:
try:
await self.delete_branch(dashboard_id, source, force=True)
source_deleted = True
logger.reason(
f"Auto-deleted source branch {source} after merge",
extra={"src": "merge_branch"},
)
except Exception as e:
logger.explore(
"Auto-delete source branch failed",
extra={"src": "merge_branch"},
error=str(e),
)
return {
"source_branch": source,
"target_branch": target,
"status": "success" if merge_success else "conflicts",
"commit_hash": merge_commit_hash or None,
"conflicts": conflicts,
"error_message": error_message or None,
"source_deleted": source_deleted,
}
# endregion merge_branch
# #endregion GitServiceMergeMixin
# #endregion GitServiceMergeMixin

View File

@@ -23,7 +23,7 @@ class GitServiceGithubMixin:
# @RETURN dict
async def create_github_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: str | None = None, auto_init: bool = True, default_branch: str | None = "main",
description: str | None = None, auto_init: bool = True, default_branch: str | None = "prod",
) -> dict[str, Any]:
base_url = self._normalize_git_server_url(server_url)
if "github.com" in base_url:
@@ -67,7 +67,7 @@ class GitServiceGitlabMixin:
# @RETURN dict
async def create_gitlab_repository(
self, server_url: str, pat: str, name: str, private: bool = True,
description: str | None = None, auto_init: bool = True, default_branch: str | None = "main",
description: str | None = None, auto_init: bool = True, default_branch: str | None = "prod",
) -> dict[str, Any]:
base_url = self._normalize_git_server_url(server_url)
api_url = f"{base_url}/api/v4/projects"

View File

@@ -63,10 +63,27 @@ class GitServiceStatusMixin:
repo = await self.get_repo(dashboard_id)
has_commits = False
try:
repo.head.commit
last_commit = repo.head.commit
has_commits = True
except (ValueError, Exception):
has_commits = False
last_commit = None
last_commit_hash = None
last_commit_message = None
last_commit_author = None
last_commit_date = None
if has_commits and last_commit is not None:
try:
last_commit_hash = last_commit.hexsha
last_commit_message = (last_commit.message or "").strip()
last_commit_author = getattr(last_commit.author, "name", None)
last_commit_date = datetime.fromtimestamp(last_commit.committed_date).isoformat()
except Exception:
logger.explore(
"Could not read latest commit metadata",
extra={"src": "get_status"},
payload={"dashboard_id": dashboard_id},
)
current_branch = repo.active_branch.name
tracking_branch = None
has_upstream = False
@@ -110,6 +127,10 @@ class GitServiceStatusMixin:
"behind_count": behind_count,
"is_diverged": is_diverged,
"sync_state": sync_state,
"last_commit_hash": last_commit_hash,
"last_commit_message": last_commit_message,
"last_commit_author": last_commit_author,
"last_commit_date": last_commit_date,
}
# endregion get_status
@@ -160,5 +181,31 @@ class GitServiceStatusMixin:
return []
return commits
# endregion get_commit_history
# region rollback_commit [C:3] [TYPE Function] [SEMANTICS git,history,rollback,lock]
# @PURPOSE: Roll back one commit by creating a revert commit (concurrent-safe).
# @PRE Repository for dashboard_id exists and commit_hash identifies an existing commit.
# @POST A new revert commit is created; history is preserved.
async def rollback_commit(self, dashboard_id: int, commit_hash: str, reason: str | None = None) -> dict:
normalized_hash = str(commit_hash or "").strip()
if not normalized_hash:
raise ValueError("commit_hash is required")
with self._locked(dashboard_id):
with belief_scope("GitService.rollback_commit"):
repo = await self.get_repo(dashboard_id)
repo.commit(normalized_hash)
repo.git.revert("--no-edit", normalized_hash)
if reason:
logger.reason(
"Rollback commit created",
extra={"src": "rollback_commit"},
payload={"dashboard_id": dashboard_id, "commit_hash": normalized_hash, "reason": reason},
)
return {
"status": "success",
"reverted_commit": normalized_hash,
"rollback_commit": repo.head.commit.hexsha,
}
# endregion rollback_commit
# #endregion GitServiceStatusMixin
# #endregion GitServiceStatusMixin

View File

@@ -41,6 +41,8 @@ def _empty_dashboard_meta() -> dict[str, str | None]:
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [TaskCleanupService]
# @RELATION DEPENDS_ON -> [TaskManager]
# @RATIONALE HealthService aggregates dashboard validation records into a status summary, using a SQL subquery (max timestamp per dashboard_id) to deduplicate to the latest record per dashboard — avoiding N+1 queries while guaranteeing fresh results. Superset metadata is cached with a 60s TTL to avoid repetitive API calls during health page reloads.
# @REJECTED In-memory aggregation without SQL subquery was rejected — it would load all records into Python for sorting, scaling poorly as validation history grows. Direct eager-loading without deduplication was rejected — it returns multiple records per dashboard, confusing the UI status view.
class HealthService:
_dashboard_summary_cache: dict[
str, tuple[float, dict[str, dict[str, str | None]]]

View File

@@ -16,6 +16,8 @@
#
# @INVARIANT Providers must be stateless and resilient to network failures.
# @INVARIANT Sensitive credentials must be handled via encrypted config.
# @RATIONALE Abstract provider pattern decouples notification dispatch from business logic, enabling silent channel addition/removal without modifying callers. Each provider owns its delivery semantics and swallows transport exceptions into False — guaranteeing a uniform boolean contract across SMTP, Telegram, and Slack.
# @REJECTED Centralized notification router with per-channel try/except was rejected — it violates Open-Closed Principle, requiring router modification for every new provider. Direct SDK usage (python-telegram-bot SDK calls) in service code rejected — it couples notification logic to SDK import times and lifecycle management.
from abc import ABC, abstractmethod
from email.mime.multipart import MIMEMultipart

View File

@@ -56,6 +56,8 @@ from .security_badge_service import SecurityBadgeService
# @RELATION DEPENDS_ON -> [UserDashboardPreference]
# @PRE db session is active.
# @POST Preference CRUD operations are user-scoped and return normalized ProfilePreferenceResponse.
# @RATIONALE Isolates preference persistence (CRUD + encryption + cross-field validation) into a focused service with its own AuthRepository and EncryptionManager dependencies, keeping it decoupled from security badges and Superset lookups.
# @REJECTED Embedding preference mutations directly in API route handlers was rejected — it duplicates validation normalization logic and bypasses encryption guarantees. Inheriting from ProfileService instead of composing was rejected — it creates tight coupling and prevents independent testing.
class ProfilePreferenceService:
"""Profile preference persistence and validation."""

View File

@@ -85,6 +85,7 @@ __all__ = [
# @INVARIANT Profile data integrity maintained, cache consistency with database state
# @RATIONALE Thin coordinator — all sub-10-line delegating methods. Actual business logic lives
# in the injected sub-services.
# @REJECTED Adding business logic directly in ProfileService methods was rejected — it would defeat the decomposition purpose and cause the class to grow back toward 770 lines, violating INV_7.
class ProfileService:
"""Facade composing preference, lookup, and security services."""

View File

@@ -9,6 +9,8 @@
# @INVARIANT All resources include metadata about their current state
# @SIDE_EFFECT Queries multiple backends for status
# @DATA_CONTRACT ResourceQuery -> ResourceStatusSummary
# @RATIONALE Centralized resource gateway enriches Superset dashboards and datasets with cross-cutting metadata (Git sync status, task validation state, linked dashboard counts). Consolidating enrichment in one service avoids scatter of identical Git/task-fetching patterns across API handlers.
# @REJECTED Client-side enrichment (frontend fetching Git + task per resource) was rejected — it multiplies HTTP round-trips and leaks backend orchestration into the UI layer. Per-handler enrichment was rejected — it duplicates Git/task logic across every listing endpoint.
import asyncio
from datetime import UTC, datetime

View File

@@ -0,0 +1,620 @@
# #region Test.Git.Branch.Phase13 [C:3] [TYPE Module] [SEMANTICS test,git,merge,delete,branch,validate,classify]
# @BRIEF Tests for Phase 13 branch features: merge_branch, delete_branch, branch validation, classification.
# @RELATION BINDS_TO -> [GitServiceMergeMixin]
# @RELATION BINDS_TO -> [GitServiceBranchMixin]
# @TEST_EDGE: merge_success -> status=success, commit_hash populated
# @TEST_EDGE: merge_conflict -> status=conflicts, merge aborted, conflicts list returned
# @TEST_EDGE: merge_already_up_to_date -> status=success, no error
# @TEST_EDGE: merge_auto_delete -> source_deleted=true after successful merge
# @TEST_EDGE: merge_same_branch -> HTTPException 400
# @TEST_EDGE: merge_missing_source -> HTTPException 404
# @TEST_EDGE: merge_missing_target -> HTTPException 404
# @TEST_EDGE: delete_protected -> HTTPException 403 for dev/preprod/prod
# @TEST_EDGE: delete_force -> bypasses protection with force=True
# @TEST_EDGE: delete_active_branch -> switches to fallback before deleting
# @TEST_EDGE: delete_remote -> pushes :branch to origin
# @TEST_EDGE: validate_valid -> returns True for valid names
# @TEST_EDGE: validate_dotdot -> HTTPException 400 for ".."
# @TEST_EDGE: validate_leading_slash -> HTTPException 400 for "/feature"
# @TEST_EDGE: classify_environment -> returns "environment" for dev/preprod/prod
# @TEST_EDGE: classify_feature -> returns "feature" for feature/*
# @TEST_EDGE: classify_hotfix -> returns "hotfix" for hotfix/*
# @TEST_EDGE: classify_legacy -> returns "legacy" for main/master
import contextlib
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from fastapi import HTTPException
from git.exc import GitCommandError
from src.services.git._branch import GitServiceBranchMixin
from src.services.git._merge import GitServiceMergeMixin
# ── Testable classes ─────────────────────────────────────────────
class TestableGitMerge(GitServiceMergeMixin):
"""Testable merge mixin with mock _locked and _get_unmerged_file_paths."""
def __init__(self, mock_repo=None):
self._mock_repo = mock_repo or MagicMock()
def _locked(self, dashboard_id):
@contextlib.contextmanager
def _lock():
yield
return _lock()
def _get_unmerged_file_paths(self, repo):
return []
class TestableGitBranch(GitServiceBranchMixin):
"""Testable branch mixin with mock _locked."""
def __init__(self, mock_repo=None):
self._mock_repo = mock_repo or MagicMock()
def _locked(self, dashboard_id):
@contextlib.contextmanager
def _lock():
yield
return _lock()
# ═══════════════════════════════════════════════════════════════════
# merge_branch tests
# ═══════════════════════════════════════════════════════════════════
class TestMergeBranch:
"""merge_branch — merge source into target with conflict detection."""
# #region test_merge_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_success(self):
"""Successful merge returns status=success with commit_hash."""
repo = MagicMock()
repo.active_branch.name = "dev"
head_feature = MagicMock()
head_feature.name = "feature/test"
head_dev = MagicMock()
head_dev.name = "dev"
repo.heads = [head_feature, head_dev]
repo.head.commit.hexsha = "abc123def"
origin = MagicMock()
repo.remote.return_value = origin
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
result = await svc.merge_branch(1, "feature/test", "dev", message="merge feature")
assert result["status"] == "success"
assert result["commit_hash"] == "abc123def"
assert result["conflicts"] == []
assert result["source_deleted"] is False
origin.fetch.assert_called_once()
repo.git.merge.assert_called_once()
origin.push.assert_called_once()
repo.git.checkout.assert_called() # restored original branch
# #endregion test_merge_success
# #region test_merge_conflict [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_conflict(self):
"""Merge conflict → status=conflicts, merge aborted, conflict files returned."""
repo = MagicMock()
repo.active_branch.name = "dev"
head_feature = MagicMock()
head_feature.name = "feature/test"
head_dev = MagicMock()
head_dev.name = "dev"
repo.heads = [head_feature, head_dev]
origin = MagicMock()
repo.remote.return_value = origin
# Simulate Git "CONFLICT" during merge
repo.git.merge.side_effect = GitCommandError("merge", "CONFLICT in file.yaml")
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
# Override to return specific conflict files
svc._get_unmerged_file_paths = MagicMock(return_value=["file.yaml", "other.yaml"])
result = await svc.merge_branch(1, "feature/test", "dev")
assert result["status"] == "conflicts"
assert result["commit_hash"] is None
assert result["conflicts"] == ["file.yaml", "other.yaml"]
assert "Merge conflict" in result["error_message"]
assert result["source_deleted"] is False
# merge was called and then --abort was called
repo.git.merge.assert_any_call("feature/test", "-m", ANY)
repo.git.merge.assert_any_call("--abort")
origin.push.assert_not_called() # no push on conflict
# #endregion test_merge_conflict
# #region test_merge_already_up_to_date [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_already_up_to_date(self):
"""Already up-to-date → status=success, no error."""
repo = MagicMock()
repo.active_branch.name = "dev"
head_feature = MagicMock()
head_feature.name = "feature/test"
head_dev = MagicMock()
head_dev.name = "dev"
repo.heads = [head_feature, head_dev]
repo.head.commit.hexsha = "already123"
origin = MagicMock()
repo.remote.return_value = origin
repo.git.merge.side_effect = GitCommandError("merge", "Already up to date.")
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
result = await svc.merge_branch(1, "feature/test", "dev")
assert result["status"] == "success"
assert result["commit_hash"] == "already123"
assert result["error_message"] is None
# Remote push should still be attempted after detecting "already up to date"
origin.push.assert_called_once()
# #endregion test_merge_already_up_to_date
# #region test_merge_auto_delete [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_auto_delete(self):
"""Auto-delete source branch after successful merge."""
repo = MagicMock()
repo.active_branch.name = "dev"
head_feature = MagicMock()
head_feature.name = "feature/test"
head_dev = MagicMock()
head_dev.name = "dev"
repo.heads = [head_feature, head_dev]
repo.head.commit.hexsha = "merged123"
origin = MagicMock()
repo.remote.return_value = origin
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
with patch.object(svc, 'delete_branch', new_callable=AsyncMock, create=True) as mock_delete:
result = await svc.merge_branch(1, "feature/test", "dev", auto_delete_source=True)
assert result["status"] == "success"
assert result["source_deleted"] is True
mock_delete.assert_called_once_with(1, "feature/test", force=True)
origin.push.assert_called() # target branch pushed
# #endregion test_merge_auto_delete
# #region test_merge_same_branch [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_same_branch(self):
"""Same source and target → HTTPException 400."""
repo = MagicMock()
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.merge_branch(1, "dev", "dev")
assert exc_info.value.status_code == 400
assert "must be different" in str(exc_info.value.detail)
# #endregion test_merge_same_branch
# #region test_merge_source_not_found [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_source_not_found(self):
"""Missing source branch → HTTPException 404."""
repo = MagicMock()
repo.active_branch.name = "dev"
head_dev = MagicMock()
head_dev.name = "dev"
repo.heads = [head_dev]
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.merge_branch(1, "feature/nonexistent", "dev")
assert exc_info.value.status_code == 404
# #endregion test_merge_source_not_found
# #region test_merge_target_not_found [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_target_not_found(self):
"""Missing target branch → HTTPException 404."""
repo = MagicMock()
repo.active_branch.name = "feature/test"
head_feature = MagicMock()
head_feature.name = "feature/test"
repo.heads = [head_feature]
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.merge_branch(1, "feature/test", "nonexistent")
assert exc_info.value.status_code == 404
# #endregion test_merge_target_not_found
# #region test_merge_empty_source [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_empty_source(self):
"""Empty source branch → HTTPException 400."""
repo = MagicMock()
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.merge_branch(1, "", "dev")
assert exc_info.value.status_code == 400
# #endregion test_merge_empty_source
# #region test_merge_restores_original_branch [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_restores_original_branch(self):
"""Original branch restored in finally even after merge error."""
repo = MagicMock()
repo.active_branch.name = "hotfix/bug"
head_hotfix = MagicMock()
head_hotfix.name = "hotfix/bug"
head_dev = MagicMock()
head_dev.name = "dev"
repo.heads = [head_hotfix, head_dev]
origin = MagicMock()
repo.remote.return_value = origin
# Simulate unexpected error after checkout
def merge_side_effect(*args, **kwargs):
raise RuntimeError("disk full")
repo.git.merge.side_effect = merge_side_effect
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.merge_branch(1, "hotfix/bug", "dev")
assert exc_info.value.status_code == 500
# Verify original branch was restored (last checkout call should be to original branch)
# Note: calls include checkout(target), then checkout(original) in finally
assert repo.git.checkout.called
# #endregion test_merge_restores_original_branch
# ═══════════════════════════════════════════════════════════════════
# delete_branch tests
# ═══════════════════════════════════════════════════════════════════
class TestDeleteBranch:
"""delete_branch — delete with environment branch protection."""
# #region test_delete_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_success(self):
"""Successful delete removes local and remote branch."""
repo = MagicMock()
repo.active_branch.name = "dev"
head_feature = MagicMock()
head_feature.name = "feature/to-delete"
head_dev = MagicMock()
head_dev.name = "dev"
repo.heads = [head_feature, head_dev]
origin = MagicMock()
repo.remote.return_value = origin
svc = TestableGitBranch(repo)
svc.get_repo = AsyncMock(return_value=repo)
result = await svc.delete_branch(1, "feature/to-delete")
assert result["status"] == "deleted"
assert result["branch_name"] == "feature/to-delete"
repo.delete_head.assert_called_once_with("feature/to-delete")
origin.push.assert_called_once_with(refspec=":feature/to-delete")
# #endregion test_delete_success
# #region test_delete_protected_dev [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_protected_dev(self):
"""Protected 'dev' → HTTPException 403."""
repo = MagicMock()
head_dev = MagicMock(); head_dev.name = "dev"
head_prod = MagicMock(); head_prod.name = "prod"
repo.heads = [head_dev, head_prod]
svc = TestableGitBranch(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.delete_branch(1, "dev")
assert exc_info.value.status_code == 403
repo.delete_head.assert_not_called()
# #endregion test_delete_protected_dev
# #region test_delete_protected_prod [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_protected_prod(self):
"""Protected 'prod' → HTTPException 403."""
repo = MagicMock()
head_dev = MagicMock(); head_dev.name = "dev"
head_prod = MagicMock(); head_prod.name = "prod"
repo.heads = [head_dev, head_prod]
svc = TestableGitBranch(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.delete_branch(1, "prod")
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_delete_protected_preprod(self):
"""Protected 'preprod' → HTTPException 403."""
repo = MagicMock()
head_dev = MagicMock(); head_dev.name = "dev"
head_pre = MagicMock(); head_pre.name = "preprod"
repo.heads = [head_dev, head_pre]
svc = TestableGitBranch(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.delete_branch(1, "preprod")
assert exc_info.value.status_code == 403
# #region test_delete_force_bypasses_protection [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_force_bypasses_protection(self):
"""Force=True bypasses environment branch protection."""
repo = MagicMock()
repo.active_branch.name = "prod"
head_prod = MagicMock(); head_prod.name = "prod"
head_dev = MagicMock(); head_dev.name = "dev"
repo.heads = [head_prod, head_dev]
origin = MagicMock()
repo.remote.return_value = origin
svc = TestableGitBranch(repo)
svc.get_repo = AsyncMock(return_value=repo)
result = await svc.delete_branch(1, "prod", force=True)
assert result["status"] == "deleted"
repo.delete_head.assert_called_once()
# #endregion test_delete_force_bypasses_protection
# #region test_delete_active_branch [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_active_branch(self):
"""Active branch → switches to fallback before delete."""
repo = MagicMock()
repo.active_branch.name = "feature/old"
head_feature = MagicMock(); head_feature.name = "feature/old"
head_dev = MagicMock(); head_dev.name = "dev"
repo.heads = [head_feature, head_dev]
origin = MagicMock()
repo.remote.return_value = origin
svc = TestableGitBranch(repo)
svc.get_repo = AsyncMock(return_value=repo)
result = await svc.delete_branch(1, "feature/old")
assert result["status"] == "deleted"
repo.git.checkout.assert_called_with("dev")
repo.delete_head.assert_called_once_with("feature/old")
# #endregion test_delete_active_branch
# #region test_delete_not_found [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_not_found(self):
"""Missing branch → HTTPException 404."""
repo = MagicMock()
head_dev = MagicMock(); head_dev.name = "dev"
repo.heads = [head_dev]
svc = TestableGitBranch(repo)
svc.get_repo = AsyncMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
await svc.delete_branch(1, "nonexistent")
assert exc_info.value.status_code == 404
# #endregion test_delete_not_found
# #region test_delete_no_origin_does_not_fail [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_no_origin_does_not_fail(self):
"""No origin remote → local delete succeeds, remote skipped gracefully."""
repo = MagicMock()
repo.active_branch.name = "dev"
head_feature = MagicMock(); head_feature.name = "feature/test"
head_dev = MagicMock(); head_dev.name = "dev"
repo.heads = [head_feature, head_dev]
repo.remote.side_effect = ValueError("no origin")
svc = TestableGitBranch(repo)
svc.get_repo = AsyncMock(return_value=repo)
result = await svc.delete_branch(1, "feature/test")
assert result["status"] == "deleted"
repo.delete_head.assert_called_once()
# #endregion test_delete_no_origin_does_not_fail
# ═══════════════════════════════════════════════════════════════════
# validate_branch_name tests
# ═══════════════════════════════════════════════════════════════════
class TestValidateBranchName:
"""validate_branch_name — regex validation for branch names."""
# #region test_valid_simple [C:2] [TYPE Function]
def test_valid_simple(self):
"""Valid simple name returns True."""
assert GitServiceBranchMixin.validate_branch_name("feature") is True
# #endregion test_valid_simple
# #region test_valid_slash [C:2] [TYPE Function]
def test_valid_slash(self):
"""Valid name with slashes."""
assert GitServiceBranchMixin.validate_branch_name("feature/my-new-feature") is True
# #endregion test_valid_slash
# #region test_valid_hyphen_underscore [C:2] [TYPE Function]
def test_valid_hyphen_underscore(self):
"""Valid name with hyphens and underscores."""
assert GitServiceBranchMixin.validate_branch_name("hotfix/bug-123_v2") is True
# #endregion test_valid_hyphen_underscore
# #region test_invalid_dotdot [C:2] [TYPE Function]
def test_invalid_dotdot(self):
"""Name with '..' → HTTPException 400."""
with pytest.raises(HTTPException) as exc_info:
GitServiceBranchMixin.validate_branch_name("feature/../escape")
assert exc_info.value.status_code == 400
assert ".." in str(exc_info.value.detail)
# #endregion test_invalid_dotdot
# #region test_invalid_leading_slash [C:2] [TYPE Function]
def test_invalid_leading_slash(self):
"""Leading '/' → HTTPException 400."""
with pytest.raises(HTTPException) as exc_info:
GitServiceBranchMixin.validate_branch_name("/feature/test")
assert exc_info.value.status_code == 400
# #endregion test_invalid_leading_slash
# #region test_invalid_trailing_slash [C:2] [TYPE Function]
def test_invalid_trailing_slash(self):
"""Trailing '/' → HTTPException 400."""
with pytest.raises(HTTPException) as exc_info:
GitServiceBranchMixin.validate_branch_name("feature/test/")
assert exc_info.value.status_code == 400
# #endregion test_invalid_trailing_slash
# #region test_invalid_leading_hyphen [C:2] [TYPE Function]
def test_invalid_leading_hyphen(self):
"""Leading '-' → HTTPException 400."""
with pytest.raises(HTTPException) as exc_info:
GitServiceBranchMixin.validate_branch_name("-feature")
assert exc_info.value.status_code == 400
# #endregion test_invalid_leading_hyphen
# #region test_invalid_empty [C:2] [TYPE Function]
def test_invalid_empty(self):
"""Empty string → HTTPException 400."""
with pytest.raises(HTTPException) as exc_info:
GitServiceBranchMixin.validate_branch_name("")
assert exc_info.value.status_code == 400
# #endregion test_invalid_empty
# #region test_invalid_spaces [C:2] [TYPE Function]
def test_invalid_spaces(self):
"""Spaces in name → HTTPException 400."""
with pytest.raises(HTTPException) as exc_info:
GitServiceBranchMixin.validate_branch_name("feature with spaces")
assert exc_info.value.status_code == 400
# #endregion test_invalid_spaces
# #region test_invalid_too_long [C:2] [TYPE Function]
def test_invalid_too_long(self):
"""Name > 255 chars → HTTPException 400."""
long_name = "a" * 256
with pytest.raises(HTTPException) as exc_info:
GitServiceBranchMixin.validate_branch_name(long_name)
assert exc_info.value.status_code == 400
# #endregion test_invalid_too_long
# ═══════════════════════════════════════════════════════════════════
# _classify_branch_type tests
# ═══════════════════════════════════════════════════════════════════
class TestClassifyBranchType:
"""_classify_branch_type — branch name to semantic type."""
# #region test_classify_dev [C:2] [TYPE Function]
def test_classify_dev(self):
"""'dev' → 'environment'."""
assert GitServiceBranchMixin._classify_branch_type("dev") == "environment"
# #endregion test_classify_dev
# #region test_classify_preprod [C:2] [TYPE Function]
def test_classify_preprod(self):
"""'preprod' → 'environment'."""
assert GitServiceBranchMixin._classify_branch_type("preprod") == "environment"
# #endregion test_classify_preprod
# #region test_classify_prod [C:2] [TYPE Function]
def test_classify_prod(self):
"""'prod' → 'environment'."""
assert GitServiceBranchMixin._classify_branch_type("prod") == "environment"
# #endregion test_classify_prod
# #region test_classify_feature [C:2] [TYPE Function]
def test_classify_feature(self):
"""'feature/auth' → 'feature'."""
assert GitServiceBranchMixin._classify_branch_type("feature/auth") == "feature"
# #endregion test_classify_feature
# #region test_classify_hotfix [C:2] [TYPE Function]
def test_classify_hotfix(self):
"""'hotfix/bug-123' → 'hotfix'."""
assert GitServiceBranchMixin._classify_branch_type("hotfix/bug-123") == "hotfix"
# #endregion test_classify_hotfix
# #region test_classify_bugfix [C:2] [TYPE Function]
def test_classify_bugfix(self):
"""'bugfix/minor-fix' → 'bugfix'."""
assert GitServiceBranchMixin._classify_branch_type("bugfix/minor-fix") == "bugfix"
# #endregion test_classify_bugfix
# #region test_classify_legacy_main [C:2] [TYPE Function]
def test_classify_legacy_main(self):
"""'main' → 'legacy'."""
assert GitServiceBranchMixin._classify_branch_type("main") == "legacy"
# #endregion test_classify_legacy_main
# #region test_classify_legacy_master [C:2] [TYPE Function]
def test_classify_legacy_master(self):
"""'master' → 'legacy'."""
assert GitServiceBranchMixin._classify_branch_type("master") == "legacy"
# #endregion test_classify_legacy_master
# #region test_classify_remote_ref [C:2] [TYPE Function]
def test_classify_remote_ref(self):
"""Remote refs flagged as 'remote_ref'."""
assert GitServiceBranchMixin._classify_branch_type("origin/dev", True) == "remote_ref"
assert GitServiceBranchMixin._classify_branch_type("origin/feature/test") == "remote_ref"
# #endregion test_classify_remote_ref
# #region test_classify_other [C:2] [TYPE Function]
def test_classify_other(self):
"""Unclassified name → 'other'."""
assert GitServiceBranchMixin._classify_branch_type("random-branch") == "other"
# #endregion test_classify_other
# ═══════════════════════════════════════════════════════════════════
# get_branch_protection_rules tests
# ═══════════════════════════════════════════════════════════════════
class TestGetBranchProtectionRules:
"""get_branch_protection_rules — returns protection config for env branches."""
# #region test_returns_rules_for_all_env_branches [C:2] [TYPE Function]
def test_returns_rules_for_all_env_branches(self):
"""Returns protection rules for dev, preprod, and prod."""
rules = GitServiceBranchMixin.get_branch_protection_rules()
branch_names = {r["branch_name"] for r in rules}
assert branch_names == {"dev", "preprod", "prod"}
for rule in rules:
assert rule["allow_direct_delete"] is False
assert rule["allow_force_push"] is False
# #endregion test_returns_rules_for_all_env_branches
# #endregion Test.Git.Branch.Phase13

View File

@@ -150,7 +150,7 @@ class TestCreateGitHubRepository:
assert call_json["name"] == "my-repo"
assert call_json["private"] is True
assert call_json["auto_init"] is True
assert call_json["default_branch"] == "main"
assert call_json["default_branch"] == "prod"
# #endregion test_github_repo_default_params
@@ -248,7 +248,7 @@ class TestCreateGitLabMergeRequest:
result = await svc.create_gitlab_merge_request(
"https://gitlab.com", "pat",
"https://gitlab.com/org/repo.git",
"feature", "main", "Add feature", description="desc",
"feature", "prod", "Add feature", description="desc",
)
assert result["id"] == 7
assert result["status"] == "opened"
@@ -270,7 +270,7 @@ class TestCreateGitLabMergeRequest:
await svc.create_gitlab_merge_request(
"https://gitlab.com", "pat",
"https://gitlab.com/org/repo.git",
"bad-branch", "main", "Title",
"bad-branch", "prod", "Title",
)
# #endregion test_gitlab_mr_api_error
@@ -285,7 +285,7 @@ class TestCreateGitLabMergeRequest:
await svc.create_gitlab_merge_request(
"https://gitlab.com", "pat",
"https://gitlab.com/org/repo.git",
"feature", "main", "Title",
"feature", "prod", "Title",
)
assert exc_info.value.status_code == 503
# #endregion test_gitlab_mr_network_error

View File

@@ -182,6 +182,8 @@ class TestGetStatus:
assert svc._lock_called is True
assert result["current_branch"] == "main"
assert result["has_upstream"] is False
assert result["last_commit_hash"] is None
assert result["last_commit_author"] is None
# #endregion test_get_status_no_commits
# #region test_get_status_diverged [C:2] [TYPE Function]
@@ -243,7 +245,12 @@ class TestGetStatus:
"""ahead=0, behind>0 → BEHIND_REMOTE."""
repo = MagicMock()
head = MagicMock()
head.commit = MagicMock()
head.commit = MagicMock(
hexsha="abcdef1234567890",
message="Update BI dashboard\n\nDetails",
committed_date=1710000000,
)
head.commit.author.name = "BI Analyst"
repo.head = head
repo.active_branch.name = "main"
tracking = MagicMock()
@@ -289,7 +296,12 @@ class TestGetStatus:
"""Clean, no divergence → SYNCED."""
repo = MagicMock()
head = MagicMock()
head.commit = MagicMock()
head.commit = MagicMock(
hexsha="abcdef1234567890",
message="Update BI dashboard\n\nDetails",
committed_date=1710000000,
)
head.commit.author.name = "BI Analyst"
repo.head = head
repo.active_branch.name = "main"
repo.active_branch.tracking_branch.return_value = None
@@ -300,6 +312,10 @@ class TestGetStatus:
assert result["sync_state"] == "SYNCED"
assert result["is_dirty"] is False
assert result["last_commit_hash"] == "abcdef1234567890"
assert result["last_commit_message"] == "Update BI dashboard\n\nDetails"
assert result["last_commit_author"] == "BI Analyst"
assert result["last_commit_date"].startswith("2024-03-09T")
# #endregion test_get_status_synced
# #region test_get_status_tracking_exception [C:2] [TYPE Function]
@@ -342,6 +358,28 @@ class TestGetStatus:
# #endregion test_get_status_iter_commits_exception
# ── rollback_commit ──
class TestRollbackCommit:
"""rollback_commit — creates a revert commit without rewriting history."""
# #region test_rollback_commit_reverts_target [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_rollback_commit_reverts_target(self):
repo = MagicMock()
repo.head.commit.hexsha = "rollback123"
svc = TestableGitStatus(repo)
result = await svc.rollback_commit(42, "abcdef1", "bad PROD deploy")
repo.commit.assert_called_once_with("abcdef1")
repo.git.revert.assert_called_once_with("--no-edit", "abcdef1")
assert result["status"] == "success"
assert result["reverted_commit"] == "abcdef1"
assert result["rollback_commit"] == "rollback123"
# #endregion test_rollback_commit_reverts_target
# ── get_diff ──
class TestGetDiff:

View File

@@ -63,9 +63,9 @@ class TestGitServerConfig:
obj = GitServerConfig(
id="cfg-2", name="Test", provider=GitProvider.GITLAB,
url="https://gitlab.com/org", pat="abc",
default_branch="main", status=GitStatus.CONNECTED,
default_branch="prod", status=GitStatus.CONNECTED,
)
assert obj.default_branch == "main"
assert obj.default_branch == "prod"
def test_with_repository(self):
from src.models.git import GitProvider, GitServerConfig, GitStatus