dev-preprod-prod logic
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user