All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m39s
175 lines
6.4 KiB
Python
175 lines
6.4 KiB
Python
"""
|
||
Gitea API 客户端
|
||
"""
|
||
import re
|
||
import httpx
|
||
import os
|
||
from typing import Tuple, Optional
|
||
|
||
|
||
class GiteaClient:
|
||
"""Gitea API 操作客户端"""
|
||
|
||
def __init__(self, gitea_url: str = None, token: str = None):
|
||
self.gitea_url = (gitea_url or os.getenv("GITEA_URL", "")).rstrip("/")
|
||
self.token = token or os.getenv("GITEA_TOKEN", "")
|
||
self.base_api_url = f"{self.gitea_url}/api/v1" if self.gitea_url else ""
|
||
self.client = httpx.Client(timeout=30)
|
||
|
||
def _headers(self) -> dict:
|
||
"""生成请求头"""
|
||
return {
|
||
"Authorization": f"token {self.token}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
def _parse_pr_url(self, pr_url: str) -> Tuple[str, str, str, int]:
|
||
"""
|
||
从 PR URL 提取 base_url, owner, repo, pr_number
|
||
例如: https://gitea.airlabs.art/owner/repo/pulls/45
|
||
返回: ("https://gitea.airlabs.art", "owner", "repo", 45)
|
||
"""
|
||
match = re.search(r'(https?://[^/]+)/([^/]+)/([^/]+)/pulls/(\d+)', pr_url)
|
||
if not match:
|
||
raise ValueError(f"无法解析 PR URL: {pr_url}")
|
||
|
||
base_url, owner, repo, pr_number = match.groups()
|
||
return base_url, owner, repo, int(pr_number)
|
||
|
||
def _get_api_url(self, pr_url: Optional[str] = None) -> str:
|
||
"""获取 API base URL,优先从 pr_url 解析,否则用 self.base_api_url"""
|
||
if pr_url:
|
||
match = re.search(r'(https?://[^/]+)/', pr_url)
|
||
if match:
|
||
return f"{match.group(1)}/api/v1"
|
||
if self.base_api_url:
|
||
return self.base_api_url
|
||
raise ValueError("无法确定 Gitea API 地址:未配置 GITEA_URL 且 PR URL 中无法提取")
|
||
|
||
def merge_pr(
|
||
self, owner: str, repo: str, pr_number: int, api_base_url: str = None
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
合并 PR
|
||
|
||
Args:
|
||
owner: 仓库所有者
|
||
repo: 仓库名称
|
||
pr_number: PR 编号
|
||
api_base_url: 可选,API base URL(从 pr_url 解析得到)
|
||
|
||
Returns:
|
||
(是否成功, 消息)
|
||
"""
|
||
base = api_base_url or self.base_api_url
|
||
url = f"{base}/repos/{owner}/{repo}/pulls/{pr_number}/merge"
|
||
payload = {
|
||
"Do": "merge", # merge / squash / rebase
|
||
"MergeMessageField": f"Merge PR #{pr_number} (approved via Log Center)",
|
||
}
|
||
|
||
try:
|
||
response = self.client.post(url, json=payload, headers=self._headers())
|
||
response.raise_for_status()
|
||
return True, "PR 合并成功"
|
||
except httpx.HTTPStatusError as e:
|
||
if e.response.status_code == 405:
|
||
# PR 已经被合并或关闭,视为成功(期望的结果已达成)
|
||
return True, "PR 已经被合并或关闭"
|
||
error_msg = f"HTTP {e.response.status_code}"
|
||
if e.response.status_code == 409:
|
||
error_msg += ": 存在合并冲突"
|
||
elif e.response.status_code == 403:
|
||
error_msg += ": Token 权限不足"
|
||
else:
|
||
error_msg += f": {e.response.text[:200]}"
|
||
return False, error_msg
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
def close_pr(
|
||
self, owner: str, repo: str, pr_number: int, reason: str = "", api_base_url: str = None
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
关闭 PR(可选添加评论说明原因)
|
||
|
||
Args:
|
||
owner: 仓库所有者
|
||
repo: 仓库名称
|
||
pr_number: PR 编号
|
||
reason: 关闭原因(将作为评论添加)
|
||
api_base_url: 可选,API base URL(从 pr_url 解析得到)
|
||
|
||
Returns:
|
||
(是否成功, 消息)
|
||
"""
|
||
base = api_base_url or self.base_api_url
|
||
|
||
# Step 1: 如果提供了原因,先添加评论
|
||
if reason:
|
||
comment_success, comment_msg = self.add_pr_comment(
|
||
owner, repo, pr_number, f"## ❌ 修复被拒绝\n\n**原因:**\n{reason}",
|
||
api_base_url=base,
|
||
)
|
||
if not comment_success:
|
||
return False, f"添加评论失败: {comment_msg}"
|
||
|
||
# Step 2: 关闭 PR
|
||
url = f"{base}/repos/{owner}/{repo}/pulls/{pr_number}"
|
||
payload = {"state": "closed"}
|
||
|
||
try:
|
||
response = self.client.patch(url, json=payload, headers=self._headers())
|
||
response.raise_for_status()
|
||
return True, "PR 已关闭"
|
||
except httpx.HTTPStatusError as e:
|
||
return False, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
def add_pr_comment(
|
||
self, owner: str, repo: str, pr_number: int, comment: str, api_base_url: str = None
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
添加 PR 评论
|
||
|
||
Args:
|
||
owner: 仓库所有者
|
||
repo: 仓库名称
|
||
pr_number: PR 编号
|
||
comment: 评论内容
|
||
api_base_url: 可选,API base URL
|
||
|
||
Returns:
|
||
(是否成功, 消息)
|
||
"""
|
||
base = api_base_url or self.base_api_url
|
||
# 注意: Gitea 中 PR 和 Issue 共用评论 API
|
||
url = f"{base}/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
||
payload = {"body": comment}
|
||
|
||
try:
|
||
response = self.client.post(url, json=payload, headers=self._headers())
|
||
response.raise_for_status()
|
||
return True, "评论添加成功"
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
def merge_pr_by_url(self, pr_url: str) -> Tuple[bool, str]:
|
||
"""通过 PR URL 直接合并(从 URL 自动解析 Gitea 地址)"""
|
||
try:
|
||
base_url, owner, repo, pr_number = self._parse_pr_url(pr_url)
|
||
api_base_url = f"{base_url}/api/v1"
|
||
return self.merge_pr(owner, repo, pr_number, api_base_url=api_base_url)
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
def close_pr_by_url(self, pr_url: str, reason: str = "") -> Tuple[bool, str]:
|
||
"""通过 PR URL 直接关闭(从 URL 自动解析 Gitea 地址)"""
|
||
try:
|
||
base_url, owner, repo, pr_number = self._parse_pr_url(pr_url)
|
||
api_base_url = f"{base_url}/api/v1"
|
||
return self.close_pr(owner, repo, pr_number, reason, api_base_url=api_base_url)
|
||
except Exception as e:
|
||
return False, str(e)
|