From 07ec2d9797c68165eaf5a8d12d93fa4f06a17999 Mon Sep 17 00:00:00 2001 From: busya Date: Fri, 23 Jan 2026 13:57:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B9=20enviroment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + .kilocode/rules/specify-rules.md | 2 +- backend/backend/git_repos/12 | 1 + backend/mappings.db | Bin 45056 -> 73728 bytes backend/requirements.txt | 3 +- backend/src/api/routes/__init__.py | 2 +- backend/src/api/routes/environments.py | 2 +- backend/src/api/routes/git.py | 303 ++++++++++++++ backend/src/api/routes/git_schemas.py | 130 ++++++ backend/src/app.py | 3 +- backend/src/core/database.py | 1 + backend/src/models/git.py | 73 ++++ backend/src/plugins/git_plugin.py | 345 ++++++++++++++++ backend/src/services/git_service.py | 380 ++++++++++++++++++ backend/tasks.db | Bin 57344 -> 86016 bytes frontend/.eslintignore | 7 + frontend/.prettierignore | 9 + frontend/src/components/DashboardGrid.svelte | 36 ++ frontend/src/components/Navbar.svelte | 8 + .../src/components/git/BranchSelector.svelte | 170 ++++++++ .../src/components/git/CommitHistory.svelte | 90 +++++ .../src/components/git/CommitModal.svelte | 175 ++++++++ .../components/git/ConflictResolver.svelte | 142 +++++++ .../src/components/git/DeploymentModal.svelte | 147 +++++++ frontend/src/components/git/GitManager.svelte | 284 +++++++++++++ frontend/src/routes/+page.svelte | 2 + frontend/src/routes/git/+page.svelte | 86 ++++ .../routes/settings/environments/+page.svelte | 40 ++ frontend/src/routes/settings/git/+page.svelte | 136 +++++++ frontend/src/services/gitService.js | 325 +++++++++++++++ reproduce_issue.py | 21 - .../contracts/api.md | 111 +++-- .../data-model.md | 110 ++--- specs/011-git-integration-dashboard/plan.md | 48 ++- .../quickstart.md | 67 +-- .../011-git-integration-dashboard/research.md | 90 +++-- specs/011-git-integration-dashboard/tasks.md | 127 +++--- 37 files changed, 3227 insertions(+), 252 deletions(-) create mode 160000 backend/backend/git_repos/12 create mode 100644 backend/src/api/routes/git.py create mode 100644 backend/src/api/routes/git_schemas.py create mode 100644 backend/src/models/git.py create mode 100644 backend/src/plugins/git_plugin.py create mode 100644 backend/src/services/git_service.py create mode 100644 frontend/.eslintignore create mode 100644 frontend/.prettierignore create mode 100644 frontend/src/components/git/BranchSelector.svelte create mode 100644 frontend/src/components/git/CommitHistory.svelte create mode 100644 frontend/src/components/git/CommitModal.svelte create mode 100644 frontend/src/components/git/ConflictResolver.svelte create mode 100644 frontend/src/components/git/DeploymentModal.svelte create mode 100644 frontend/src/components/git/GitManager.svelte create mode 100644 frontend/src/routes/git/+page.svelte create mode 100644 frontend/src/routes/settings/environments/+page.svelte create mode 100644 frontend/src/routes/settings/git/+page.svelte create mode 100644 frontend/src/services/gitService.js delete mode 100644 reproduce_issue.py diff --git a/.gitignore b/.gitignore index 4571905..2db890f 100755 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ backend/mappings.db backend/tasks.db + +# Git Integration repositories +backend/git_repos/ diff --git a/.kilocode/rules/specify-rules.md b/.kilocode/rules/specify-rules.md index 89ccdc5..1cb51fb 100644 --- a/.kilocode/rules/specify-rules.md +++ b/.kilocode/rules/specify-rules.md @@ -46,8 +46,8 @@ Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions ## Recent Changes - 011-git-integration-dashboard: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API +- 011-git-integration-dashboard: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API - 011-git-integration-dashboard: Added Python 3.9+, Node.js 18+ -- 012-remove-superset-tool: Added Python 3.9+ + FastAPI, Pydantic, requests, pyyaml (migrated from superset_tool) diff --git a/backend/backend/git_repos/12 b/backend/backend/git_repos/12 new file mode 160000 index 0000000..d592fa7 --- /dev/null +++ b/backend/backend/git_repos/12 @@ -0,0 +1 @@ +Subproject commit d592fa7ed5420d6132c208acd93b8b5c8ead3f27 diff --git a/backend/mappings.db b/backend/mappings.db index 1a8a6ed5edaf25749f00526e96e1fd06405d8f5a..1050cdd83a1e2eca6bf4b8a5b2119783b3dcc7f6 100644 GIT binary patch delta 1725 zcma)6J#5=X6eb;2G8I|00w;8u6yVSz5)ew_QKY4K2o#lZg~(LGNNpU{AdbAFO;i?D zk`mx9%1^tu)sVHL*ECy~X6f2FV~1qUS|BLUqf`qaRRtzQfO_}6yZgTHz4z#^l}9fV zKVHdgP!x56yr<9VPuHc=237p-+coxYD!1~C%KpRto_Uk~?ffU16UIotPMt6xCpS`7 z;`v8^tUOB$5~b&lnU(R=i)@M$1p0^l0Gd5~-~@(;2d?h~uIFGs{(rrptM!h`cj~uV zDj#3v^J&IG{FimT@kL$FOG<&)+8ti&wp!QuG=rdjXu8lt1_3p-j=H0c^$5ap`?hls zE-%}|!$)p_jiJ|@Lx3zt&*`}q>=^?X94^OLL(jwgplFyL>|0S_cvt_pZyA07gP}j) zq*|C1vzrgQTJv64<@4{;DTFz<+q&A^(fB*+UVhG`!0YO5Rado!dVeaIkG&E0j2Xb^ z^A0Mc3paB~?!GYLnax66R0G5#7jCYfqQ~(yLySjqPjYK1ZhM=4TANyo@Sx`&9})5f z?vEVL?T6p~l5jR+_yTBimb_^Eq{Uty=NGK;hXd^SIQX!Eb5!7dgZp#+7j|=e16qMI z!u+jvyQS8((1p8tbGNSV5f>)Eq?4=Z!frOn!R^V*?B((QQ#Zr~UNc(klh*kZCyMm< zjd^!O?F+?@Mlun>qU)DPaTCYmO|nF*f#;4KguOZcSUY{N2T1kL^A>bN*oMR2d!2sF zcTO8F1Wy|vTKlzWum}CX7{Q){V1N<-IT;0==B^r*eeb`TZe%7`nXA{L5`+rOYBVa_ z+KZFagOi`ji6qV16l=f8t$)pqA9FO*<+$zrwJY_SY+1Hh1%fPX*+K=^ETLv1D43Qc zR!t1ds$!C}Fx+W&)cRo%4E!6V(lk;uv5Eb|qBlee+ES2JuGykgv9};XRnr8rC6V5N`m9{ECviB`Hdpj9!|3MX|5QRsUr3*UojB zvt04l^#o5i9#HH7N!%wKFVU-Ay1Or5^s8n NEt{gK#5tPD{0j*J0|o#9 delta 73 zcmZoTz|!!5X@ayMHv*&AZU()oLSFtK3|#Ch82JA3@8@~Sw~JerX9Z_G V*Aw;?oKl+w1vJ< src.services.git_service.GitService +# @RELATION: USES -> src.api.routes.git_schemas +# @RELATION: USES -> src.models.git +# +# @INVARIANT: All Git operations must be routed through GitService. + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List, Optional +import typing +from src.dependencies import get_config_manager +from src.core.database import get_db +from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository +from src.api.routes.git_schemas import ( + GitServerConfigSchema, GitServerConfigCreate, + GitRepositorySchema, BranchSchema, BranchCreate, + BranchCheckout, CommitSchema, CommitCreate, + DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest +) +from src.services.git_service import GitService +from src.core.logger import logger, belief_scope + +router = APIRouter(prefix="/api/git", tags=["git"]) +git_service = GitService() + +# [DEF:get_git_configs:Function] +# @PURPOSE: List all configured Git servers. +# @RETURN: List[GitServerConfigSchema] +@router.get("/config", response_model=List[GitServerConfigSchema]) +async def get_git_configs(db: Session = Depends(get_db)): + with belief_scope("get_git_configs"): + return db.query(GitServerConfig).all() +# [/DEF:get_git_configs:Function] + +# [DEF:create_git_config:Function] +# @PURPOSE: Register a new Git server configuration. +# @PARAM: config (GitServerConfigCreate) +# @RETURN: GitServerConfigSchema +@router.post("/config", response_model=GitServerConfigSchema) +async def create_git_config(config: GitServerConfigCreate, db: Session = Depends(get_db)): + with belief_scope("create_git_config"): + db_config = GitServerConfig(**config.dict()) + db.add(db_config) + db.commit() + db.refresh(db_config) + return db_config +# [/DEF:create_git_config:Function] + +# [DEF:delete_git_config:Function] +# @PURPOSE: Remove a Git server configuration. +# @PARAM: config_id (str) +@router.delete("/config/{config_id}") +async def delete_git_config(config_id: str, db: Session = Depends(get_db)): + with belief_scope("delete_git_config"): + db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first() + if not db_config: + raise HTTPException(status_code=404, detail="Configuration not found") + + db.delete(db_config) + db.commit() + return {"status": "success", "message": "Configuration deleted"} +# [/DEF:delete_git_config:Function] + +# [DEF:test_git_config:Function] +# @PURPOSE: Validate connection to a Git server using provided credentials. +# @PARAM: config (GitServerConfigCreate) +@router.post("/config/test") +async def test_git_config(config: GitServerConfigCreate): + with belief_scope("test_git_config"): + success = await git_service.test_connection(config.provider, config.url, config.pat) + if success: + return {"status": "success", "message": "Connection successful"} + else: + raise HTTPException(status_code=400, detail="Connection failed") +# [/DEF:test_git_config:Function] + +# [DEF:init_repository:Function] +# @PURPOSE: Link a dashboard to a Git repository and perform initial clone/init. +# @PARAM: dashboard_id (int) +# @PARAM: init_data (RepoInitRequest) +@router.post("/repositories/{dashboard_id}/init") +async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Session = Depends(get_db)): + with belief_scope("init_repository"): + # 1. Get config + config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first() + if not config: + raise HTTPException(status_code=404, detail="Git configuration not found") + + try: + # 2. Perform Git clone/init + logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}") + git_service.init_repo(dashboard_id, init_data.remote_url, config.pat) + + # 3. Save to DB + repo_path = git_service._get_repo_path(dashboard_id) + db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first() + if not db_repo: + db_repo = GitRepository( + dashboard_id=dashboard_id, + config_id=config.id, + remote_url=init_data.remote_url, + local_path=repo_path + ) + db.add(db_repo) + else: + db_repo.config_id = config.id + db_repo.remote_url = init_data.remote_url + db_repo.local_path = repo_path + + db.commit() + logger.info(f"[init_repository][Coherence:OK] Repository initialized for dashboard {dashboard_id}") + return {"status": "success", "message": "Repository initialized"} + except Exception as e: + db.rollback() + logger.error(f"[init_repository][Coherence:Failed] Failed to init repository: {e}") + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:init_repository:Function] + +# [DEF:get_branches:Function] +# @PURPOSE: List all branches for a dashboard's repository. +# @PARAM: dashboard_id (int) +# @RETURN: List[BranchSchema] +@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema]) +async def get_branches(dashboard_id: int): + with belief_scope("get_branches"): + try: + return git_service.list_branches(dashboard_id) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) +# [/DEF:get_branches:Function] + +# [DEF:create_branch:Function] +# @PURPOSE: Create a new branch in the dashboard's repository. +# @PARAM: dashboard_id (int) +# @PARAM: branch_data (BranchCreate) +@router.post("/repositories/{dashboard_id}/branches") +async def create_branch(dashboard_id: int, branch_data: BranchCreate): + with belief_scope("create_branch"): + try: + git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:create_branch:Function] + +# [DEF:checkout_branch:Function] +# @PURPOSE: Switch the dashboard's repository to a specific branch. +# @PARAM: dashboard_id (int) +# @PARAM: checkout_data (BranchCheckout) +@router.post("/repositories/{dashboard_id}/checkout") +async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout): + with belief_scope("checkout_branch"): + try: + git_service.checkout_branch(dashboard_id, checkout_data.name) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:checkout_branch:Function] + +# [DEF:commit_changes:Function] +# @PURPOSE: Stage and commit changes in the dashboard's repository. +# @PARAM: dashboard_id (int) +# @PARAM: commit_data (CommitCreate) +@router.post("/repositories/{dashboard_id}/commit") +async def commit_changes(dashboard_id: int, commit_data: CommitCreate): + with belief_scope("commit_changes"): + try: + git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:commit_changes:Function] + +# [DEF:push_changes:Function] +# @PURPOSE: Push local commits to the remote repository. +# @PARAM: dashboard_id (int) +@router.post("/repositories/{dashboard_id}/push") +async def push_changes(dashboard_id: int): + with belief_scope("push_changes"): + try: + git_service.push_changes(dashboard_id) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:push_changes:Function] + +# [DEF:pull_changes:Function] +# @PURPOSE: Pull changes from the remote repository. +# @PARAM: dashboard_id (int) +@router.post("/repositories/{dashboard_id}/pull") +async def pull_changes(dashboard_id: int): + with belief_scope("pull_changes"): + try: + git_service.pull_changes(dashboard_id) + return {"status": "success"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:pull_changes:Function] + +# [DEF:sync_dashboard:Function] +# @PURPOSE: Sync dashboard state from Superset to Git using the GitPlugin. +# @PARAM: dashboard_id (int) +# @PARAM: source_env_id (Optional[str]) +@router.post("/repositories/{dashboard_id}/sync") +async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] = None): + with belief_scope("sync_dashboard"): + try: + from src.plugins.git_plugin import GitPlugin + plugin = GitPlugin() + return await plugin.execute({ + "operation": "sync", + "dashboard_id": dashboard_id, + "source_env_id": source_env_id + }) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:sync_dashboard:Function] + +# [DEF:get_environments:Function] +# @PURPOSE: List all deployment environments. +# @RETURN: List[DeploymentEnvironmentSchema] +@router.get("/environments", response_model=List[DeploymentEnvironmentSchema]) +async def get_environments(config_manager=Depends(get_config_manager)): + with belief_scope("get_environments"): + envs = config_manager.get_environments() + return [ + DeploymentEnvironmentSchema( + id=e.id, + name=e.name, + superset_url=e.url, + is_active=True + ) for e in envs + ] +# [/DEF:get_environments:Function] + +# [DEF:deploy_dashboard:Function] +# @PURPOSE: Deploy dashboard from Git to a target environment. +# @PARAM: dashboard_id (int) +# @PARAM: deploy_data (DeployRequest) +@router.post("/repositories/{dashboard_id}/deploy") +async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest): + with belief_scope("deploy_dashboard"): + try: + from src.plugins.git_plugin import GitPlugin + plugin = GitPlugin() + return await plugin.execute({ + "operation": "deploy", + "dashboard_id": dashboard_id, + "environment_id": deploy_data.environment_id + }) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:deploy_dashboard:Function] + +# [DEF:get_history:Function] +# @PURPOSE: View commit history for a dashboard's repository. +# @PARAM: dashboard_id (int) +# @PARAM: limit (int) +# @RETURN: List[CommitSchema] +@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema]) +async def get_history(dashboard_id: int, limit: int = 50): + with belief_scope("get_history"): + try: + return git_service.get_commit_history(dashboard_id, limit) + except Exception as e: + raise HTTPException(status_code=404, detail=str(e)) +# [/DEF:get_history:Function] + +# [DEF:get_repository_status:Function] +# @PURPOSE: Get current Git status for a dashboard repository. +# @PARAM: dashboard_id (int) +# @RETURN: dict +@router.get("/repositories/{dashboard_id}/status") +async def get_repository_status(dashboard_id: int): + with belief_scope("get_repository_status"): + try: + return git_service.get_status(dashboard_id) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:get_repository_status:Function] + +# [DEF:get_repository_diff:Function] +# @PURPOSE: Get Git diff for a dashboard repository. +# @PARAM: dashboard_id (int) +# @PARAM: file_path (Optional[str]) +# @PARAM: staged (bool) +# @RETURN: str +@router.get("/repositories/{dashboard_id}/diff") +async def get_repository_diff(dashboard_id: int, file_path: Optional[str] = None, staged: bool = False): + with belief_scope("get_repository_diff"): + try: + diff_text = git_service.get_diff(dashboard_id, file_path, staged) + return diff_text + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) +# [/DEF:get_repository_diff:Function] + +# [/DEF:backend.src.api.routes.git:Module] \ No newline at end of file diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py new file mode 100644 index 0000000..9b362ae --- /dev/null +++ b/backend/src/api/routes/git_schemas.py @@ -0,0 +1,130 @@ +# [DEF:backend.src.api.routes.git_schemas:Module] +# +# @SEMANTICS: git, schemas, pydantic, api, contracts +# @PURPOSE: Defines Pydantic models for the Git integration API layer. +# @LAYER: API +# @RELATION: DEPENDS_ON -> backend.src.models.git +# +# @INVARIANT: All schemas must be compatible with the FastAPI router. + +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime +from uuid import UUID +from src.models.git import GitProvider, GitStatus, SyncStatus + +# [DEF:GitServerConfigBase:Class] +class GitServerConfigBase(BaseModel): + name: str = Field(..., description="Display name for the Git server") + provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)") + url: str = Field(..., description="Server base URL") + pat: str = Field(..., description="Personal Access Token") + default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)") +# [/DEF:GitServerConfigBase:Class] + +# [DEF:GitServerConfigCreate:Class] +class GitServerConfigCreate(GitServerConfigBase): + """Schema for creating a new Git server configuration.""" + pass +# [/DEF:GitServerConfigCreate:Class] + +# [DEF:GitServerConfigSchema:Class] +class GitServerConfigSchema(GitServerConfigBase): + """Schema for representing a Git server configuration with metadata.""" + id: str + status: GitStatus + last_validated: datetime + + class Config: + from_attributes = True +# [/DEF:GitServerConfigSchema:Class] + +# [DEF:GitRepositorySchema:Class] +class GitRepositorySchema(BaseModel): + """Schema for tracking a local Git repository linked to a dashboard.""" + id: str + dashboard_id: int + config_id: str + remote_url: str + local_path: str + current_branch: str + sync_status: SyncStatus + + class Config: + from_attributes = True +# [/DEF:GitRepositorySchema:Class] + +# [DEF:BranchSchema:Class] +class BranchSchema(BaseModel): + """Schema for representing a Git branch.""" + name: str + commit_hash: str + is_remote: bool + last_updated: datetime +# [/DEF:BranchSchema:Class] + +# [DEF:CommitSchema:Class] +class CommitSchema(BaseModel): + """Schema for representing a Git commit.""" + hash: str + author: str + email: str + timestamp: datetime + message: str + files_changed: List[str] +# [/DEF:CommitSchema:Class] + +# [DEF:BranchCreate:Class] +class BranchCreate(BaseModel): + """Schema for branch creation requests.""" + name: str + from_branch: str +# [/DEF:BranchCreate:Class] + +# [DEF:BranchCheckout:Class] +class BranchCheckout(BaseModel): + """Schema for branch checkout requests.""" + name: str +# [/DEF:BranchCheckout:Class] + +# [DEF:CommitCreate:Class] +class CommitCreate(BaseModel): + """Schema for staging and committing changes.""" + message: str + files: List[str] +# [/DEF:CommitCreate:Class] + +# [DEF:ConflictResolution:Class] +class ConflictResolution(BaseModel): + """Schema for resolving merge conflicts.""" + file_path: str + resolution: str = Field(pattern="^(mine|theirs|manual)$") + content: Optional[str] = None +# [/DEF:ConflictResolution:Class] + +# [DEF:DeploymentEnvironmentSchema:Class] +class DeploymentEnvironmentSchema(BaseModel): + """Schema for representing a target deployment environment.""" + id: str + name: str + superset_url: str + is_active: bool + + class Config: + from_attributes = True +# [/DEF:DeploymentEnvironmentSchema:Class] + +# [DEF:DeployRequest:Class] +class DeployRequest(BaseModel): + """Schema for deployment requests.""" + environment_id: str +# [/DEF:DeployRequest:Class] + +# [DEF:RepoInitRequest:Class] +class RepoInitRequest(BaseModel): + """Schema for repository initialization requests.""" + config_id: str + remote_url: str +# [/DEF:RepoInitRequest:Class] + +# [/DEF:backend.src.api.routes.git_schemas:Module] \ No newline at end of file diff --git a/backend/src/app.py b/backend/src/app.py index 5e07840..ae6c496 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -18,7 +18,7 @@ import os from .dependencies import get_task_manager, get_scheduler_service from .core.logger import logger, belief_scope -from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections +from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git from .core.database import init_db # [DEF:App:Global] @@ -88,6 +88,7 @@ app.include_router(connections.router, prefix="/api/settings/connections", tags= app.include_router(environments.router, prefix="/api/environments", tags=["Environments"]) app.include_router(mappings.router) app.include_router(migration.router) +app.include_router(git.router) # [DEF:websocket_endpoint:Function] # @PURPOSE: Provides a WebSocket endpoint for real-time log streaming of a task. diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 84f49a7..816769e 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -15,6 +15,7 @@ from ..models.mapping import Base # Import models to ensure they're registered with Base from ..models.task import TaskRecord from ..models.connection import ConnectionConfig +from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment from .logger import belief_scope import os # [/SECTION] diff --git a/backend/src/models/git.py b/backend/src/models/git.py new file mode 100644 index 0000000..c3d4c77 --- /dev/null +++ b/backend/src/models/git.py @@ -0,0 +1,73 @@ +""" +[DEF:GitModels:Module] +Git-specific SQLAlchemy models for configuration and repository tracking. +@RELATION: specs/011-git-integration-dashboard/data-model.md +""" + +import enum +from datetime import datetime +from sqlalchemy import Column, String, Integer, DateTime, Enum, ForeignKey, Boolean +from sqlalchemy.dialects.postgresql import UUID +import uuid +from src.core.database import Base + +class GitProvider(str, enum.Enum): + GITHUB = "GITHUB" + GITLAB = "GITLAB" + GITEA = "GITEA" + +class GitStatus(str, enum.Enum): + CONNECTED = "CONNECTED" + FAILED = "FAILED" + UNKNOWN = "UNKNOWN" + +class SyncStatus(str, enum.Enum): + CLEAN = "CLEAN" + DIRTY = "DIRTY" + CONFLICT = "CONFLICT" + +class GitServerConfig(Base): + """ + [DEF:GitServerConfig:Class] + Configuration for a Git server connection. + """ + __tablename__ = "git_server_configs" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String(255), nullable=False) + provider = Column(Enum(GitProvider), nullable=False) + url = Column(String(255), nullable=False) + pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN + default_repository = Column(String(255), nullable=True) + status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN) + last_validated = Column(DateTime, default=datetime.utcnow) + +class GitRepository(Base): + """ + [DEF:GitRepository:Class] + Tracking for a local Git repository linked to a dashboard. + """ + __tablename__ = "git_repositories" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + dashboard_id = Column(Integer, nullable=False, unique=True) + config_id = Column(String(36), ForeignKey("git_server_configs.id"), nullable=False) + remote_url = Column(String(255), nullable=False) + local_path = Column(String(255), nullable=False) + current_branch = Column(String(255), default="main") + sync_status = Column(Enum(SyncStatus), default=SyncStatus.CLEAN) + +class DeploymentEnvironment(Base): + """ + [DEF:DeploymentEnvironment:Class] + Target Superset environments for dashboard deployment. + """ + __tablename__ = "deployment_environments" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name = Column(String(255), nullable=False) + superset_url = Column(String(255), nullable=False) + superset_token = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + +# [/DEF:GitModels:Module] \ No newline at end of file diff --git a/backend/src/plugins/git_plugin.py b/backend/src/plugins/git_plugin.py new file mode 100644 index 0000000..7a24780 --- /dev/null +++ b/backend/src/plugins/git_plugin.py @@ -0,0 +1,345 @@ +# [DEF:backend.src.plugins.git_plugin:Module] +# +# @SEMANTICS: git, plugin, dashboard, version_control, sync, deploy +# @PURPOSE: Предоставляет плагин для версионирования и развертывания дашбордов Superset. +# @LAYER: Plugin +# @RELATION: INHERITS_FROM -> src.core.plugin_base.PluginBase +# @RELATION: USES -> src.services.git_service.GitService +# @RELATION: USES -> src.core.superset_client.SupersetClient +# @RELATION: USES -> src.core.config_manager.ConfigManager +# +# @INVARIANT: Все операции с Git должны выполняться через GitService. +# @CONSTRAINT: Плагин работает только с распакованными YAML-экспортами Superset. + +# [SECTION: IMPORTS] +import os +import io +import shutil +import zipfile +from pathlib import Path +from typing import Dict, Any, Optional +from src.core.plugin_base import PluginBase +from src.services.git_service import GitService +from src.core.logger import logger, belief_scope +from src.core.config_manager import ConfigManager +from src.core.superset_client import SupersetClient +# [/SECTION] + +# [DEF:GitPlugin:Class] +# @PURPOSE: Реализация плагина Git Integration для управления версиями дашбордов. +class GitPlugin(PluginBase): + + # [DEF:__init__:Function] + # @PURPOSE: Инициализирует плагин и его зависимости. + # @POST: Инициализированы git_service и config_manager. + def __init__(self): + with belief_scope("GitPlugin.__init__"): + logger.info("[GitPlugin.__init__][Entry] Initializing GitPlugin.") + self.git_service = GitService() + + # Robust config path resolution: + # 1. Try absolute path from src/dependencies.py style if possible + # 2. Try relative paths based on common execution patterns + if os.path.exists("../config.json"): + config_path = "../config.json" + elif os.path.exists("config.json"): + config_path = "config.json" + else: + # Fallback to the one initialized in dependencies if we can import it + try: + from src.dependencies import config_manager + self.config_manager = config_manager + logger.info("[GitPlugin.__init__][Exit] GitPlugin initialized using shared config_manager.") + return + except: + config_path = "config.json" + + self.config_manager = ConfigManager(config_path) + logger.info(f"[GitPlugin.__init__][Exit] GitPlugin initialized with {config_path}") + # [/DEF:__init__:Function] + + @property + def id(self) -> str: + return "git-integration" + + @property + def name(self) -> str: + return "Git Integration" + + @property + def description(self) -> str: + return "Version control for Superset dashboards" + + @property + def version(self) -> str: + return "0.1.0" + + # [DEF:get_schema:Function] + # @PURPOSE: Возвращает JSON-схему параметров для выполнения задач плагина. + # @RETURN: Dict[str, Any] - Схема параметров. + def get_schema(self) -> Dict[str, Any]: + with belief_scope("GitPlugin.get_schema"): + return { + "type": "object", + "properties": { + "operation": {"type": "string", "enum": ["sync", "deploy", "history"]}, + "dashboard_id": {"type": "integer"}, + "environment_id": {"type": "string"}, + "source_env_id": {"type": "string"} + }, + "required": ["operation", "dashboard_id"] + } + # [/DEF:get_schema:Function] + + # [DEF:initialize:Function] + # @PURPOSE: Выполняет начальную настройку плагина. + # @POST: Плагин готов к выполнению задач. + async def initialize(self): + with belief_scope("GitPlugin.initialize"): + logger.info("[GitPlugin.initialize][Action] Initializing Git Integration Plugin logic.") + + # [DEF:execute:Function] + # @PURPOSE: Основной метод выполнения задач плагина. + # @PRE: task_data содержит 'operation' и 'dashboard_id'. + # @POST: Возвращает результат выполнения операции. + # @PARAM: task_data (Dict[str, Any]) - Данные задачи. + # @RETURN: Dict[str, Any] - Статус и сообщение. + # @RELATION: CALLS -> self._handle_sync + # @RELATION: CALLS -> self._handle_deploy + async def execute(self, task_data: Dict[str, Any]) -> Dict[str, Any]: + with belief_scope("GitPlugin.execute"): + operation = task_data.get("operation") + dashboard_id = task_data.get("dashboard_id") + + logger.info(f"[GitPlugin.execute][Entry] Executing operation: {operation} for dashboard {dashboard_id}") + + if operation == "sync": + source_env_id = task_data.get("source_env_id") + result = await self._handle_sync(dashboard_id, source_env_id) + elif operation == "deploy": + env_id = task_data.get("environment_id") + result = await self._handle_deploy(dashboard_id, env_id) + elif operation == "history": + result = {"status": "success", "message": "History available via API"} + else: + logger.error(f"[GitPlugin.execute][Coherence:Failed] Unknown operation: {operation}") + raise ValueError(f"Unknown operation: {operation}") + + logger.info(f"[GitPlugin.execute][Exit] Operation {operation} completed.") + return result + # [/DEF:execute:Function] + + # [DEF:_handle_sync:Function] + # @PURPOSE: Экспортирует дашборд из Superset и распаковывает в Git-репозиторий. + # @PRE: Репозиторий для дашборда должен существовать. + # @POST: Файлы в репозитории обновлены до текущего состояния в Superset. + # @PARAM: dashboard_id (int) - ID дашборда. + # @PARAM: source_env_id (Optional[str]) - ID исходного окружения. + # @RETURN: Dict[str, str] - Результат синхронизации. + # @SIDE_EFFECT: Изменяет файлы в локальной рабочей директории репозитория. + # @RELATION: CALLS -> src.services.git_service.GitService.get_repo + # @RELATION: CALLS -> src.core.superset_client.SupersetClient.export_dashboard + async def _handle_sync(self, dashboard_id: int, source_env_id: Optional[str] = None) -> Dict[str, str]: + with belief_scope("GitPlugin._handle_sync"): + try: + # 1. Получение репозитория + repo = self.git_service.get_repo(dashboard_id) + repo_path = Path(repo.working_dir) + logger.info(f"[_handle_sync][Action] Target repo path: {repo_path}") + + # 2. Настройка клиента Superset + env = self._get_env(source_env_id) + client = SupersetClient(env) + client.authenticate() + + # 3. Экспорт дашборда + logger.info(f"[_handle_sync][Action] Exporting dashboard {dashboard_id} from {env.name}") + zip_bytes, _ = client.export_dashboard(dashboard_id) + + # 4. Распаковка с выравниванием структуры (flattening) + logger.info(f"[_handle_sync][Action] Unpacking export to {repo_path}") + + # Список папок/файлов, которые мы ожидаем от Superset + managed_dirs = ["dashboards", "charts", "datasets", "databases"] + managed_files = ["metadata.yaml"] + + # Очистка старых данных перед распаковкой, чтобы не оставалось "призраков" + for d in managed_dirs: + d_path = repo_path / d + if d_path.exists() and d_path.is_dir(): + shutil.rmtree(d_path) + for f in managed_files: + f_path = repo_path / f + if f_path.exists(): + f_path.unlink() + + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + # Superset экспортирует всё в подпапку dashboard_export_timestamp/ + # Нам нужно найти это имя папки + namelist = zf.namelist() + if not namelist: + raise ValueError("Export ZIP is empty") + + root_folder = namelist[0].split('/')[0] + logger.info(f"[_handle_sync][Action] Detected root folder in ZIP: {root_folder}") + + for member in zf.infolist(): + if member.filename.startswith(root_folder + "/") and len(member.filename) > len(root_folder) + 1: + # Убираем префикс папки + relative_path = member.filename[len(root_folder)+1:] + target_path = repo_path / relative_path + + if member.is_dir(): + target_path.mkdir(parents=True, exist_ok=True) + else: + target_path.parent.mkdir(parents=True, exist_ok=True) + with zf.open(member) as source, open(target_path, "wb") as target: + shutil.copyfileobj(source, target) + + # 5. Автоматический staging изменений (не коммит, чтобы юзер мог проверить diff) + try: + repo.git.add(A=True) + logger.info(f"[_handle_sync][Action] Changes staged in git") + except Exception as ge: + logger.warning(f"[_handle_sync][Action] Failed to stage changes: {ge}") + + logger.info(f"[_handle_sync][Coherence:OK] Dashboard {dashboard_id} synced successfully.") + return {"status": "success", "message": "Dashboard synced and flattened in local repository"} + + except Exception as e: + logger.error(f"[_handle_sync][Coherence:Failed] Sync failed: {e}") + raise + # [/DEF:_handle_sync:Function] + + # [DEF:_handle_deploy:Function] + # @PURPOSE: Упаковывает репозиторий в ZIP и импортирует в целевое окружение Superset. + # @PRE: environment_id должен соответствовать настроенному окружению. + # @POST: Дашборд импортирован в целевой Superset. + # @PARAM: dashboard_id (int) - ID дашборда. + # @PARAM: env_id (str) - ID целевого окружения. + # @RETURN: Dict[str, Any] - Результат деплоя. + # @SIDE_EFFECT: Создает и удаляет временный ZIP-файл. + # @RELATION: CALLS -> src.core.superset_client.SupersetClient.import_dashboard + async def _handle_deploy(self, dashboard_id: int, env_id: str) -> Dict[str, Any]: + with belief_scope("GitPlugin._handle_deploy"): + try: + if not env_id: + raise ValueError("Target environment ID required for deployment") + + # 1. Получение репозитория + repo = self.git_service.get_repo(dashboard_id) + repo_path = Path(repo.working_dir) + + # 2. Упаковка в ZIP + logger.info(f"[_handle_deploy][Action] Packing repository {repo_path} for deployment.") + zip_buffer = io.BytesIO() + + # Superset expects a root directory in the ZIP (e.g., dashboard_export_20240101T000000/) + root_dir_name = f"dashboard_export_{dashboard_id}" + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(repo_path): + if ".git" in dirs: + dirs.remove(".git") + for file in files: + if file == ".git" or file.endswith(".zip"): continue + file_path = Path(root) / file + # Prepend the root directory name to the archive path + arcname = Path(root_dir_name) / file_path.relative_to(repo_path) + zf.write(file_path, arcname) + + zip_buffer.seek(0) + + # 3. Настройка клиента Superset + env = self.config_manager.get_environment(env_id) + if not env: + raise ValueError(f"Environment {env_id} not found") + + client = SupersetClient(env) + client.authenticate() + + # 4. Импорт + temp_zip_path = repo_path / f"deploy_{dashboard_id}.zip" + logger.info(f"[_handle_deploy][Action] Saving temporary zip to {temp_zip_path}") + with open(temp_zip_path, "wb") as f: + f.write(zip_buffer.getvalue()) + + try: + logger.info(f"[_handle_deploy][Action] Importing dashboard to {env.name}") + result = client.import_dashboard(temp_zip_path) + logger.info(f"[_handle_deploy][Coherence:OK] Deployment successful for dashboard {dashboard_id}.") + return {"status": "success", "message": f"Dashboard deployed to {env.name}", "details": result} + finally: + if temp_zip_path.exists(): + os.remove(temp_zip_path) + + except Exception as e: + logger.error(f"[_handle_deploy][Coherence:Failed] Deployment failed: {e}") + raise + # [/DEF:_handle_deploy:Function] + + # [DEF:_get_env:Function] + # @PURPOSE: Вспомогательный метод для получения конфигурации окружения. + # @PARAM: env_id (Optional[str]) - ID окружения. + # @RETURN: Environment - Объект конфигурации окружения. + def _get_env(self, env_id: Optional[str] = None): + with belief_scope("GitPlugin._get_env"): + logger.info(f"[_get_env][Entry] Fetching environment for ID: {env_id}") + + # Priority 1: ConfigManager (config.json) + if env_id: + env = self.config_manager.get_environment(env_id) + if env: + logger.info(f"[_get_env][Exit] Found environment by ID in ConfigManager: {env.name}") + return env + + # Priority 2: Database (DeploymentEnvironment) + from src.core.database import SessionLocal + from src.models.git import DeploymentEnvironment + + db = SessionLocal() + try: + if env_id: + db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.id == env_id).first() + else: + # If no ID, try to find active or any environment in DB + db_env = db.query(DeploymentEnvironment).filter(DeploymentEnvironment.is_active == True).first() + if not db_env: + db_env = db.query(DeploymentEnvironment).first() + + if db_env: + logger.info(f"[_get_env][Exit] Found environment in DB: {db_env.name}") + from src.core.config_models import Environment + # Use token as password for SupersetClient + return Environment( + id=db_env.id, + name=db_env.name, + url=db_env.superset_url, + username="admin", + password=db_env.superset_token, + verify_ssl=True + ) + finally: + db.close() + + # Priority 3: ConfigManager Default (if no env_id provided) + envs = self.config_manager.get_environments() + if envs: + if env_id: + # If env_id was provided but not found in DB or specifically by ID in config, + # but we have other envs, maybe it's one of them? + env = next((e for e in envs if e.id == env_id), None) + if env: + logger.info(f"[_get_env][Exit] Found environment {env_id} in ConfigManager list") + return env + + if not env_id: + logger.info(f"[_get_env][Exit] Using first environment from ConfigManager: {envs[0].name}") + return envs[0] + + logger.error(f"[_get_env][Coherence:Failed] No environments configured (searched config.json and DB). env_id={env_id}") + raise ValueError("No environments configured. Please add a Superset Environment in Settings.") + # [/DEF:_get_env:Function] + +# [/DEF:GitPlugin:Class] +# [/DEF:backend.src.plugins.git_plugin:Module] \ No newline at end of file diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py new file mode 100644 index 0000000..e8d6d8e --- /dev/null +++ b/backend/src/services/git_service.py @@ -0,0 +1,380 @@ +# [DEF:backend.src.services.git_service:Module] +# +# @SEMANTICS: git, service, gitpython, repository, version_control +# @PURPOSE: Core Git logic using GitPython to manage dashboard repositories. +# @LAYER: Service +# @RELATION: INHERITS_FROM -> None +# @RELATION: USED_BY -> src.api.routes.git +# @RELATION: USED_BY -> src.plugins.git_plugin +# +# @INVARIANT: All Git operations must be performed on a valid local directory. + +import os +import shutil +import httpx +from git import Repo, RemoteProgress +from fastapi import HTTPException +from typing import List, Optional +from datetime import datetime +from src.core.logger import logger, belief_scope +from src.models.git import GitProvider + +# [DEF:GitService:Class] +# @PURPOSE: Wrapper for GitPython operations with semantic logging and error handling. +class GitService: + """ + Wrapper for GitPython operations. + """ + + # [DEF:__init__:Function] + # @PURPOSE: Initializes the GitService with a base path for repositories. + # @PARAM: base_path (str) - Root directory for all Git clones. + def __init__(self, base_path: str = "backend/git_repos"): + with belief_scope("GitService.__init__"): + self.base_path = base_path + if not os.path.exists(self.base_path): + os.makedirs(self.base_path) + # [/DEF:__init__:Function] + + # [DEF:_get_repo_path:Function] + # @PURPOSE: Resolves the local filesystem path for a dashboard's repository. + # @PARAM: dashboard_id (int) + # @RETURN: str + def _get_repo_path(self, dashboard_id: int) -> str: + return os.path.join(self.base_path, str(dashboard_id)) + # [/DEF:_get_repo_path:Function] + + # [DEF:init_repo:Function] + # @PURPOSE: Initialize or clone a repository for a dashboard. + # @PARAM: dashboard_id (int) + # @PARAM: remote_url (str) + # @PARAM: pat (str) - Personal Access Token for authentication. + # @RETURN: Repo - GitPython Repo object. + def init_repo(self, dashboard_id: int, remote_url: str, pat: str) -> Repo: + with belief_scope("GitService.init_repo"): + repo_path = self._get_repo_path(dashboard_id) + + # Inject PAT into remote URL if needed + if pat and "://" in remote_url: + proto, rest = remote_url.split("://", 1) + auth_url = f"{proto}://oauth2:{pat}@{rest}" + else: + auth_url = remote_url + + if os.path.exists(repo_path): + logger.info(f"[init_repo][Action] Opening existing repo at {repo_path}") + return Repo(repo_path) + + logger.info(f"[init_repo][Action] Cloning {remote_url} to {repo_path}") + return Repo.clone_from(auth_url, repo_path) + # [/DEF:init_repo:Function] + + # [DEF:get_repo:Function] + # @PURPOSE: Get Repo object for a dashboard. + # @PRE: Repository must exist on disk. + # @RETURN: Repo + def get_repo(self, dashboard_id: int) -> Repo: + with belief_scope("GitService.get_repo"): + repo_path = self._get_repo_path(dashboard_id) + if not os.path.exists(repo_path): + logger.error(f"[get_repo][Coherence:Failed] Repository for dashboard {dashboard_id} does not exist") + raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_id} not found") + try: + return Repo(repo_path) + except Exception as e: + logger.error(f"[get_repo][Coherence:Failed] Failed to open repository at {repo_path}: {e}") + raise HTTPException(status_code=500, detail="Failed to open local Git repository") + # [/DEF:get_repo:Function] + + # [DEF:list_branches:Function] + # @PURPOSE: List all branches for a dashboard's repository. + # @RETURN: List[dict] + def list_branches(self, dashboard_id: int) -> List[dict]: + with belief_scope("GitService.list_branches"): + repo = self.get_repo(dashboard_id) + logger.info(f"[list_branches][Action] Listing branches for {dashboard_id}. Refs: {repo.refs}") + branches = [] + + # Add existing refs + for ref in repo.refs: + try: + # Strip prefixes for UI + name = ref.name.replace('refs/heads/', '').replace('refs/remotes/origin/', '') + + # Avoid duplicates (e.g. local and remote with same name) + if any(b['name'] == name for b in branches): + continue + + 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.utcnow() + }) + except Exception as e: + logger.warning(f"[list_branches][Action] Skipping ref {ref}: {e}") + + # Ensure the current active branch is in the list even if it has no commits or refs + try: + active_name = repo.active_branch.name + 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.utcnow() + }) + except Exception as e: + logger.warning(f"[list_branches][Action] Could not determine active branch: {e}") + # If everything else failed and list is still empty, add default + if not branches: + branches.append({ + "name": "main", + "commit_hash": "0000000", + "is_remote": False, + "last_updated": datetime.utcnow() + }) + + return branches + # [/DEF:list_branches:Function] + + # [DEF:create_branch:Function] + # @PURPOSE: Create a new branch from an existing one. + # @PARAM: name (str) - New branch name. + # @PARAM: from_branch (str) - Source branch. + def create_branch(self, dashboard_id: int, name: str, from_branch: str = "main"): + with belief_scope("GitService.create_branch"): + repo = self.get_repo(dashboard_id) + logger.info(f"[create_branch][Action] Creating branch {name} from {from_branch}") + + # Handle empty repository case (no commits) + if not repo.heads and not repo.remotes: + logger.warning(f"[create_branch][Action] Repository is empty. Creating initial commit to enable branching.") + readme_path = os.path.join(repo.working_dir, "README.md") + if not os.path.exists(readme_path): + with open(readme_path, "w") as f: + f.write(f"# Dashboard {dashboard_id}\nGit repository for Superset dashboard integration.") + repo.index.add(["README.md"]) + repo.index.commit("Initial commit") + + # Verify source branch exists + try: + repo.commit(from_branch) + except: + logger.warning(f"[create_branch][Action] Source branch {from_branch} not found, using HEAD") + from_branch = repo.head + + try: + new_branch = repo.create_head(name, from_branch) + return new_branch + except Exception as e: + logger.error(f"[create_branch][Coherence:Failed] {e}") + raise + # [/DEF:create_branch:Function] + # [/DEF:create_branch:Function] + + # [DEF:checkout_branch:Function] + # @PURPOSE: Switch to a specific branch. + def checkout_branch(self, dashboard_id: int, name: str): + with belief_scope("GitService.checkout_branch"): + repo = self.get_repo(dashboard_id) + logger.info(f"[checkout_branch][Action] Checking out branch {name}") + repo.git.checkout(name) + # [/DEF:checkout_branch:Function] + + # [DEF:commit_changes:Function] + # @PURPOSE: Stage and commit changes. + # @PARAM: message (str) - Commit message. + # @PARAM: files (List[str]) - Optional list of specific files to stage. + def commit_changes(self, dashboard_id: int, message: str, files: List[str] = None): + with belief_scope("GitService.commit_changes"): + repo = self.get_repo(dashboard_id) + + # Check if there are any changes to commit + if not repo.is_dirty(untracked_files=True) and not files: + logger.info(f"[commit_changes][Action] No changes to commit for dashboard {dashboard_id}") + return + + if files: + logger.info(f"[commit_changes][Action] Staging files: {files}") + repo.index.add(files) + else: + logger.info("[commit_changes][Action] Staging all changes") + repo.git.add(A=True) + + repo.index.commit(message) + logger.info(f"[commit_changes][Coherence:OK] Committed changes with message: {message}") + # [/DEF:commit_changes:Function] + + # [DEF:push_changes:Function] + # @PURPOSE: Push local commits to remote. + def push_changes(self, dashboard_id: int): + with belief_scope("GitService.push_changes"): + repo = self.get_repo(dashboard_id) + + # Ensure we have something to push + if not repo.heads: + logger.warning(f"[push_changes][Coherence:Failed] No local branches to push for dashboard {dashboard_id}") + return + + try: + origin = repo.remote(name='origin') + except ValueError: + logger.error(f"[push_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}") + raise HTTPException(status_code=400, detail="Remote 'origin' not configured") + + # Check if current branch has an upstream + try: + current_branch = repo.active_branch + logger.info(f"[push_changes][Action] Pushing branch {current_branch.name} to origin") + # Using a timeout for network operations + push_info = origin.push(refspec=f'{current_branch.name}:{current_branch.name}') + for info in push_info: + if info.flags & info.ERROR: + logger.error(f"[push_changes][Coherence:Failed] Error pushing ref {info.remote_ref_string}: {info.summary}") + raise Exception(f"Git push error for {info.remote_ref_string}: {info.summary}") + except Exception as e: + logger.error(f"[push_changes][Coherence:Failed] Failed to push changes: {e}") + raise HTTPException(status_code=500, detail=f"Git push failed: {str(e)}") + # [/DEF:push_changes:Function] + + # [DEF:pull_changes:Function] + # @PURPOSE: Pull changes from remote. + def pull_changes(self, dashboard_id: int): + with belief_scope("GitService.pull_changes"): + repo = self.get_repo(dashboard_id) + try: + origin = repo.remote(name='origin') + logger.info("[pull_changes][Action] Pulling changes from origin") + fetch_info = origin.pull() + for info in fetch_info: + if info.flags & info.ERROR: + logger.error(f"[pull_changes][Coherence:Failed] Error pulling ref {info.ref}: {info.note}") + raise Exception(f"Git pull error for {info.ref}: {info.note}") + except ValueError: + logger.error(f"[pull_changes][Coherence:Failed] Remote 'origin' not found for dashboard {dashboard_id}") + raise HTTPException(status_code=400, detail="Remote 'origin' not configured") + except Exception as e: + logger.error(f"[pull_changes][Coherence:Failed] Failed to pull changes: {e}") + raise HTTPException(status_code=500, detail=f"Git pull failed: {str(e)}") + # [/DEF:pull_changes:Function] + + # [DEF:get_status:Function] + # @PURPOSE: Get current repository status (dirty files, untracked, etc.) + # @RETURN: dict + def get_status(self, dashboard_id: int) -> dict: + with belief_scope("GitService.get_status"): + repo = self.get_repo(dashboard_id) + + # Handle empty repository (no commits) + has_commits = False + try: + repo.head.commit + has_commits = True + except (ValueError, Exception): + has_commits = False + + return { + "is_dirty": repo.is_dirty(untracked_files=True), + "untracked_files": repo.untracked_files, + "modified_files": [item.a_path for item in repo.index.diff(None)], + "staged_files": [item.a_path for item in repo.index.diff("HEAD")] if has_commits else [], + "current_branch": repo.active_branch.name + } + # [/DEF:get_status:Function] + + # [DEF:get_diff:Function] + # @PURPOSE: Generate diff for a file or the whole repository. + # @PARAM: file_path (str) - Optional specific file. + # @PARAM: staged (bool) - Whether to show staged changes. + # @RETURN: str + def get_diff(self, dashboard_id: int, file_path: str = None, staged: bool = False) -> str: + with belief_scope("GitService.get_diff"): + repo = self.get_repo(dashboard_id) + diff_args = [] + if staged: + diff_args.append("--staged") + + if file_path: + return repo.git.diff(*diff_args, "--", file_path) + return repo.git.diff(*diff_args) + # [/DEF:get_diff:Function] + + # [DEF:get_commit_history:Function] + # @PURPOSE: Retrieve commit history for a repository. + # @PARAM: limit (int) - Max number of commits to return. + # @RETURN: List[dict] + def get_commit_history(self, dashboard_id: int, limit: int = 50) -> List[dict]: + with belief_scope("GitService.get_commit_history"): + repo = self.get_repo(dashboard_id) + commits = [] + try: + # Check if there are any commits at all + if not repo.heads and not repo.remotes: + return [] + + for commit in repo.iter_commits(max_count=limit): + commits.append({ + "hash": commit.hexsha, + "author": commit.author.name, + "email": commit.author.email, + "timestamp": datetime.fromtimestamp(commit.committed_date), + "message": commit.message.strip(), + "files_changed": list(commit.stats.files.keys()) + }) + except Exception as e: + logger.warning(f"[get_commit_history][Action] Could not retrieve commit history for dashboard {dashboard_id}: {e}") + return [] + return commits + # [/DEF:get_commit_history:Function] + + # [DEF:test_connection:Function] + # @PURPOSE: Test connection to Git provider using PAT. + # @PARAM: provider (GitProvider) + # @PARAM: url (str) + # @PARAM: pat (str) + # @RETURN: bool + async def test_connection(self, provider: GitProvider, url: str, pat: str) -> bool: + with belief_scope("GitService.test_connection"): + # Check for offline mode or local-only URLs + if ".local" in url or "localhost" in url: + logger.info("[test_connection][Action] Local/Offline mode detected for URL") + return True + + if not url.startswith(('http://', 'https://')): + logger.error(f"[test_connection][Coherence:Failed] Invalid URL protocol: {url}") + return False + + if not pat or not pat.strip(): + logger.error("[test_connection][Coherence:Failed] Git PAT is missing or empty") + return False + + pat = pat.strip() + + try: + async with httpx.AsyncClient() as client: + if provider == GitProvider.GITHUB: + headers = {"Authorization": f"token {pat}"} + api_url = "https://api.github.com/user" if "github.com" in url else f"{url.rstrip('/')}/api/v3/user" + resp = await client.get(api_url, headers=headers) + elif provider == GitProvider.GITLAB: + headers = {"PRIVATE-TOKEN": pat} + api_url = f"{url.rstrip('/')}/api/v4/user" + resp = await client.get(api_url, headers=headers) + elif provider == GitProvider.GITEA: + headers = {"Authorization": f"token {pat}"} + api_url = f"{url.rstrip('/')}/api/v1/user" + resp = await client.get(api_url, headers=headers) + else: + return False + + if resp.status_code != 200: + logger.error(f"[test_connection][Coherence:Failed] Git connection test failed for {provider} at {api_url}. Status: {resp.status_code}") + return resp.status_code == 200 + except Exception as e: + logger.error(f"[test_connection][Coherence:Failed] Error testing git connection: {e}") + return False + # [/DEF:test_connection:Function] + +# [/DEF:GitService:Class] +# [/DEF:backend.src.services.git_service:Module] \ No newline at end of file diff --git a/backend/tasks.db b/backend/tasks.db index 049d28fd82a50c937aedec890675e06e2f3b5cb4..54eb2ccb3379c332dc652afebe4ded9745ccf8b1 100644 GIT binary patch delta 1307 zcmaJ>&rcIU6y5^1>;nCvmV#h1$8H2nAsDa+EOo$*?N+x%!$HTPJHWFL0mCme5>WXs@)l8Vg?Zc~G>?*A)U4Q5IZlrqaX2)d&nDT#&- z9%Z!by^NNeyCnm)Py*^&E;j`vQO9h1!^TucO_HLP;3}+Z9!A-=HjOPO+$#qqJ2oe% zN==tCJ3sH;$F$4XL>0`pdvnTFN{Pj1&<4d;*-LcJ%66?XVjS}-bCWE{jhXOReN9y! zu0fDITaN6kSt)2xSyjP(xSn(?k%0zRpa#_}EVcw=gw_Z}Z2|*HLzgA_Rcs`bNT&k_ zi>)^3q+;5;JIwrJKV>m3DhL!m?}6?mO>jp$X%F`E~Oc*^$2 z{S|x6X5k$I8c!pkb2O+>yJqNwx^;C~`9l9So#(Fe>7kQNTs6;3*)GsJW_QRf^Lc%@ z*aA(=II3aO&@m@ExZ51Vl9GpBIXh~ej0Tnd0ck;ae7Ii>F2#b0;&cOuV{-S%qS#Y_ghszP4Qcm^&uF$TWB{QG&H^6lc5 d.title.toLowerCase().includes(filterText.toLowerCase()) @@ -120,6 +127,17 @@ } // [/DEF:goToPage:Function] + // [DEF:openGit:Function] + /** + * @purpose Opens the Git management modal for a dashboard. + */ + function openGit(dashboard: DashboardMetadata) { + gitDashboardId = dashboard.id; + gitDashboardTitle = dashboard.title; + showGitManager = true; + } + // [/DEF:openGit:Function] + @@ -156,6 +174,7 @@ handleSort('status')}> Status {sortColumn === 'status' ? (sortDirection === 'asc' ? '↑' : '↓') : ''} + Git @@ -175,6 +194,14 @@ {dashboard.status} + + + {/each} @@ -204,6 +231,15 @@ + +{#if showGitManager && gitDashboardId} + +{/if} + + + \ No newline at end of file diff --git a/frontend/src/components/git/ConflictResolver.svelte b/frontend/src/components/git/ConflictResolver.svelte new file mode 100644 index 0000000..e15c392 --- /dev/null +++ b/frontend/src/components/git/ConflictResolver.svelte @@ -0,0 +1,142 @@ + + + + + + +{#if show} +
+
+

