From da24fb9253b9b3fb10a1bc538cabbc3c89bfdd10 Mon Sep 17 00:00:00 2001 From: busya Date: Sun, 1 Mar 2026 14:39:25 +0300 Subject: [PATCH] dev-preprod-prod logic --- backend/git_repos/10 | 2 +- backend/src/api/routes/environments.py | 33 +- backend/src/api/routes/git.py | 102 +++++++ backend/src/api/routes/git_schemas.py | 27 ++ backend/src/core/config_models.py | 1 + backend/src/services/git_service.py | 281 +++++++++++++++++- .../src/components/git/DeploymentModal.svelte | 56 +++- frontend/src/components/git/GitManager.svelte | 189 +++++++++++- .../lib/components/layout/TopNavbar.svelte | 6 +- frontend/src/lib/stores/environmentContext.js | 4 +- frontend/src/lib/toasts.js | 42 ++- frontend/src/routes/settings/+page.svelte | 66 ++-- frontend/src/services/gitService.js | 15 + 13 files changed, 754 insertions(+), 70 deletions(-) diff --git a/backend/git_repos/10 b/backend/git_repos/10 index 5f04cdd..3c0ade6 160000 --- a/backend/git_repos/10 +++ b/backend/git_repos/10 @@ -1 +1 @@ -Subproject commit 5f04cdd3bcf6d6b49c870503361bbc80a04674f7 +Subproject commit 3c0ade67f99fc538562be23f5ef3591dbeeca3b9 diff --git a/backend/src/api/routes/environments.py b/backend/src/api/routes/environments.py index ecb1981..90abbac 100644 --- a/backend/src/api/routes/environments.py +++ b/backend/src/api/routes/environments.py @@ -31,6 +31,7 @@ class EnvironmentResponse(BaseModel): id: str name: str url: str + stage: str = "DEV" is_production: bool = False backup_schedule: Optional[ScheduleSchema] = None # [/DEF:EnvironmentResponse:DataClass] @@ -59,18 +60,26 @@ async def get_environments( # Ensure envs is a list if not isinstance(envs, list): envs = [] - return [ - EnvironmentResponse( - id=e.id, - name=e.name, - url=e.url, - is_production=getattr(e, "is_production", False), - backup_schedule=ScheduleSchema( - enabled=e.backup_schedule.enabled, - cron_expression=e.backup_schedule.cron_expression - ) if getattr(e, 'backup_schedule', None) else None - ) for e in envs - ] + response_items = [] + for e in envs: + resolved_stage = str( + getattr(e, "stage", "") + or ("PROD" if bool(getattr(e, "is_production", False)) else "DEV") + ).upper() + response_items.append( + EnvironmentResponse( + id=e.id, + name=e.name, + url=e.url, + stage=resolved_stage, + is_production=(resolved_stage == "PROD"), + backup_schedule=ScheduleSchema( + enabled=e.backup_schedule.enabled, + cron_expression=e.backup_schedule.cron_expression + ) if getattr(e, 'backup_schedule', None) else None + ) + ) + return response_items # [/DEF:get_environments:Function] # [DEF:update_environment_schedule:Function] diff --git a/backend/src/api/routes/git.py b/backend/src/api/routes/git.py index bed09cd..da9171c 100644 --- a/backend/src/api/routes/git.py +++ b/backend/src/api/routes/git.py @@ -26,6 +26,7 @@ from src.api.routes.git_schemas import ( RepoStatusBatchRequest, RepoStatusBatchResponse, GiteaRepoCreateRequest, GiteaRepoSchema, RemoteRepoCreateRequest, RemoteRepoSchema, + PromoteRequest, PromoteResponse, ) from src.services.git_service import GitService from src.core.superset_client import SupersetClient @@ -627,6 +628,107 @@ async def sync_dashboard( _handle_unexpected_git_route_error("sync_dashboard", e) # [/DEF:sync_dashboard:Function] + +# [DEF:promote_dashboard:Function] +# @PURPOSE: Promote changes between branches via MR or direct merge. +# @PRE: dashboard repository is initialized and Git config is valid. +# @POST: Returns promotion result metadata. +@router.post("/repositories/{dashboard_ref}/promote", response_model=PromoteResponse) +async def promote_dashboard( + dashboard_ref: str, + payload: PromoteRequest, + env_id: Optional[str] = None, + config_manager=Depends(get_config_manager), + db: Session = Depends(get_db), + _ = Depends(has_permission("plugin:git", "EXECUTE")) +): + with belief_scope("promote_dashboard"): + dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) + db_repo = db.query(GitRepository).filter(GitRepository.dashboard_id == dashboard_id).first() + if not db_repo: + raise HTTPException(status_code=404, detail=f"Repository for dashboard {dashboard_ref} is not initialized") + config = _get_git_config_or_404(db, db_repo.config_id) + + from_branch = payload.from_branch.strip() + to_branch = payload.to_branch.strip() + if not from_branch or not to_branch: + raise HTTPException(status_code=400, detail="from_branch and to_branch are required") + if from_branch == to_branch: + raise HTTPException(status_code=400, detail="from_branch and to_branch must be different") + + mode = (payload.mode or "mr").strip().lower() + if mode == "direct": + reason = (payload.reason or "").strip() + if not reason: + raise HTTPException(status_code=400, detail="Direct promote requires non-empty reason") + logger.warning( + "[promote_dashboard][PolicyViolation] Direct promote without MR by actor=unknown dashboard_ref=%s from=%s to=%s reason=%s", + dashboard_ref, + from_branch, + to_branch, + reason, + ) + result = git_service.promote_direct_merge( + dashboard_id=dashboard_id, + from_branch=from_branch, + to_branch=to_branch, + ) + return PromoteResponse( + mode="direct", + from_branch=from_branch, + to_branch=to_branch, + status=result.get("status", "merged"), + policy_violation=True, + ) + + title = (payload.title or "").strip() or f"Promote {from_branch} -> {to_branch}" + description = payload.description + if config.provider == GitProvider.GITEA: + pr = await git_service.create_gitea_pull_request( + server_url=config.url, + pat=config.pat, + remote_url=db_repo.remote_url, + from_branch=from_branch, + to_branch=to_branch, + title=title, + description=description, + ) + elif config.provider == GitProvider.GITHUB: + pr = await git_service.create_github_pull_request( + server_url=config.url, + pat=config.pat, + remote_url=db_repo.remote_url, + from_branch=from_branch, + to_branch=to_branch, + title=title, + description=description, + draft=payload.draft, + ) + elif config.provider == GitProvider.GITLAB: + pr = await git_service.create_gitlab_merge_request( + server_url=config.url, + pat=config.pat, + remote_url=db_repo.remote_url, + from_branch=from_branch, + to_branch=to_branch, + title=title, + description=description, + remove_source_branch=payload.remove_source_branch, + ) + else: + raise HTTPException(status_code=501, detail=f"Provider {config.provider} does not support promotion API") + + return PromoteResponse( + mode="mr", + from_branch=from_branch, + to_branch=to_branch, + status=pr.get("status", "opened"), + url=pr.get("url"), + reference_id=str(pr.get("id")) if pr.get("id") is not None else None, + policy_violation=False, + ) +# [/DEF:promote_dashboard:Function] + # [DEF:get_environments:Function] # @PURPOSE: List all deployment environments. # @PRE: Config manager is accessible. diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index b150017..119eb90 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -203,4 +203,31 @@ class RemoteRepoCreateRequest(BaseModel): default_branch: Optional[str] = "main" # [/DEF:RemoteRepoCreateRequest:Class] + +# [DEF:PromoteRequest:Class] +# @PURPOSE: Request schema for branch promotion workflow. +class PromoteRequest(BaseModel): + from_branch: str = Field(..., min_length=1, max_length=255) + to_branch: str = Field(..., min_length=1, max_length=255) + mode: str = Field(default="mr", pattern="^(mr|direct)$") + title: Optional[str] = None + description: Optional[str] = None + reason: Optional[str] = None + draft: bool = False + remove_source_branch: bool = False +# [/DEF:PromoteRequest:Class] + + +# [DEF:PromoteResponse:Class] +# @PURPOSE: Response schema for promotion operation result. +class PromoteResponse(BaseModel): + mode: str + from_branch: str + to_branch: str + status: str + url: Optional[str] = None + reference_id: Optional[str] = None + policy_violation: bool = False +# [/DEF:PromoteResponse:Class] + # [/DEF:backend.src.api.routes.git_schemas:Module] diff --git a/backend/src/core/config_models.py b/backend/src/core/config_models.py index 6d03616..4e5203c 100755 --- a/backend/src/core/config_models.py +++ b/backend/src/core/config_models.py @@ -30,6 +30,7 @@ class Environment(BaseModel): url: str username: str password: str # Will be masked in UI + stage: str = Field(default="DEV", pattern="^(DEV|PREPROD|PROD)$") verify_ssl: bool = True timeout: int = 30 is_default: bool = False diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index ac24a16..0704685 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -15,6 +15,7 @@ from git import Repo from fastapi import HTTPException from typing import Any, Dict, List, Optional from datetime import datetime +from urllib.parse import quote, urlparse from src.core.logger import logger, belief_scope from src.models.git import GitProvider @@ -251,12 +252,21 @@ class GitService: 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}") + tracking_branch = None + try: + tracking_branch = current_branch.tracking_branch() + except Exception: + tracking_branch = None + + # First push for a new branch must set upstream, otherwise future pull fails. + if tracking_branch is None: + repo.git.push("--set-upstream", "origin", f"{current_branch.name}:{current_branch.name}") + else: + 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)}") @@ -271,8 +281,17 @@ class GitService: 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() + current_branch = repo.active_branch.name + remote_ref = f"origin/{current_branch}" + has_remote_branch = any(ref.name == remote_ref for ref in repo.refs) + if not has_remote_branch: + raise HTTPException( + status_code=409, + detail=f"Remote branch '{current_branch}' does not exist yet. Push this branch first.", + ) + + logger.info(f"[pull_changes][Action] Pulling changes from origin/{current_branch}") + fetch_info = origin.pull(current_branch) for info in fetch_info: if info.flags & info.ERROR: logger.error(f"[pull_changes][Coherence:Failed] Error pulling ref {info.ref}: {info.note}") @@ -280,6 +299,8 @@ class GitService: 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 HTTPException: + raise 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)}") @@ -735,5 +756,249 @@ class GitService: return data # [/DEF:create_gitlab_repository:Function] + # [DEF:_parse_remote_repo_identity:Function] + # @PURPOSE: Parse owner/repo from remote URL for Git server API operations. + # @PRE: remote_url is a valid git URL. + # @POST: Returns owner/repo tokens. + # @RETURN: Dict[str, str] + def _parse_remote_repo_identity(self, remote_url: str) -> Dict[str, str]: + normalized = str(remote_url or "").strip() + if not normalized: + raise HTTPException(status_code=400, detail="Repository remote_url is empty") + + if normalized.startswith("git@"): + # git@host:owner/repo.git + path = normalized.split(":", 1)[1] if ":" in normalized else "" + else: + parsed = urlparse(normalized) + path = parsed.path or "" + + path = path.strip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [segment for segment in path.split("/") if segment] + if len(parts) < 2: + raise HTTPException(status_code=400, detail=f"Cannot parse repository owner/name from remote URL: {remote_url}") + + owner = parts[0] + repo = parts[-1] + namespace = "/".join(parts[:-1]) + return { + "owner": owner, + "repo": repo, + "namespace": namespace, + "full_name": f"{namespace}/{repo}", + } + # [/DEF:_parse_remote_repo_identity:Function] + + # [DEF:promote_direct_merge:Function] + # @PURPOSE: Perform direct merge between branches in local repo and push target branch. + # @PRE: Repository exists and both branches are valid. + # @POST: Target branch contains merged changes from source branch. + # @RETURN: Dict[str, Any] + def promote_direct_merge( + self, + dashboard_id: int, + from_branch: str, + to_branch: str, + ) -> Dict[str, Any]: + with belief_scope("GitService.promote_direct_merge"): + if not from_branch or not to_branch: + raise HTTPException(status_code=400, detail="from_branch and to_branch are required") + repo = self.get_repo(dashboard_id) + source = from_branch.strip() + target = to_branch.strip() + if source == target: + raise HTTPException(status_code=400, detail="from_branch and to_branch must be different") + + try: + origin = repo.remote(name="origin") + except ValueError: + raise HTTPException(status_code=400, detail="Remote 'origin' not configured") + + try: + origin.fetch() + # Ensure local source branch exists. + if source not in [head.name for head in repo.heads]: + if f"origin/{source}" in [ref.name for ref in repo.refs]: + repo.git.checkout("-b", source, f"origin/{source}") + else: + raise HTTPException(status_code=404, detail=f"Source branch '{source}' not found") + + # Ensure local target branch exists and is checked out. + if target in [head.name for head in repo.heads]: + repo.git.checkout(target) + elif f"origin/{target}" in [ref.name for ref in repo.refs]: + repo.git.checkout("-b", target, f"origin/{target}") + else: + raise HTTPException(status_code=404, detail=f"Target branch '{target}' not found") + + # Bring target up to date and merge source into target. + try: + origin.pull(target) + except Exception: + pass + repo.git.merge(source, "--no-ff", "-m", f"chore(flow): promote {source} -> {target}") + origin.push(refspec=f"{target}:{target}") + except HTTPException: + raise + except Exception as e: + message = str(e) + if "CONFLICT" in message.upper(): + raise HTTPException(status_code=409, detail=f"Merge conflict during direct promote: {message}") + raise HTTPException(status_code=500, detail=f"Direct promote failed: {message}") + + return { + "mode": "direct", + "from_branch": source, + "to_branch": target, + "status": "merged", + } + # [/DEF:promote_direct_merge:Function] + + # [DEF:create_gitea_pull_request:Function] + # @PURPOSE: Create pull request in Gitea. + # @PRE: Config and remote URL are valid. + # @POST: Returns normalized PR metadata. + # @RETURN: Dict[str, Any] + async def create_gitea_pull_request( + self, + server_url: str, + pat: str, + remote_url: str, + from_branch: str, + to_branch: str, + title: str, + description: Optional[str] = None, + ) -> Dict[str, Any]: + identity = self._parse_remote_repo_identity(remote_url) + payload = { + "title": title, + "head": from_branch, + "base": to_branch, + "body": description or "", + } + data = await self._gitea_request( + "POST", + server_url, + pat, + f"/repos/{identity['namespace']}/{identity['repo']}/pulls", + payload=payload, + ) + return { + "id": data.get("number") or data.get("id"), + "url": data.get("html_url") or data.get("url"), + "status": data.get("state") or "open", + } + # [/DEF:create_gitea_pull_request:Function] + + # [DEF:create_github_pull_request:Function] + # @PURPOSE: Create pull request in GitHub or GitHub Enterprise. + # @PRE: Config and remote URL are valid. + # @POST: Returns normalized PR metadata. + # @RETURN: Dict[str, Any] + async def create_github_pull_request( + self, + server_url: str, + pat: str, + remote_url: str, + from_branch: str, + to_branch: str, + title: str, + description: Optional[str] = None, + draft: bool = False, + ) -> Dict[str, Any]: + identity = self._parse_remote_repo_identity(remote_url) + base_url = self._normalize_git_server_url(server_url) + if "github.com" in base_url: + api_url = f"https://api.github.com/repos/{identity['namespace']}/{identity['repo']}/pulls" + else: + api_url = f"{base_url}/api/v3/repos/{identity['namespace']}/{identity['repo']}/pulls" + headers = { + "Authorization": f"token {pat.strip()}", + "Content-Type": "application/json", + "Accept": "application/vnd.github+json", + } + payload = { + "title": title, + "head": from_branch, + "base": to_branch, + "body": description or "", + "draft": bool(draft), + } + try: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.post(api_url, headers=headers, json=payload) + except Exception as e: + raise HTTPException(status_code=503, detail=f"GitHub API is unavailable: {str(e)}") + if response.status_code >= 400: + detail = response.text + try: + detail = response.json().get("message") or detail + except Exception: + pass + raise HTTPException(status_code=response.status_code, detail=f"GitHub API error: {detail}") + data = response.json() + return { + "id": data.get("number") or data.get("id"), + "url": data.get("html_url") or data.get("url"), + "status": data.get("state") or "open", + } + # [/DEF:create_github_pull_request:Function] + + # [DEF:create_gitlab_merge_request:Function] + # @PURPOSE: Create merge request in GitLab. + # @PRE: Config and remote URL are valid. + # @POST: Returns normalized MR metadata. + # @RETURN: Dict[str, Any] + async def create_gitlab_merge_request( + self, + server_url: str, + pat: str, + remote_url: str, + from_branch: str, + to_branch: str, + title: str, + description: Optional[str] = None, + remove_source_branch: bool = False, + ) -> Dict[str, Any]: + identity = self._parse_remote_repo_identity(remote_url) + base_url = self._normalize_git_server_url(server_url) + project_id = quote(identity["full_name"], safe="") + api_url = f"{base_url}/api/v4/projects/{project_id}/merge_requests" + headers = { + "PRIVATE-TOKEN": pat.strip(), + "Content-Type": "application/json", + "Accept": "application/json", + } + payload = { + "source_branch": from_branch, + "target_branch": to_branch, + "title": title, + "description": description or "", + "remove_source_branch": bool(remove_source_branch), + } + try: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.post(api_url, headers=headers, json=payload) + except Exception as e: + raise HTTPException(status_code=503, detail=f"GitLab API is unavailable: {str(e)}") + if response.status_code >= 400: + detail = response.text + try: + parsed = response.json() + if isinstance(parsed, dict): + detail = parsed.get("message") or detail + except Exception: + pass + raise HTTPException(status_code=response.status_code, detail=f"GitLab API error: {detail}") + data = response.json() + return { + "id": data.get("iid") or data.get("id"), + "url": data.get("web_url") or data.get("url"), + "status": data.get("state") or "opened", + } + # [/DEF:create_gitlab_merge_request:Function] + # [/DEF:GitService:Class] # [/DEF:backend.src.services.git_service:Module] diff --git a/frontend/src/components/git/DeploymentModal.svelte b/frontend/src/components/git/DeploymentModal.svelte index b2f9a5f..4a6974b 100644 --- a/frontend/src/components/git/DeploymentModal.svelte +++ b/frontend/src/components/git/DeploymentModal.svelte @@ -11,14 +11,15 @@ @@ -427,6 +560,59 @@ +
+

Promote

+ {#if currentEnvStage} +
+ Stage: {currentEnvStage} + {#if preferredDeployTargetStage} + | Next deploy target: {preferredDeployTargetStage} + {/if} +
+ {/if} +
+ + +
+ + {/if} + +
+

{$t.git.deployment}