dev-preprod-prod logic
This commit is contained in:
Submodule backend/git_repos/10 updated: 5f04cdd3bc...3c0ade67f9
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -11,14 +11,15 @@
|
||||
|
||||
<script>
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount, createEventDispatcher } from "svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { gitService } from "../../services/gitService";
|
||||
import { api } from "../../lib/api.js";
|
||||
import { addToast as toast } from "../../lib/toasts.js";
|
||||
import { t } from "../../lib/i18n";
|
||||
// [/SECTION]
|
||||
|
||||
// [SECTION: PROPS]
|
||||
let { dashboardId, envId = null, show = false } = $props();
|
||||
let { dashboardId, envId = null, show = false, preferredTargetStage = "" } = $props();
|
||||
|
||||
// [/SECTION]
|
||||
|
||||
@@ -30,6 +31,13 @@
|
||||
// [/SECTION]
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const normalizedPreferredStage = $derived(String(preferredTargetStage || "").toUpperCase());
|
||||
const deploymentCandidates = $derived.by(() => {
|
||||
const all = environments.filter((env) => env?.id !== envId);
|
||||
if (!normalizedPreferredStage) return all;
|
||||
const stageMatched = all.filter((env) => normalizeEnvStage(env) === normalizedPreferredStage);
|
||||
return stageMatched.length > 0 ? stageMatched : all;
|
||||
});
|
||||
|
||||
// [DEF:loadStatus:Watcher]
|
||||
$effect(() => {
|
||||
@@ -37,6 +45,29 @@
|
||||
});
|
||||
// [/DEF:loadStatus:Watcher]
|
||||
|
||||
// [DEF:normalizeEnvStage:Function]
|
||||
/**
|
||||
* @purpose Normalize environment stage with legacy production fallback.
|
||||
* @post Returns DEV/PREPROD/PROD.
|
||||
*/
|
||||
function normalizeEnvStage(env) {
|
||||
if (env?.is_production) return "PROD";
|
||||
const stage = String(env?.stage || "").trim().toUpperCase();
|
||||
if (stage === "PROD" || stage === "PREPROD") return stage;
|
||||
return "DEV";
|
||||
}
|
||||
// [/DEF:normalizeEnvStage:Function]
|
||||
|
||||
// [DEF:resolveEnvUrl:Function]
|
||||
/**
|
||||
* @purpose Resolve environment URL from consolidated or git-specific payload shape.
|
||||
* @post Returns stable URL string.
|
||||
*/
|
||||
function resolveEnvUrl(env) {
|
||||
return String(env?.superset_url || env?.url || "");
|
||||
}
|
||||
// [/DEF:resolveEnvUrl:Function]
|
||||
|
||||
// [DEF:loadEnvironments:Function]
|
||||
/**
|
||||
* @purpose Fetch available environments from API.
|
||||
@@ -47,9 +78,13 @@
|
||||
console.log(`[DeploymentModal][Action] Loading environments`);
|
||||
loading = true;
|
||||
try {
|
||||
environments = await gitService.getEnvironments();
|
||||
if (environments.length > 0) {
|
||||
selectedEnv = environments[0].id;
|
||||
environments = await api.getEnvironmentsList();
|
||||
const candidates = (environments || []).filter((env) => env?.id !== envId);
|
||||
if (normalizedPreferredStage) {
|
||||
const stageMatched = candidates.filter((env) => normalizeEnvStage(env) === normalizedPreferredStage);
|
||||
selectedEnv = (stageMatched[0]?.id) || (candidates[0]?.id) || "";
|
||||
} else {
|
||||
selectedEnv = (candidates[0]?.id) || "";
|
||||
}
|
||||
console.log(
|
||||
`[DeploymentModal][Coherence:OK] Loaded ${environments.length} environments`,
|
||||
@@ -103,7 +138,7 @@
|
||||
|
||||
{#if loading}
|
||||
<p class="text-gray-500">{$t.migration?.loading_envs}</p>
|
||||
{:else if environments.length === 0}
|
||||
{:else if deploymentCandidates.length === 0}
|
||||
<p class="text-red-500 mb-4">
|
||||
{$t.git?.no_deploy_envs}
|
||||
</p>
|
||||
@@ -116,6 +151,11 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if normalizedPreferredStage}
|
||||
<p class="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
GitFlow target stage: {normalizedPreferredStage}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>{$t.migration?.target_env}</label
|
||||
@@ -124,9 +164,9 @@
|
||||
bind:value={selectedEnv}
|
||||
class="w-full border rounded p-2 focus:ring-2 focus:ring-blue-500 outline-none bg-white"
|
||||
>
|
||||
{#each environments as env}
|
||||
{#each deploymentCandidates as env}
|
||||
<option value={env.id}
|
||||
>{env.name} ({env.superset_url})</option
|
||||
>{env.name} [{normalizeEnvStage(env)}] ({resolveEnvUrl(env)})</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// [SECTION: IMPORTS]
|
||||
import { onMount } from 'svelte';
|
||||
import { gitService } from '../../services/gitService';
|
||||
import { api } from '../../lib/api.js';
|
||||
import { addToast as toast } from '../../lib/toasts.js';
|
||||
import { t } from '../../lib/i18n';
|
||||
import { Button, Card, PageHeader, Select, Input } from '../../lib/ui';
|
||||
@@ -51,8 +52,83 @@
|
||||
let selectedConfigId = $state("");
|
||||
let remoteUrl = $state("");
|
||||
let creatingRemoteRepo = $state(false);
|
||||
let promoting = $state(false);
|
||||
let promoteFromBranch = $state("dev");
|
||||
let promoteToBranch = $state("preprod");
|
||||
let promoteMode = $state("mr");
|
||||
let promoteReason = $state("");
|
||||
let currentEnvStage = $state("");
|
||||
let preferredDeployTargetStage = $state("");
|
||||
// [/SECTION]
|
||||
|
||||
// [DEF:normalizeEnvStage:Function]
|
||||
/**
|
||||
* @purpose Normalize environment stage with legacy fallback.
|
||||
* @post Returns DEV/PREPROD/PROD.
|
||||
*/
|
||||
function normalizeEnvStage(env) {
|
||||
if (env?.is_production) return 'PROD';
|
||||
const stage = String(env?.stage || '').trim().toUpperCase();
|
||||
if (stage === 'PROD' || stage === 'PREPROD') return stage;
|
||||
return 'DEV';
|
||||
}
|
||||
// [/DEF:normalizeEnvStage:Function]
|
||||
|
||||
// [DEF:resolveCurrentEnvironmentId:Function]
|
||||
/**
|
||||
* @purpose Resolve active environment id for current dashboard view.
|
||||
* @post Returns env id from prop or selected_env_id in localStorage.
|
||||
*/
|
||||
function resolveCurrentEnvironmentId() {
|
||||
if (envId) return String(envId);
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('selected_env_id');
|
||||
}
|
||||
// [/DEF:resolveCurrentEnvironmentId:Function]
|
||||
|
||||
// [DEF:applyGitflowStageDefaults:Function]
|
||||
/**
|
||||
* @purpose Apply branch promotion/deploy defaults based on environment stage.
|
||||
* @post Promote fields and deploy target stage are updated.
|
||||
*/
|
||||
function applyGitflowStageDefaults(stage) {
|
||||
const normalizedStage = String(stage || '').toUpperCase();
|
||||
if (normalizedStage === 'DEV') {
|
||||
promoteFromBranch = 'dev';
|
||||
promoteToBranch = 'preprod';
|
||||
preferredDeployTargetStage = 'PREPROD';
|
||||
return;
|
||||
}
|
||||
if (normalizedStage === 'PREPROD') {
|
||||
promoteFromBranch = 'preprod';
|
||||
promoteToBranch = 'main';
|
||||
preferredDeployTargetStage = 'PROD';
|
||||
return;
|
||||
}
|
||||
preferredDeployTargetStage = '';
|
||||
}
|
||||
// [/DEF:applyGitflowStageDefaults:Function]
|
||||
|
||||
// [DEF:loadCurrentEnvironmentStage:Function]
|
||||
/**
|
||||
* @purpose Detect current environment stage and bind Gitflow defaults.
|
||||
* @post currentEnvStage and defaults are set when environment is found.
|
||||
*/
|
||||
async function loadCurrentEnvironmentStage() {
|
||||
try {
|
||||
const currentEnvId = resolveCurrentEnvironmentId();
|
||||
if (!currentEnvId) return;
|
||||
const environments = await api.getEnvironmentsList();
|
||||
const currentEnv = (environments || []).find((item) => item.id === currentEnvId);
|
||||
if (!currentEnv) return;
|
||||
currentEnvStage = normalizeEnvStage(currentEnv);
|
||||
applyGitflowStageDefaults(currentEnvStage);
|
||||
} catch (e) {
|
||||
console.error(`[GitManager][Coherence:Failed] Failed to resolve environment stage: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// [/DEF:loadCurrentEnvironmentStage:Function]
|
||||
|
||||
// [DEF:isNumericDashboardRef:Function]
|
||||
/**
|
||||
* @purpose Checks whether current dashboard reference is numeric ID.
|
||||
@@ -277,6 +353,61 @@
|
||||
}
|
||||
// [/DEF:handlePull:Function]
|
||||
|
||||
// [DEF:handlePromote:Function]
|
||||
/**
|
||||
* @purpose Promotes changes between branches via MR (default) or direct merge (unsafe).
|
||||
* @pre Repository is initialized and source/target branches are provided.
|
||||
* @post Promotion request is sent to backend and user receives result feedback.
|
||||
*/
|
||||
async function handlePromote() {
|
||||
if (isNumericDashboardRef()) {
|
||||
toast('GitManager requires dashboard slug. Numeric ID is forbidden.', 'error');
|
||||
return;
|
||||
}
|
||||
const fromBranch = String(promoteFromBranch || '').trim();
|
||||
const toBranch = String(promoteToBranch || '').trim();
|
||||
if (!fromBranch || !toBranch || fromBranch === toBranch) {
|
||||
toast('Select different source and target branches', 'error');
|
||||
return;
|
||||
}
|
||||
if (promoteMode === 'direct' && !String(promoteReason || '').trim()) {
|
||||
toast('Unsafe direct promote requires explicit reason', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
promoting = true;
|
||||
try {
|
||||
const response = await gitService.promote(
|
||||
dashboardId,
|
||||
{
|
||||
from_branch: fromBranch,
|
||||
to_branch: toBranch,
|
||||
mode: promoteMode,
|
||||
title: `Promote ${fromBranch} -> ${toBranch}: ${dashboardTitle || dashboardId}`,
|
||||
description: promoteMode === 'direct'
|
||||
? `Unsafe direct promote requested.\nReason: ${promoteReason}`
|
||||
: undefined,
|
||||
reason: promoteMode === 'direct' ? promoteReason : undefined,
|
||||
},
|
||||
envId,
|
||||
);
|
||||
|
||||
if (promoteMode === 'direct') {
|
||||
toast('Unsafe direct promote completed. Policy violation logged.', 'warning');
|
||||
} else {
|
||||
if (response?.url) {
|
||||
window.open(response.url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
toast('Merge request created on Git server', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
promoting = false;
|
||||
}
|
||||
}
|
||||
// [/DEF:handlePromote:Function]
|
||||
|
||||
// [DEF:closeModal:Function]
|
||||
/**
|
||||
* @purpose Закрывает модальное окно управления Git.
|
||||
@@ -300,7 +431,9 @@
|
||||
}
|
||||
// [/DEF:handleBackdropClick:Function]
|
||||
|
||||
onMount(checkStatus);
|
||||
onMount(async () => {
|
||||
await Promise.all([checkStatus(), loadCurrentEnvironmentStage()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- [SECTION: TEMPLATE] -->
|
||||
@@ -427,6 +560,59 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Promote</h3>
|
||||
{#if currentEnvStage}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
Stage: {currentEnvStage}
|
||||
{#if preferredDeployTargetStage}
|
||||
| Next deploy target: {preferredDeployTargetStage}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
label="From branch"
|
||||
bind:value={promoteFromBranch}
|
||||
placeholder="dev"
|
||||
/>
|
||||
<Input
|
||||
label="To branch"
|
||||
bind:value={promoteToBranch}
|
||||
placeholder="preprod"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Promotion mode"
|
||||
bind:value={promoteMode}
|
||||
options={[
|
||||
{ value: 'mr', label: 'Create MR/PR (Safe)' },
|
||||
{ value: 'direct', label: 'Direct merge without MR (Unsafe)' },
|
||||
]}
|
||||
/>
|
||||
{#if promoteMode === 'direct'}
|
||||
<div class="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800">
|
||||
<div class="font-semibold">Warning: Unsafe direct promote</div>
|
||||
<div class="mt-1">This bypasses MR approval rules and writes a policy violation to logs.</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Reason (required)"
|
||||
bind:value={promoteReason}
|
||||
placeholder="Why MR is bypassed?"
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
onclick={handlePromote}
|
||||
disabled={promoting || loading}
|
||||
isLoading={promoting}
|
||||
class={`w-full ${promoteMode === 'direct' ? 'bg-red-600 hover:bg-red-700 focus-visible:ring-red-500' : ''}`}
|
||||
>
|
||||
{promoteMode === 'direct'
|
||||
? 'Direct promote (unsafe)'
|
||||
: 'Create MR/PR for promote'}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">{$t.git.deployment}</h3>
|
||||
<Button
|
||||
@@ -460,6 +646,7 @@
|
||||
<DeploymentModal
|
||||
{dashboardId}
|
||||
{envId}
|
||||
preferredTargetStage={preferredDeployTargetStage}
|
||||
bind:show={showDeployModal}
|
||||
/>
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@
|
||||
$: globalEnvironments = $environmentContextStore?.environments || [];
|
||||
$: globalSelectedEnvId = $environmentContextStore?.selectedEnvId || "";
|
||||
$: globalSelectedEnv = $selectedEnvironmentStore;
|
||||
$: isProdContext = Boolean(globalSelectedEnv?.is_production);
|
||||
$: isProdContext =
|
||||
String(globalSelectedEnv?.stage || "").toUpperCase() === "PROD" ||
|
||||
Boolean(globalSelectedEnv?.is_production);
|
||||
|
||||
function toggleUserMenu(event) {
|
||||
event.stopPropagation();
|
||||
@@ -431,7 +433,7 @@
|
||||
>
|
||||
{#each globalEnvironments as env}
|
||||
<option value={env.id}>
|
||||
{env.name}{env.is_production ? " [PROD]" : ""}
|
||||
{env.name}{(String(env.stage || "").toUpperCase() === "PROD" || env.is_production) ? " [PROD]" : ""}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
@@ -136,6 +136,8 @@ export const selectedEnvironmentStore = derived(
|
||||
|
||||
export const isProductionContextStore = derived(
|
||||
selectedEnvironmentStore,
|
||||
($selectedEnvironment) => Boolean($selectedEnvironment?.is_production),
|
||||
($selectedEnvironment) =>
|
||||
String($selectedEnvironment?.stage || "").toUpperCase() === "PROD" ||
|
||||
Boolean($selectedEnvironment?.is_production),
|
||||
);
|
||||
// [/DEF:environmentContext:Store]
|
||||
|
||||
@@ -3,12 +3,30 @@
|
||||
// @PURPOSE: Manages toast notifications using a Svelte writable store.
|
||||
// @LAYER: UI-State
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// [DEF:toasts:Data]
|
||||
// @PURPOSE: Writable store containing the list of active toasts.
|
||||
export const toasts = writable([]);
|
||||
// [/DEF:toasts:Data]
|
||||
export const toasts = writable([]);
|
||||
// [/DEF:toasts:Data]
|
||||
|
||||
const TOAST_DEDUP_WINDOW_MS = 1200;
|
||||
const recentToastByKey = new Map();
|
||||
|
||||
function buildToastKey(message, type) {
|
||||
return `${String(type || 'info')}::${String(message || '')}`;
|
||||
}
|
||||
|
||||
function shouldSkipDuplicateToast(message, type) {
|
||||
const key = buildToastKey(message, type);
|
||||
const now = Date.now();
|
||||
const previousAt = recentToastByKey.get(key);
|
||||
if (previousAt && now - previousAt < TOAST_DEDUP_WINDOW_MS) {
|
||||
return true;
|
||||
}
|
||||
recentToastByKey.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
// [DEF:addToast:Function]
|
||||
// @PURPOSE: Adds a new toast message.
|
||||
@@ -17,12 +35,16 @@ export const toasts = writable([]);
|
||||
// @PARAM: message (string) - The message text.
|
||||
// @PARAM: type (string) - The type of toast (info, success, error).
|
||||
// @PARAM: duration (number) - Duration in ms before the toast is removed.
|
||||
export function addToast(message, type = 'info', duration = 3000) {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
console.log(`[toasts.addToast][Action] Adding toast context={{'id': '${id}', 'type': '${type}', 'message': '${message}'}}`);
|
||||
toasts.update(all => [...all, { id, message, type }]);
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
export function addToast(message, type = 'info', duration = 3000) {
|
||||
if (shouldSkipDuplicateToast(message, type)) {
|
||||
console.log(`[toasts.addToast][Action] Duplicate skipped context={{'type': '${type}', 'message': '${message}'}}`);
|
||||
return;
|
||||
}
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
console.log(`[toasts.addToast][Action] Adding toast context={{'id': '${id}', 'type': '${type}', 'message': '${message}'}}`);
|
||||
toasts.update(all => [...all, { id, message, type }]);
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
// [/DEF:addToast:Function]
|
||||
|
||||
// [DEF:removeToast:Function]
|
||||
@@ -35,4 +57,4 @@ function removeToast(id) {
|
||||
toasts.update(all => all.filter(t => t.id !== id));
|
||||
}
|
||||
// [/DEF:removeToast:Function]
|
||||
// [/DEF:toasts_module:Module]
|
||||
// [/DEF:toasts_module:Module]
|
||||
|
||||
@@ -74,8 +74,8 @@
|
||||
url: "",
|
||||
username: "",
|
||||
password: "",
|
||||
stage: "DEV",
|
||||
is_default: false,
|
||||
is_production: false,
|
||||
backup_schedule: {
|
||||
enabled: false,
|
||||
cron_expression: "0 0 * * *",
|
||||
@@ -356,8 +356,13 @@
|
||||
if (!newEnv.backup_schedule) {
|
||||
newEnv.backup_schedule = { enabled: false, cron_expression: "0 0 * * *" };
|
||||
}
|
||||
if (typeof newEnv.is_production !== "boolean") {
|
||||
newEnv.is_production = false;
|
||||
const rawStage = String(newEnv.stage || "").toUpperCase();
|
||||
if (rawStage === "DEV" && newEnv.is_production === true) {
|
||||
newEnv.stage = "PROD";
|
||||
} else if (!rawStage) {
|
||||
newEnv.stage = newEnv.is_production ? "PROD" : "DEV";
|
||||
} else {
|
||||
newEnv.stage = rawStage;
|
||||
}
|
||||
editingEnvId = env.id;
|
||||
isAddingEnv = false;
|
||||
@@ -370,8 +375,8 @@
|
||||
url: "",
|
||||
username: "",
|
||||
password: "",
|
||||
stage: "DEV",
|
||||
is_default: false,
|
||||
is_production: false,
|
||||
backup_schedule: {
|
||||
enabled: false,
|
||||
cron_expression: "0 0 * * *",
|
||||
@@ -392,11 +397,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...newEnv,
|
||||
stage: String(newEnv.stage || "DEV").toUpperCase(),
|
||||
is_production: String(newEnv.stage || "DEV").toUpperCase() === "PROD",
|
||||
};
|
||||
|
||||
if (editingEnvId) {
|
||||
await api.updateEnvironment(editingEnvId, newEnv);
|
||||
await api.updateEnvironment(editingEnvId, payload);
|
||||
addToast($t.settings?.env_updated, "success");
|
||||
} else {
|
||||
await api.addEnvironment(newEnv);
|
||||
await api.addEnvironment(payload);
|
||||
addToast($t.settings?.env_added, "success");
|
||||
}
|
||||
|
||||
@@ -429,6 +440,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEnvStage(env) {
|
||||
return String((env?.is_production ? "PROD" : env?.stage) || "DEV").toUpperCase();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto w-full max-w-7xl space-y-6">
|
||||
@@ -679,19 +694,21 @@
|
||||
>{$t.settings?.env_default}</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center mt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="env_production"
|
||||
bind:checked={newEnv.is_production}
|
||||
class="h-4 w-4 text-red-600 border-gray-300 rounded"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
for="env_production"
|
||||
class="ml-2 block text-sm font-medium text-red-700"
|
||||
>{$t.settings?.env_production ||
|
||||
"Production environment"}</label
|
||||
for="env_stage"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Stage</label
|
||||
>
|
||||
<select
|
||||
id="env_stage"
|
||||
bind:value={newEnv.stage}
|
||||
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
|
||||
>
|
||||
<option value="DEV">DEV</option>
|
||||
<option value="PREPROD">PREPROD</option>
|
||||
<option value="PROD">PROD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -777,7 +794,7 @@
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>{$t.settings?.env_production || "Production"}</th
|
||||
>Stage</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
@@ -804,15 +821,10 @@
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{#if env.is_production}
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"
|
||||
>
|
||||
PROD
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-gray-500">{$t.common?.no}</span>
|
||||
{/if}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{resolveEnvStage(env) === 'PROD' ? 'bg-red-100 text-red-800' : resolveEnvStage(env) === 'PREPROD' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800'}">
|
||||
{resolveEnvStage(env)}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
|
||||
|
||||
@@ -335,6 +335,21 @@ export const gitService = {
|
||||
if (envId) params.append('env_id', String(envId));
|
||||
if (params.toString()) endpoint += `?${params.toString()}`;
|
||||
return requestApi(endpoint);
|
||||
},
|
||||
|
||||
/**
|
||||
* [DEF:promote:Function]
|
||||
* @purpose Promotes changes between branches via MR or direct merge.
|
||||
* @pre Dashboard repository must be initialized.
|
||||
* @post Returns promotion metadata (MR URL or direct merge status).
|
||||
* @param {string|number} dashboardRef - Dashboard slug or id.
|
||||
* @param {Object} payload - {from_branch,to_branch,mode,title,description,reason,draft,remove_source_branch}
|
||||
* @param {string|null} envId - Environment id for slug resolution.
|
||||
* @returns {Promise<Object>} Promotion result.
|
||||
*/
|
||||
async promote(dashboardRef, payload, envId = null) {
|
||||
console.log(`[promote][Action] Promoting ${payload?.from_branch} -> ${payload?.to_branch} for dashboard ${dashboardRef} mode=${payload?.mode}`);
|
||||
return requestApi(buildDashboardRepoEndpoint(dashboardRef, '/promote', envId), 'POST', payload);
|
||||
}
|
||||
};
|
||||
// [/DEF:gitService:Action]
|
||||
|
||||
Reference in New Issue
Block a user