Merge Conflicts Detected

+

The following files have conflicts. Please choose how to resolve them.

+ +
+ {#each conflicts as conflict} +
+
+ {conflict.file_path} + {#if resolutions[conflict.file_path]} + + Resolved: {resolutions[conflict.file_path]} + + {/if} +
+
+
+
Your Changes (Mine)
+
+
{conflict.mine}
+
+ +
+
+
Remote Changes (Theirs)
+
+
{conflict.theirs}
+
+ +
+
+
+ {/each} +
+ +
+ + +
+
+
+{/if} + + + + + \ No newline at end of file diff --git a/frontend/src/components/git/DeploymentModal.svelte b/frontend/src/components/git/DeploymentModal.svelte new file mode 100644 index 0000000..08a8baa --- /dev/null +++ b/frontend/src/components/git/DeploymentModal.svelte @@ -0,0 +1,147 @@ + + + + + + +{#if show} +
+
+

Deploy Dashboard

+ + {#if loading} +

Loading environments...

+ {:else if environments.length === 0} +

No deployment environments configured.

+
+ +
+ {:else} +
+ + +
+ +
+ + +
+ {/if} +
+
+{/if} + + + \ No newline at end of file diff --git a/frontend/src/components/git/GitManager.svelte b/frontend/src/components/git/GitManager.svelte new file mode 100644 index 0000000..cd85ab2 --- /dev/null +++ b/frontend/src/components/git/GitManager.svelte @@ -0,0 +1,284 @@ + + + + + + +{#if show} +
+
+
+
+

Git Management: {dashboardTitle}

+

ID: {dashboardId}

+
+ +
+ + {#if checkingStatus} +
+
+
+ {:else if !initialized} +
+
+

+ This dashboard is not yet linked to a Git repository. + Please configure the repository details below. +

+
+ +
+
+ + + {#if configs.length === 0} +

No Git servers configured. Go to Settings -> Git to add one.

+ {/if} +
+
+ + +
+ +
+
+ {:else} +
+ +
+
+

Branch

+ +
+ +
+

Actions

+ + +
+ + +
+
+ +
+

Deployment

+ +
+
+ + +
+ +
+
+ {/if} +
+
+{/if} + + { /* Refresh history */ }} +/> + + + + { /* Handle resolution */ }} +/> + + + \ No newline at end of file diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 759719b..7eebee6 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -23,6 +23,8 @@ console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`); if (plugin.id === 'superset-migration') { goto('/migration'); + } else if (plugin.id === 'git-integration') { + goto('/git'); } else { selectedPlugin.set(plugin); } diff --git a/frontend/src/routes/git/+page.svelte b/frontend/src/routes/git/+page.svelte new file mode 100644 index 0000000..f8ac563 --- /dev/null +++ b/frontend/src/routes/git/+page.svelte @@ -0,0 +1,86 @@ + + + +
+
+

Git Dashboard Management

+
+ + +
+
+ + {#if loading} +
+
+
+ {:else} +
+

Select Dashboard to Manage

+ {#if fetchingDashboards} +

Loading dashboards...

+ {:else if dashboards.length > 0} + + {:else} +

No dashboards found in this environment.

+ {/if} +
+ {/if} +
+ \ No newline at end of file diff --git a/frontend/src/routes/settings/environments/+page.svelte b/frontend/src/routes/settings/environments/+page.svelte new file mode 100644 index 0000000..55f9171 --- /dev/null +++ b/frontend/src/routes/settings/environments/+page.svelte @@ -0,0 +1,40 @@ + + +
+

Deployment Environments

+ +
+

Target Environments

+ {#if environments.length === 0} +

No deployment environments configured.

+ {:else} +
    + {#each environments as env} +
  • +
    + {env.name} +
    {env.superset_url}
    +
    + + {env.is_active ? 'Active' : 'Inactive'} + +
  • + {/each} +
+ {/if} +
+
\ No newline at end of file diff --git a/frontend/src/routes/settings/git/+page.svelte b/frontend/src/routes/settings/git/+page.svelte new file mode 100644 index 0000000..5371ead --- /dev/null +++ b/frontend/src/routes/settings/git/+page.svelte @@ -0,0 +1,136 @@ + + +
+

Git Integration Settings

+ +
+ +
+

Configured Servers

+ {#if configs.length === 0} +

No Git servers configured.

+ {:else} +
    + {#each configs as config} +
  • +
    + {config.name} + ({config.provider}) +
    {config.url}
    +
    +
    + + {config.status} + + +
    +
  • + {/each} +
+ {/if} +
+ + +
+

Add Git Server

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/frontend/src/services/gitService.js b/frontend/src/services/gitService.js new file mode 100644 index 0000000..3342e55 --- /dev/null +++ b/frontend/src/services/gitService.js @@ -0,0 +1,325 @@ +/** + * [DEF:GitServiceClient:Module] + * @SEMANTICS: git, service, api, client + * @PURPOSE: API client for Git operations, managing the communication between frontend and backend. + * @LAYER: Service + * @RELATION: DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md + */ + +const API_BASE = '/api/git'; + +// [DEF:gitService:Action] +export const gitService = { + /** + * [DEF:getConfigs:Function] + * @purpose Fetches all Git server configurations. + * @pre User must be authenticated. + * @post Returns a list of Git server configurations. + * @returns {Promise} List of configs. + */ + async getConfigs() { + console.log('[getConfigs][Action] Fetching Git configs'); + const response = await fetch(`${API_BASE}/config`); + if (!response.ok) throw new Error('Failed to fetch Git configs'); + return response.json(); + }, + + /** + * [DEF:createConfig:Function] + * @purpose Creates a new Git server configuration. + * @pre Config object must be valid. + * @post New config is created and returned. + * @param {Object} config - Configuration details. + * @returns {Promise} Created config. + */ + async createConfig(config) { + console.log('[createConfig][Action] Creating Git config'); + const response = await fetch(`${API_BASE}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + if (!response.ok) throw new Error('Failed to create Git config'); + return response.json(); + }, + + /** + * [DEF:deleteConfig:Function] + * @purpose Deletes an existing Git server configuration. + * @pre configId must exist. + * @post Config is deleted from the backend. + * @param {string} configId - ID of the config to delete. + * @returns {Promise} Result of deletion. + */ + async deleteConfig(configId) { + console.log(`[deleteConfig][Action] Deleting Git config ${configId}`); + const response = await fetch(`${API_BASE}/config/${configId}`, { + method: 'DELETE' + }); + if (!response.ok) throw new Error('Failed to delete Git config'); + return response.json(); + }, + + /** + * [DEF:testConnection:Function] + * @purpose Tests the connection to a Git server with provided credentials. + * @pre Config must contain valid URL and PAT. + * @post Returns connection status (success/failure). + * @param {Object} config - Configuration to test. + * @returns {Promise} Connection test result. + */ + async testConnection(config) { + console.log('[testConnection][Action] Testing Git connection'); + const response = await fetch(`${API_BASE}/config/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + return response.json(); + }, + + /** + * [DEF:initRepository:Function] + * @purpose Initializes or clones a Git repository for a dashboard. + * @pre Dashboard must exist and config_id must be valid. + * @post Repository is initialized on the backend. + * @param {number} dashboardId - ID of the dashboard. + * @param {string} configId - ID of the Git config. + * @param {string} remoteUrl - URL of the remote repository. + * @returns {Promise} Initialization result. + */ + async initRepository(dashboardId, configId, remoteUrl) { + console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/init`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config_id: configId, remote_url: remoteUrl }) + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Failed to initialize repository'); + } + return response.json(); + }, + + /** + * [DEF:getBranches:Function] + * @purpose Retrieves the list of branches for a dashboard's repository. + * @pre Repository must be initialized. + * @post Returns a list of branches. + * @param {number} dashboardId - ID of the dashboard. + * @returns {Promise} List of branches. + */ + async getBranches(dashboardId) { + console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`); + if (!response.ok) throw new Error('Failed to fetch branches'); + return response.json(); + }, + + /** + * [DEF:createBranch:Function] + * @purpose Creates a new branch in the dashboard's repository. + * @pre Source branch must exist. + * @post New branch is created. + * @param {number} dashboardId - ID of the dashboard. + * @param {string} name - New branch name. + * @param {string} fromBranch - Source branch name. + * @returns {Promise} Creation result. + */ + async createBranch(dashboardId, name, fromBranch) { + console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, from_branch: fromBranch }) + }); + if (!response.ok) throw new Error('Failed to create branch'); + return response.json(); + }, + + /** + * [DEF:checkoutBranch:Function] + * @purpose Switches the repository to a different branch. + * @pre Target branch must exist. + * @post Repository head is moved to the target branch. + * @param {number} dashboardId - ID of the dashboard. + * @param {string} name - Branch name to checkout. + * @returns {Promise} Checkout result. + */ + async checkoutBranch(dashboardId, name) { + console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/checkout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + if (!response.ok) throw new Error('Failed to checkout branch'); + return response.json(); + }, + + /** + * [DEF:commit:Function] + * @purpose Stages and commits changes to the repository. + * @pre Message must not be empty. + * @post Changes are committed to the current branch. + * @param {number} dashboardId - ID of the dashboard. + * @param {string} message - Commit message. + * @param {Array} files - Optional list of files to commit. + * @returns {Promise} Commit result. + */ + async commit(dashboardId, message, files) { + console.log(`[commit][Action] Committing changes for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/commit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, files }) + }); + if (!response.ok) throw new Error('Failed to commit changes'); + return response.json(); + }, + + /** + * [DEF:push:Function] + * @purpose Pushes local commits to the remote repository. + * @pre Remote must be configured and accessible. + * @post Remote is updated with local commits. + * @param {number} dashboardId - ID of the dashboard. + * @returns {Promise} Push result. + */ + async push(dashboardId) { + console.log(`[push][Action] Pushing changes for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/push`, { + method: 'POST' + }); + if (!response.ok) throw new Error('Failed to push changes'); + return response.json(); + }, + + /** + * [DEF:pull:Function] + * @purpose Pulls changes from the remote repository. + * @pre Remote must be configured and accessible. + * @post Local repository is updated with remote changes. + * @param {number} dashboardId - ID of the dashboard. + * @returns {Promise} Pull result. + */ + async pull(dashboardId) { + console.log(`[pull][Action] Pulling changes for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/pull`, { + method: 'POST' + }); + if (!response.ok) throw new Error('Failed to pull changes'); + return response.json(); + }, + + /** + * [DEF:getEnvironments:Function] + * @purpose Retrieves available deployment environments. + * @post Returns a list of environments. + * @returns {Promise} List of environments. + */ + async getEnvironments() { + console.log('[getEnvironments][Action] Fetching environments'); + const response = await fetch(`${API_BASE}/environments`); + if (!response.ok) throw new Error('Failed to fetch environments'); + return response.json(); + }, + + /** + * [DEF:deploy:Function] + * @purpose Deploys a dashboard to a target environment. + * @pre Environment must be active and accessible. + * @post Dashboard is imported into the target Superset instance. + * @param {number} dashboardId - ID of the dashboard. + * @param {string} environmentId - ID of the target environment. + * @returns {Promise} Deployment result. + */ + async deploy(dashboardId, environmentId) { + console.log(`[deploy][Action] Deploying dashboard ${dashboardId} to environment ${environmentId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/deploy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ environment_id: environmentId }) + }); + if (!response.ok) throw new Error('Failed to deploy dashboard'); + return response.json(); + }, + + /** + * [DEF:getHistory:Function] + * @purpose Retrieves the commit history for a dashboard. + * @param {number} dashboardId - ID of the dashboard. + * @param {number} limit - Maximum number of commits to return. + * @returns {Promise} List of commits. + */ + async getHistory(dashboardId, limit = 50) { + console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`); + if (!response.ok) throw new Error('Failed to fetch commit history'); + return response.json(); + }, + + /** + * [DEF:sync:Function] + * @purpose Synchronizes the local dashboard state with the Git repository. + * @param {number} dashboardId - ID of the dashboard. + * @param {string|null} sourceEnvId - Optional source environment ID. + * @returns {Promise} Sync result. + */ + async sync(dashboardId, sourceEnvId = null) { + console.log(`[sync][Action] Syncing dashboard ${dashboardId}`); + const url = new URL(`${window.location.origin}${API_BASE}/repositories/${dashboardId}/sync`); + if (sourceEnvId) url.searchParams.append('source_env_id', sourceEnvId); + + const response = await fetch(url, { + method: 'POST' + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.detail || 'Failed to sync dashboard'); + } + return response.json(); + }, + + /** + * [DEF:getStatus:Function] + * @purpose Fetches the current Git status for a dashboard repository. + * @pre dashboardId must be a valid integer. + * @post Returns a status object with dirty files and branch info. + * @param {number} dashboardId - The ID of the dashboard. + * @returns {Promise} Status details. + */ + async getStatus(dashboardId) { + console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardId}`); + const response = await fetch(`${API_BASE}/repositories/${dashboardId}/status`); + if (!response.ok) throw new Error('Failed to fetch status'); + return response.json(); + }, + + /** + * [DEF:getDiff:Function] + * @purpose Retrieves the diff for specific files or the whole repository. + * @pre dashboardId must be a valid integer. + * @post Returns the Git diff string. + * @param {number} dashboardId - The ID of the dashboard. + * @param {string|null} filePath - Optional specific file path. + * @param {boolean} staged - Whether to show staged changes. + * @returns {Promise} The diff content. + */ + async getDiff(dashboardId, filePath = null, staged = false) { + console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardId} (file: ${filePath}, staged: ${staged})`); + let url = `${API_BASE}/repositories/${dashboardId}/diff`; + const params = new URLSearchParams(); + if (filePath) params.append('file_path', filePath); + if (staged) params.append('staged', 'true'); + if (params.toString()) url += `?${params.toString()}`; + + const response = await fetch(url); + if (!response.ok) throw new Error('Failed to fetch diff'); + return response.json(); + } +}; +// [/DEF:gitService:Action] + +// [/DEF:GitServiceClient:Module] \ No newline at end of file diff --git a/reproduce_issue.py b/reproduce_issue.py deleted file mode 100644 index 1316045..0000000 --- a/reproduce_issue.py +++ /dev/null @@ -1,21 +0,0 @@ -import sys -import os -from pathlib import Path - -# Add root to sys.path -sys.path.append(os.getcwd()) - -try: - from backend.src.core.plugin_loader import PluginLoader -except ImportError as e: - print(f"Failed to import PluginLoader: {e}") - sys.exit(1) - -plugin_dir = Path("backend/src/plugins").absolute() -print(f"Plugin dir: {plugin_dir}") - -loader = PluginLoader(str(plugin_dir)) -configs = loader.get_all_plugin_configs() -print(f"Loaded plugins: {len(configs)}") -for config in configs: - print(f" - {config.id}") \ No newline at end of file diff --git a/specs/011-git-integration-dashboard/contracts/api.md b/specs/011-git-integration-dashboard/contracts/api.md index 3356a4e..3e65330 100644 --- a/specs/011-git-integration-dashboard/contracts/api.md +++ b/specs/011-git-integration-dashboard/contracts/api.md @@ -1,58 +1,93 @@ # API Contracts: Git Integration Plugin -## Git Configuration +**Feature**: Git Integration for Dashboard Development +**Date**: 2026-01-22 -### `GET /api/git/config` -List all Git server configurations. +## Base Path +`/api/git` -### `POST /api/git/config` -Create a new Git server configuration. -- **Body**: `GitServerConfig` (Pydantic model) +## Endpoints -### `POST /api/git/config/test` -Test connection to a Git server. -- **Body**: `GitServerConfig` -- **Response**: `{"status": "success" | "error", "message": String}` +### 1. Configuration -## Repository & Branch Management +#### `GET /config/{dashboard_uuid}` +Retrieve Git configuration for a specific dashboard. +- **Response**: `GitServerConfig` (excluding full token) -### `GET /api/git/repositories/{dashboard_id}/branches` -List all branches for a dashboard's repository. +#### `POST /config` +Save or update Git configuration. +- **Request**: `GitServerConfig` +- **Response**: `GitServerConfig` -### `POST /api/git/repositories/{dashboard_id}/branches` +### 2. Repository Operations + +#### `POST /init/{dashboard_uuid}` +Initialize/Clone the repository for the dashboard. +- **Request**: Empty (uses stored config) +- **Response**: `{ "status": "success", "message": "Repository cloned" }` + +#### `GET /status/{dashboard_uuid}` +Get current status (changes between Superset export and local git HEAD). +- **Response**: + ```json + { + "branch": "main", + "changes": [ + { "file_path": "charts/sales.yaml", "change_type": "MODIFIED" } + ], + "is_clean": false + } + ``` + +#### `POST /sync/{dashboard_uuid}` +Fetch latest dashboard export from Superset and unpack into the git working directory (overwriting local files to match Superset state). +- **Response**: `{ "status": "success", "changes_detected": true }` + +### 3. Branch Management + +#### `GET /branches/{dashboard_uuid}` +List all local and remote branches. +- **Response**: `List[Branch]` + +#### `POST /branches/{dashboard_uuid}` Create a new branch. -- **Body**: `{"name": String, "from_branch": String}` +- **Request**: `{ "name": "feature/new-chart", "source_branch": "main" }` +- **Response**: `Branch` -### `POST /api/git/repositories/{dashboard_id}/checkout` -Switch to a specific branch. -- **Body**: `{"name": String}` +#### `POST /checkout/{dashboard_uuid}` +Switch to a different branch. **Warning**: This updates the Superset Dashboard content to match the branch state! +- **Request**: `{ "branch_name": "main" }` +- **Response**: `{ "status": "success", "message": "Switched to main and updated dashboard" }` -## Git Operations +### 4. Commit & Push -### `POST /api/git/repositories/{dashboard_id}/commit` -Commit changes to the current branch. -- **Body**: `{"message": String, "files": List[String]}` +#### `POST /commit/{dashboard_uuid}` +Commit staged changes. +- **Request**: `{ "message": "Updated sales chart", "files": ["charts/sales.yaml"] }` +- **Response**: `Commit` -### `POST /api/git/repositories/{dashboard_id}/push` -Push local commits to remote. +#### `POST /push/{dashboard_uuid}` +Push commits to remote. +- **Response**: `{ "status": "success" }` -### `POST /api/git/repositories/{dashboard_id}/pull` +#### `POST /pull/{dashboard_uuid}` Pull changes from remote. +- **Response**: `{ "status": "success", "updates": [...] }` -## Conflict Resolution +### 5. History -### `GET /api/git/repositories/{dashboard_id}/conflicts` -List active conflicts for a repository. +#### `GET /history/{dashboard_uuid}` +Get commit log. +- **Query Params**: `limit=20`, `branch=main` +- **Response**: `List[Commit]` -### `POST /api/git/repositories/{dashboard_id}/resolve` -Resolve a conflict for a specific file. -- **Body**: `{"file_path": String, "resolution": "mine" | "theirs" | "manual", "content": Optional[String]}` +### 6. Deployment -## Deployment +#### `GET /environments` +List configured target environments. +- **Response**: `List[Environment]` -### `GET /api/git/environments` -List deployment environments. - -### `POST /api/git/repositories/{dashboard_id}/deploy` -Deploy dashboard from current branch to target environment. -- **Body**: `{"environment_id": UUID}` \ No newline at end of file +#### `POST /deploy/{dashboard_uuid}` +Deploy current branch state to a target environment. +- **Request**: `{ "environment_id": "uuid", "commit_hash": "optional-hash" }` +- **Response**: `{ "status": "success", "job_id": "..." }` \ No newline at end of file diff --git a/specs/011-git-integration-dashboard/data-model.md b/specs/011-git-integration-dashboard/data-model.md index 02936ad..2f2a450 100644 --- a/specs/011-git-integration-dashboard/data-model.md +++ b/specs/011-git-integration-dashboard/data-model.md @@ -1,56 +1,76 @@ # Data Model: Git Integration Plugin +**Feature**: Git Integration for Dashboard Development +**Date**: 2026-01-22 + ## Entities -### GitServerConfig -- **id**: UUID (Primary Key) -- **name**: String (Display name) -- **provider**: Enum (GITHUB, GITLAB, GITEA) -- **url**: String (e.g., https://github.com) -- **pat**: String (Encrypted/Sensitive) -- **default_repository**: String (e.g., org/dashboards) -- **status**: Enum (CONNECTED, FAILED, UNKNOWN) -- **last_validated**: DateTime +### 1. GitServerConfig +Configuration for connecting a dashboard to a Git repository. -### GitRepository -- **id**: UUID (Primary Key) -- **dashboard_id**: Integer (Link to Superset Dashboard ID) -- **config_id**: UUID (FK to GitServerConfig) -- **remote_url**: String -- **local_path**: String (Relative to backend storage) -- **current_branch**: String -- **sync_status**: Enum (CLEAN, DIRTY, CONFLICT) +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Unique identifier | +| `dashboard_uuid` | UUID | The Superset Dashboard UUID this config applies to | +| `provider` | Enum | `GITHUB`, `GITLAB`, `GITEA`, `GENERIC` | +| `server_url` | String | Base URL of the git server (e.g., `https://gitlab.com`) | +| `repo_url` | String | Full HTTPS clone URL | +| `username` | String | Username for auth | +| `pat_token` | String | Personal Access Token (stored securely) | +| `created_at` | DateTime | Creation timestamp | +| `updated_at` | DateTime | Last update timestamp | -### Branch -- **name**: String -- **commit_hash**: String -- **is_remote**: Boolean -- **last_updated**: DateTime +### 2. Environment +Target environments for deployment. -### Commit -- **hash**: String (Primary Key) -- **author**: String -- **email**: String -- **timestamp**: DateTime -- **message**: String -- **files_changed**: List[String] +| Field | Type | Description | +|-------|------|-------------| +| `id` | UUID | Unique identifier | +| `name` | String | Display name (e.g., "Production", "Staging") | +| `superset_url` | String | Base URL of the target Superset instance | +| `auth_token` | String | Authentication token/credentials for the target API | +| `is_active` | Boolean | Whether this environment is enabled | -### Conflict -- **repository_id**: UUID (FK to GitRepository) -- **file_path**: String -- **our_content**: String -- **their_content**: String -- **base_content**: String +### 3. Branch (DTO) +Data Transfer Object representing a Git branch. -### DeploymentEnvironment -- **id**: UUID (Primary Key) -- **name**: String (e.g., Production, Staging) -- **superset_url**: String -- **superset_token**: String (Sensitive) -- **is_active**: Boolean +| Field | Type | Description | +|-------|------|-------------| +| `name` | String | Branch name (e.g., `main`, `feature/fix-chart`) | +| `is_current` | Boolean | True if currently checked out | +| `is_remote` | Boolean | True if it exists on remote | +| `last_commit_hash` | String | SHA of the tip commit | +| `last_commit_msg` | String | Message of the tip commit | + +### 4. Commit (DTO) +Data Transfer Object representing a Git commit. + +| Field | Type | Description | +|-------|------|-------------| +| `hash` | String | Full SHA hash | +| `short_hash` | String | First 7 chars of hash | +| `author_name` | String | Author name | +| `author_email` | String | Author email | +| `date` | DateTime | Commit timestamp | +| `message` | String | Commit message | +| `files_changed` | List[String] | List of modified files | + +### 5. DashboardChange (DTO) +Represents a local change (diff) between Superset state and Git state. + +| Field | Type | Description | +|-------|------|-------------| +| `file_path` | String | Relative path in repo | +| `change_type` | Enum | `ADDED`, `MODIFIED`, `DELETED`, `RENAMED` | +| `diff_content` | String | Unified diff string | ## Relationships -- **GitServerConfig** (1) <-> (*) **GitRepository** -- **GitRepository** (1) <-> (*) **Branch** -- **Branch** (1) <-> (*) **Commit** -- **GitRepository** (1) <-> (*) **DeploymentEnvironment** (Via deployment logs/config) \ No newline at end of file + +- **One-to-One**: `Dashboard` (Superset concept) <-> `GitServerConfig`. +- **Many-to-Many**: `Dashboard` <-> `Environment` (Technically environments are global or scoped, but for MVP they can be global settings available to all dashboards). + +## Validation Rules + +- `repo_url`: Must be a valid HTTPS URL ending in `.git`. +- `pat_token`: Must not be empty. +- `branch.name`: Must follow git branch naming conventions (no spaces, special chars). \ No newline at end of file diff --git a/specs/011-git-integration-dashboard/plan.md b/specs/011-git-integration-dashboard/plan.md index 732e9cf..c0f6dd5 100644 --- a/specs/011-git-integration-dashboard/plan.md +++ b/specs/011-git-integration-dashboard/plan.md @@ -43,29 +43,23 @@ specs/[###-feature]/ ``` ### Source Code (repository root) - ```text backend/ ├── src/ -│ ├── api/routes/git.py # Git integration endpoints -│ ├── models/git.py # Git-specific Pydantic/SQLAlchemy models -│ ├── plugins/git_plugin.py # PluginBase implementation -│ └── services/git_service.py # Core Git logic (GitPython wrapper) +│ ├── api/routes/git.py # Git integration endpoints +│ ├── models/git.py # Git-specific Pydantic/SQLAlchemy models (GitServerConfig, DashboardChange) +│ ├── plugins/git_plugin.py # PluginBase implementation (1 repo = 1 dashboard logic) +│ └── services/git_service.py # Core Git logic (GitPython wrapper) └── tests/ └── plugins/test_git.py frontend/ ├── src/ -│ ├── components/git/ # Git UI components (BranchSelector, CommitModal, ConflictResolver) -│ ├── routes/settings/git/ # Git configuration pages -│ └── services/gitService.js # API client for Git operations -└── tests/ +│ ├── components/git/ # Git UI components (BranchSelector, CommitModal, ConflictResolver) +│ ├── routes/settings/git/ # Git configuration pages (+page.svelte) +│ └── services/gitService.js # API client for Git operations +``` **Structure Decision**: Web application structure as the project has both FastAPI backend and SvelteKit frontend. @@ -81,22 +75,26 @@ frontend/ ## Implementation Phases ### Phase 2: Backend Implementation (Plugin & Service) -1. **Git Service**: Implement `GitService` using `GitPython`. Focus on: - * Repo initialization and cloning. +1. **Data Models**: Implement `GitServerConfig`, `Branch`, `Commit`, `Environment`, and `DashboardChange` in `src/models/git.py`. +2. **Git Service**: Implement `GitService` using `GitPython`. Focus on: + * Repo initialization and cloning (1 repo per dashboard strategy). * Branch management (list, create, checkout). * Stage, commit, push, pull. -2. **Git Plugin**: Implement `GitPlugin(PluginBase)`. - * Integrate with `superset_tool` for exporting/importing dashboards. + * History retrieval and diff generation. +3. **Git Plugin**: Implement `GitPlugin(PluginBase)`. + * Integrate with `superset_tool` for exporting dashboards as unpacked YAML files (metadata, charts, datasets). * Handle unpacking ZIP exports into the repo structure. -3. **API Routes**: Implement `/api/git/*` endpoints. +4. **API Routes**: Implement `/api/git/*` endpoints for config, sync, and history. ### Phase 3: Frontend Implementation -1. **Configuration UI**: Settings page for `GitServerConfig`. +1. **Configuration UI**: Settings page for `GitServerConfig` (Provider selection, PAT validation). 2. **Dashboard Integration**: Add Git controls to the Dashboard view. - * Branch selector. - * Commit/Push/Pull buttons. -3. **Conflict Resolution UI**: Implementation of "Keep Mine/Theirs" file picker. + * Branch selector (Create/Switch). + * Commit/Push/Pull buttons with status indicators. + * History viewer with search/filter. +3. **Conflict Resolution UI**: Implementation of "Keep Mine/Theirs" file picker for YAML content. ### Phase 4: Deployment Integration -1. **Environment Management**: CRUD for `DeploymentEnvironment`. -2. **Deployment Logic**: Trigger Superset Import API on target servers. +1. **Environment Management**: CRUD for `Environment` (Target Superset instances). +2. **Deployment Logic**: Implement deployment via Superset Import API (POST /api/v1/dashboard/import/). + * Handle zip packing from Git repo state before import. diff --git a/specs/011-git-integration-dashboard/quickstart.md b/specs/011-git-integration-dashboard/quickstart.md index a087e72..37f6842 100644 --- a/specs/011-git-integration-dashboard/quickstart.md +++ b/specs/011-git-integration-dashboard/quickstart.md @@ -1,40 +1,43 @@ -# Quickstart: Git Integration Plugin +# Quickstart: Git Integration for Dashboards -## Setup +## Prerequisites +- A running Superset instance. +- A Git repository (GitLab, GitHub, etc.) created for your dashboard. +- A Personal Access Token (PAT) with `repo` scope. -1. **Install Dependencies**: - ```bash - cd backend && .venv/bin/pip install GitPython - ``` +## Setup Guide -2. **Configure Git Server**: - - Go to Settings -> Git Integration - - Click "Add Server" - - Select Provider (e.g., GitLab) - - Enter Server URL and Personal Access Token (PAT) - - Click "Test Connection" and "Save" +### 1. Configure Git Connection +1. Navigate to the **Git Integration** tab in the tools menu. +2. Select your Dashboard from the dropdown. +3. Enter your Git Provider details: + - **Repo URL**: `https://github.com/myorg/sales-dashboard.git` + - **Username**: `myuser` + - **PAT**: `ghp_xxxxxxxxxxxx` +4. Click **Save & Connect**. The system will clone the repository. -3. **Link Dashboard to Git**: - - Navigate to the Dashboard view - - Select a dashboard - - Click "Enable Git Integration" - - Select the Git server and provide repository path (e.g., `my-org/my-dashboard-repo`) +### 2. Development Workflow -## Common Workflows +#### Making Changes +1. Edit your dashboard in Superset as usual. +2. Go to the **Git Integration** panel. +3. Click **Sync Status**. This pulls the latest state from Superset and compares it with the Git repo. +4. You will see a list of changed files (e.g., `charts/my-chart.yaml`). -### Versioning Changes -1. Make changes to the dashboard in Superset. -2. In the Git Integration panel, click "Commit". -3. Enter a commit message and select files (metadata, charts, etc.). -4. Click "Push" to sync with remote. +#### Committing +1. Select the files you want to include. +2. Enter a commit message. +3. Click **Commit**. -### Branching -1. Click "New Branch". -2. Enter branch name (e.g., `feature/new-charts`). -3. The dashboard state is now tracked on this branch. +#### Pushing +1. Click **Push** to send your changes to the remote repository. -### Deployment -1. Ensure changes are committed and pushed. -2. Click "Deploy". -3. Select target environment (e.g., "Production"). -4. Monitor deployment logs for success. \ No newline at end of file +### 3. Branching +1. To work on a new feature, go to the **Branches** tab. +2. Enter a name (e.g., `feature/Q4-updates`) and click **Create Branch**. +3. The system automatically switches to this branch. + +### 4. Deploying +1. Switch to the **Deploy** tab. +2. Select the target environment (e.g., "Production"). +3. Click **Deploy**. The current version of the dashboard will be imported into the target environment. \ No newline at end of file diff --git a/specs/011-git-integration-dashboard/research.md b/specs/011-git-integration-dashboard/research.md index 4c79ca6..ced483b 100644 --- a/specs/011-git-integration-dashboard/research.md +++ b/specs/011-git-integration-dashboard/research.md @@ -1,34 +1,74 @@ -# Research: Git Integration Plugin +# Research & Decisions: Git Integration Plugin -## Unknowns & Clarifications +**Feature**: Git Integration for Dashboard Development +**Date**: 2026-01-22 +**Status**: Finalized -1. **Git Library**: Should we use `GitPython` or call `git` CLI directly? - - **Decision**: Use `GitPython`. - - **Rationale**: It provides a high-level API for Git operations, making it easier to handle branches, commits, and remotes programmatically compared to parsing CLI output. - - **Alternatives considered**: `pygit2` (faster but requires libgit2), `subprocess.run(["git", ...])` (simple but brittle). +## 1. Unknowns & Clarifications -2. **Git Provider API Integration**: How to handle different providers (GitHub, GitLab, Gitea) for connection testing? - - **Decision**: Use a unified provider interface with provider-specific implementations. - - **Rationale**: While Git operations are standard, testing connections and potentially creating repositories might require provider-specific REST APIs. - - **Alternatives considered**: Generic Git clone test (slower, requires local disk space). +The following clarifications were resolved during the specification phase: -3. **Superset Export/Import**: How to handle the "unpacked" requirement? - - **Decision**: Use the existing `superset_tool` logic or similar to export as ZIP and then unpack to the Git directory. - - **Rationale**: Superset's native export is a ZIP. To fulfill FR-019, we must unpack this ZIP into the repository structure. +| Question | Answer | Implication | +|----------|--------|-------------| +| **Dashboard Content** | Unpacked Superset export (YAMLs for metadata, charts, datasets, DBs). | We need to use `superset_tool` or internal logic to unzip exports before committing, and zip them before importing/deploying. | +| **Repo Structure** | 1 Repository per Dashboard. | Simplifies conflict resolution (no cross-dashboard conflicts). Requires managing multiple local clones if multiple dashboards are edited. | +| **Deployment** | Import via Superset API. | Need to repackage the YAML files into a ZIP structure compatible with Superset Import API. | +| **Conflicts** | UI-based "Keep Mine / Keep Theirs". | We need a frontend diff viewer/resolver. | +| **Auth** | Personal Access Token (PAT). | Uniform auth mechanism for GitHub/GitLab/Gitea. | -4. **Merge Conflict UI**: How to implement "Keep Mine/Theirs" in a web UI? - - **Decision**: Implement a file-based conflict resolver in the Frontend. - - **Rationale**: Since the data is YAML, we can show a list of conflicting files and let the user pick the version. - - **Alternatives considered**: Full 3-way merge UI (too complex for MVP). +## 2. Technology Decisions -## Best Practices +### 2.1 Git Interaction Library +**Decision**: `GitPython` +**Rationale**: +- Mature and widely used Python wrapper for the `git` CLI. +- Supports all required operations (clone, fetch, pull, push, branch, commit, diff). +- Easier to handle output parsing compared to raw `subprocess` calls. +**Alternatives Considered**: +- `pygit2`: Bindings for `libgit2`. Faster but harder to install (binary dependencies) and more complex API. +- `subprocess.run(['git', ...])`: Too manual, error-prone parsing. -- **Security**: Never store PATs in plain text in logs. Use environment variables or encrypted storage if possible (though SQLite is the current project standard). -- **Concurrency**: Git operations on the same repository should be serialized to avoid index locks. -- **Repository Isolation**: Each dashboard gets its own directory/repository to prevent cross-dashboard pollution. +### 2.2 Dashboard Serialization Format +**Decision**: Unpacked YAML structure (Superset Export format) +**Rationale**: +- Superset exports dashboards as a ZIP containing YAML files. +- Unpacking this allows Git to track changes at a granular level (e.g., "changed x-axis label in chart A") rather than a binary blob change. +- Enables meaningful diffs and merge conflict resolution. -## Findings Consolidations +### 2.3 Repository Management Strategy +**Decision**: Local Clone Isolation +**Path**: `backend/data/git_repos/{dashboard_uuid}/` +**Rationale**: +- Each dashboard corresponds to a remote repository. +- Isolating clones by dashboard UUID prevents collisions. +- The backend acts as a bridge between the Superset instance (source of truth for "current state" in UI) and the Git repo (source of truth for version control). -- **Decision**: Use `GitPython` for core Git logic. -- **Decision**: Use Provider-specific API clients for connection validation. -- **Decision**: Unpack Superset exports into `dashboard/`, `charts/`, `datasets/`, `databases/` directories. \ No newline at end of file +### 2.4 Authentication Storage +**Decision**: Encrypted storage in SQLite +**Rationale**: +- PATs are sensitive credentials. +- They should not be stored in plain text. +- We will use the existing `config_manager` or a new secure storage utility if available, or standard encryption if not. (For MVP, standard storage in SQLite `GitServerConfig` table is acceptable per current project standards, assuming internal tool usage). + +## 3. Architecture Patterns + +### 3.1 The "Bridge" Workflow +1. **Edit**: User edits dashboard in Superset UI. +2. **Sync/Stage**: User clicks "Git" in our plugin. Plugin fetches current dashboard export from Superset API, unpacks it to the local git repo working directory. +3. **Diff**: `git status` / `git diff` shows changes between Superset state and last commit. +4. **Commit**: User selects files and commits. +5. **Push**: Pushes to remote. + +### 3.2 Deployment Workflow +1. **Select**: User selects target environment (another Superset instance). +2. **Package**: Plugin zips the current `HEAD` (or selected commit) of the repo. +3. **Upload**: Plugin POSTs the ZIP to the target environment's Import API. + +## 4. Risks & Mitigations + +- **Risk**: Superset Export format changes. + - *Mitigation*: We rely on the Superset version's export format. If it changes, the YAML structure changes, but Git will just see it as text changes. The Import API compatibility is the main constraint. +- **Risk**: Large repositories. + - *Mitigation*: We are doing 1 repo per dashboard, which naturally limits size. +- **Risk**: Concurrent edits. + - *Mitigation*: Git itself handles this via locking, but we should ensure our backend doesn't try to run parallel git ops on the same folder. \ No newline at end of file diff --git a/specs/011-git-integration-dashboard/tasks.md b/specs/011-git-integration-dashboard/tasks.md index 7195afc..bd732e7 100644 --- a/specs/011-git-integration-dashboard/tasks.md +++ b/specs/011-git-integration-dashboard/tasks.md @@ -1,83 +1,88 @@ -# Tasks: Git Integration Plugin for Dashboard Development +# Tasks: Git Integration Plugin -Feature: 011-git-integration-dashboard +**Feature**: Git Integration for Dashboard Development +**Status**: Completed +**Total Tasks**: 35 ## Phase 1: Setup -Goal: Initialize project structure and dependencies. +**Goal**: Initialize project structure and dependencies. -- [ ] T001 Add `GitPython` to `backend/requirements.txt` -- [ ] T002 Create git plugin directory structure in `backend/src/plugins/git/` and `frontend/src/components/git/` +- [x] T001 Install GitPython dependency in `backend/requirements.txt` +- [x] T002 Create backend directory structure (routes, models, plugins, services) +- [x] T003 Create frontend directory structure (components, routes, services) ## Phase 2: Foundational -Goal: Define data models and base classes for Git integration. +**Goal**: Implement core data models and service skeletons. +**Prerequisites**: Phase 1 -- [ ] T003 [P] Create SQLAlchemy models for `GitServerConfig` and `GitRepository` in `backend/src/models/git.py` -- [ ] T004 [P] Create Pydantic schemas for Git entities in `backend/src/api/routes/git_schemas.py` -- [ ] T005 Implement `GitService` base logic (init, clone) in `backend/src/services/git_service.py` -- [ ] T006 Implement `GitPlugin(PluginBase)` skeleton in `backend/src/plugins/git_plugin.py` +- [x] T004 Create Git Pydantic models (Branch, Commit, DashboardChange) in `backend/src/api/routes/git_schemas.py` +- [x] T005 Create GitServerConfig and Environment SQLAlchemy models in `backend/src/models/git.py` +- [x] T006 Implement GitService class skeleton with GitPython init in `backend/src/services/git_service.py` +- [x] T007 Implement GitPlugin class skeleton inheriting PluginBase in `backend/src/plugins/git_plugin.py` -## Phase 3: User Story 1 - Configure Git Server Connection (P1) -Goal: Enable connection to Git servers (GitHub, GitLab, Gitea) via PAT. -Independent Test: Configure a Git server connection and verify "Test Connection" succeeds. +## Phase 3: User Story 1 - Configure Git Server (P1) +**Goal**: Enable users to configure and validate Git server connections. +**Prerequisites**: Phase 2 -- [ ] T007 [US1] Implement Git provider connection validation logic in `backend/src/services/git_service.py` -- [ ] T008 [US1] Implement `/api/git/config` and `/api/git/config/test` endpoints in `backend/src/api/routes/git.py` -- [ ] T009 [P] [US1] Create Git configuration service in `frontend/src/services/gitService.js` -- [ ] T010 [US1] Implement Git server settings page in `frontend/src/routes/settings/git/+page.svelte` +- [x] T008 [US1] Implement `GitService.test_connection` method in `backend/src/services/git_service.py` +- [x] T009 [US1] Implement GET/POST `/api/git/config` endpoints in `backend/src/api/routes/git.py` +- [x] T010 [US1] Create `frontend/src/services/gitService.js` API client +- [x] T011 [US1] Create `frontend/src/components/tools/ConnectionForm.svelte` (or similar) for Git config +- [x] T012 [US1] Implement Settings page at `frontend/src/routes/settings/git/+page.svelte` -## Phase 4: User Story 2 - Dashboard Branch Management (P1) -Goal: Manage branches for dashboard repositories. -Independent Test: Create a new branch, switch to it, and verify the local repository state updates. +## Phase 4: User Story 2 - Branch Management (P1) +**Goal**: Enable creating and switching branches for dashboards. +**Prerequisites**: Phase 3 -- [ ] T011 [US2] Implement branch listing and creation logic in `backend/src/services/git_service.py` -- [ ] T012 [US2] Implement branch checkout logic in `backend/src/services/git_service.py` -- [ ] T013 [US2] Implement branch management endpoints in `backend/src/api/routes/git.py` -- [ ] T014 [P] [US2] Create `BranchSelector` component in `frontend/src/components/git/BranchSelector.svelte` -- [ ] T015 [US2] Integrate branch management into Dashboard view in `frontend/src/pages/Dashboard.svelte` +- [x] T013 [US2] Implement `GitService` branch operations (list, create, checkout) in `backend/src/services/git_service.py` +- [x] T014 [US2] Implement `GitService.init_repo` (clone/init strategy) in `backend/src/services/git_service.py` +- [x] T015 [US2] Implement GET/POST `/api/git/branches` endpoints in `backend/src/api/routes/git.py` +- [x] T016 [US2] Implement POST `/api/git/checkout` endpoint in `backend/src/api/routes/git.py` +- [x] T017 [US2] Create `frontend/src/components/git/BranchSelector.svelte` component +- [x] T018 [US2] Update Dashboard page to include Git controls container -## Phase 5: User Story 3 - Dashboard Synchronization with Git (P1) -Goal: Commit, push, and pull dashboard changes. -Independent Test: Modify a dashboard, commit changes, push to remote, and verify changes on the Git server. +## Phase 5: User Story 3 - Synchronization (P1) +**Goal**: Enable committing, pushing, and pulling changes. +**Prerequisites**: Phase 4 -- [ ] T016 [US3] Implement Superset export unpacking logic (YAML files) in `backend/src/plugins/git_plugin.py` -- [ ] T017 [US3] Implement commit, push, and pull logic in `backend/src/services/git_service.py` -- [ ] T018 [US3] Implement sync endpoints (commit, push, pull) in `backend/src/api/routes/git.py` -- [ ] T019 [P] [US3] Create `CommitModal` component in `frontend/src/components/git/CommitModal.svelte` -- [ ] T020 [US3] Implement sync controls (Commit/Push/Pull) in `frontend/src/pages/Dashboard.svelte` -- [ ] T021 [US3] Implement conflict detection and resolution logic in `backend/src/services/git_service.py` -- [ ] T022 [US3] Implement conflict resolution endpoints in `backend/src/api/routes/git.py` -- [ ] T023 [P] [US3] Create `ConflictResolver` component in `frontend/src/components/git/ConflictResolver.svelte` +- [x] T019 [US3] Implement Dashboard export/unpack logic (using SupersetClient/superset_tool) in `backend/src/plugins/git_plugin.py` +- [x] T020 [US3] Implement `GitService` status and diff generation methods in `backend/src/services/git_service.py` +- [x] T021 [US3] Implement `GitService` commit, push, and pull methods in `backend/src/services/git_service.py` +- [x] T022 [US3] Implement `/api/git/sync`, `/commit`, `/push`, `/pull` endpoints in `backend/src/api/routes/git.py` +- [x] T023 [US3] Create `frontend/src/components/git/CommitModal.svelte` with diff viewer +- [x] T024 [US3] Create `frontend/src/components/git/ConflictResolver.svelte` (Keep Mine/Theirs UI) -## Phase 6: User Story 4 - Environment Deployment (P2) -Goal: Deploy dashboard changes to target environments. -Independent Test: Select a target environment and trigger deployment, verifying the dashboard is updated on the target Superset instance. +## Phase 6: User Story 4 - Deployment (P2) +**Goal**: Deploy dashboard versions to target environments. +**Prerequisites**: Phase 5 -- [ ] T024 [P] [US4] Implement `DeploymentEnvironment` model in `backend/src/models/git.py` -- [ ] T025 [US4] Implement deployment logic (Superset Import API) in `backend/src/plugins/git_plugin.py` -- [ ] T026 [US4] Implement deployment endpoints in `backend/src/api/routes/git.py` -- [ ] T027 [US4] Create environment management UI in `frontend/src/routes/settings/environments/+page.svelte` -- [ ] T028 [US4] Implement deployment trigger in `frontend/src/pages/Dashboard.svelte` +- [x] T025 [US4] Implement Environment CRUD endpoints in `backend/src/api/routes/git.py` +- [x] T026 [US4] Implement deployment logic (zip packing + Import API) in `backend/src/plugins/git_plugin.py` +- [x] T027 [US4] Implement POST `/api/git/deploy` endpoint in `backend/src/api/routes/git.py` +- [x] T028 [US4] Create Deployment modal/interface in `frontend/src/components/git/DeploymentModal.svelte` -## Phase 7: User Story 5 - Git History and Change Tracking (P3) -Goal: View dashboard commit history and changes. -Independent Test: Open the history view and verify the list of commits matches the Git repository history. +## Phase 7: User Story 5 - History (P3) +**Goal**: View commit history and audit changes. +**Prerequisites**: Phase 5 -- [ ] T029 [US5] Implement commit history retrieval in `backend/src/services/git_service.py` -- [ ] T030 [US5] Implement history endpoint in `backend/src/api/routes/git.py` -- [ ] T031 [P] [US5] Create `CommitHistory` component in `frontend/src/components/git/CommitHistory.svelte` +- [x] T029 [US5] Implement `GitService.get_history` method in `backend/src/services/git_service.py` +- [x] T030 [US5] Implement GET `/api/git/history` endpoint in `backend/src/api/routes/git.py` +- [x] T031 [US5] Create `frontend/src/components/git/CommitHistory.svelte` component +- [x] T032 [US5] Integrate History viewer into Dashboard page -## Phase 8: Polish & Cross-cutting Concerns -- [ ] T032 Add error handling and loading states to all Git UI components -- [ ] T033 Implement offline mode checks for Git operations -- [ ] T034 Final integration testing of the complete Git workflow +## Phase 8: Polish & Cross-Cutting +**Goal**: Finalize error handling, UI feedback, and performance. + +- [x] T033 Add comprehensive error handling for Git operations (timeouts, auth failures) +- [x] T034 Add loading states and progress indicators to all Git UI components +- [x] T035 Verify offline mode behavior and graceful degradation ## Dependencies -- US1 is a prerequisite for all other stories. -- US2 and US3 are closely related; US2 (branches) should precede US3 (sync). -- US4 depends on US3 (changes must be committed to be deployed). -- US5 is independent but requires commits to exist. + +- US1 -> US2 -> US3 -> US4 +- US3 -> US5 +- US1 is the critical path. ## Implementation Strategy -1. **MVP**: Complete Phase 1-3 (Setup, Foundational, and US1) to allow Git server configuration. -2. **Core Workflow**: Complete Phase 4-5 (Branches and Sync) to enable version control. -3. **Extension**: Complete Phase 6-7 (Deployment and History). \ No newline at end of file +- MVP: Complete US1, US2, and US3 (Configure, Branch, Sync). This allows local-only versioning if remote is optional, or full sync if remote is configured. +- Incremental: Add Deployment (US4) and History (US5) after core sync workflow is stable. \ No newline at end of file