Compare commits

..

1 Commits

Author SHA1 Message Date
repair-agent
b152832440 fix: 添加 httpx 依赖,修复 API 启动崩溃
gitea_client.py 依赖 httpx 但未列入 requirements.txt,
导致 Docker 镜像中缺少该模块,API 启动时 import 失败。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:45:57 +08:00
6 changed files with 31 additions and 80 deletions

View File

@ -16,13 +16,7 @@ DB_NAME = os.getenv("DB_NAME")
DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
engine = create_async_engine(
DATABASE_URL,
echo=True,
future=True,
pool_pre_ping=True,
pool_recycle=300,
)
engine = create_async_engine(DATABASE_URL, echo=True, future=True)
async def init_db():
async with engine.begin() as conn:

View File

@ -1,10 +1,9 @@
"""
Gitea API 客户端
"""
import re
import httpx
import os
from typing import Tuple, Optional
from typing import Tuple
class GiteaClient:
@ -13,7 +12,7 @@ class GiteaClient:
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.base_api_url = f"{self.gitea_url}/api/v1"
self.client = httpx.Client(timeout=30)
def _headers(self) -> dict:
@ -23,31 +22,21 @@ class GiteaClient:
"Content-Type": "application/json",
}
def _parse_pr_url(self, pr_url: str) -> Tuple[str, str, str, int]:
def _extract_owner_repo_from_pr_url(self, pr_url: str) -> Tuple[str, str, int]:
"""
PR URL 提取 base_url, owner, repo, pr_number
PR 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)
import re
match = re.search(r'([^/]+)/([^/]+)/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 中无法提取")
owner, repo, pr_number = match.groups()
return owner, repo, int(pr_number)
def merge_pr(
self, owner: str, repo: str, pr_number: int, api_base_url: str = None
self, owner: str, repo: str, pr_number: int
) -> Tuple[bool, str]:
"""
合并 PR
@ -56,13 +45,11 @@ class GiteaClient:
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"
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}/merge"
payload = {
"Do": "merge", # merge / squash / rebase
"MergeMessageField": f"Merge PR #{pr_number} (approved via Log Center)",
@ -73,11 +60,10 @@ class GiteaClient:
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:
if e.response.status_code == 405:
error_msg += ": PR 已经合并或已关闭"
elif e.response.status_code == 409:
error_msg += ": 存在合并冲突"
elif e.response.status_code == 403:
error_msg += ": Token 权限不足"
@ -88,7 +74,7 @@ class GiteaClient:
return False, str(e)
def close_pr(
self, owner: str, repo: str, pr_number: int, reason: str = "", api_base_url: str = None
self, owner: str, repo: str, pr_number: int, reason: str = ""
) -> Tuple[bool, str]:
"""
关闭 PR可选添加评论说明原因
@ -98,24 +84,20 @@ class GiteaClient:
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,
owner, repo, pr_number, f"## ❌ 修复被拒绝\n\n**原因:**\n{reason}"
)
if not comment_success:
return False, f"添加评论失败: {comment_msg}"
# Step 2: 关闭 PR
url = f"{base}/repos/{owner}/{repo}/pulls/{pr_number}"
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}"
payload = {"state": "closed"}
try:
@ -128,7 +110,7 @@ class GiteaClient:
return False, str(e)
def add_pr_comment(
self, owner: str, repo: str, pr_number: int, comment: str, api_base_url: str = None
self, owner: str, repo: str, pr_number: int, comment: str
) -> Tuple[bool, str]:
"""
添加 PR 评论
@ -138,14 +120,12 @@ class GiteaClient:
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"
url = f"{self.base_api_url}/repos/{owner}/{repo}/issues/{pr_number}/comments"
payload = {"body": comment}
try:
@ -156,19 +136,17 @@ class GiteaClient:
return False, str(e)
def merge_pr_by_url(self, pr_url: str) -> Tuple[bool, str]:
"""通过 PR URL 直接合并(从 URL 自动解析 Gitea 地址)"""
"""通过 PR URL 直接合并"""
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)
owner, repo, pr_number = self._extract_owner_repo_from_pr_url(pr_url)
return self.merge_pr(owner, repo, pr_number)
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 地址)"""
"""通过 PR URL 直接关闭"""
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)
owner, repo, pr_number = self._extract_owner_repo_from_pr_url(pr_url)
return self.close_pr(owner, repo, pr_number, reason)
except Exception as e:
return False, str(e)

View File

@ -85,8 +85,7 @@ def generate_fingerprint(log: ErrorLogCreate) -> str:
if source == "cicd":
ctx = log.context or {}
# 加入 error_message 避免同一 job 不同错误被去重
raw = f"{log.project_id}|cicd|{log.error.get('type')}|{ctx.get('job_name', 'unknown')}|{ctx.get('step_name', 'unknown')}|{log.error.get('message', '')}"
raw = f"{log.project_id}|cicd|{log.error.get('type')}|{ctx.get('job_name', 'unknown')}|{ctx.get('step_name', 'unknown')}"
elif source == "deployment":
ctx = log.context or {}
raw = f"{log.project_id}|deployment|{log.error.get('type')}|{ctx.get('namespace', 'default')}|{ctx.get('deployment_name', 'unknown')}"
@ -106,28 +105,13 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
existing_log = results.first()
if existing_log:
# If exists and not resolved, update error content but keep status
# If exists and not resolved, just ignore or update count (implied)
if existing_log.status not in [LogStatus.DEPLOYED, LogStatus.FIXED, LogStatus.VERIFIED]:
# 更新错误内容,确保始终反映最新的错误信息
existing_log.error_message = log_data.error.get("message", existing_log.error_message)
existing_log.stack_trace = log_data.error.get("stack_trace", existing_log.stack_trace)
existing_log.context = log_data.context or existing_log.context
existing_log.timestamp = log_data.timestamp or datetime.utcnow()
if log_data.commit_hash:
existing_log.commit_hash = log_data.commit_hash
session.add(existing_log)
await session.commit()
await session.refresh(existing_log)
return {"message": "Log deduplicated (content updated)", "id": existing_log.id, "status": existing_log.status}
# If it was resolved but happened again -> Regression! Reset to NEW
return {"message": "Log deduplicated", "id": existing_log.id, "status": existing_log.status}
# If it was resolved but happened again -> Regression! Reset to NEW?
existing_log.status = LogStatus.NEW
existing_log.error_message = log_data.error.get("message", existing_log.error_message)
existing_log.stack_trace = log_data.error.get("stack_trace", existing_log.stack_trace)
existing_log.context = log_data.context or existing_log.context
existing_log.timestamp = log_data.timestamp or datetime.utcnow()
existing_log.retry_count = 0
if log_data.commit_hash:
existing_log.commit_hash = log_data.commit_hash
existing_log.retry_count = 0 # Reset retries for new occurrence
session.add(existing_log)
await session.commit()
await session.refresh(existing_log)
@ -370,7 +354,7 @@ async def approve_report(report_id: int, session: AsyncSession = Depends(get_ses
gitea_client = GiteaClient()
success, message = gitea_client.merge_pr_by_url(report.pr_url)
if not success:
raise HTTPException(status_code=502, detail=f"合并 PR 失败: {message}")
raise HTTPException(status_code=500, detail=f"合并 PR 失败: {message}")
# 更新报告状态
report.status = LogStatus.FIXED

View File

@ -32,9 +32,6 @@ spec:
value: "log_center"
- name: DB_PASSWORD
value: "JogNQdtrd3WY8CBCAiYfYEGx"
# Gitea TokenURL 从 PR URL 自动解析)
- name: GITEA_TOKEN
value: "443f7f2f556b4832f90e46df9af3e21ccb06b8a3"
resources:
requests:
memory: "128Mi"

View File

@ -38,7 +38,6 @@ class ClaudeService:
cmd = [
self.cli_path,
"-p", prompt,
"--model", settings.claude_model,
"--dangerously-skip-permissions",
]

View File

@ -19,7 +19,6 @@ class Settings(BaseSettings):
# Claude CLI
claude_cli_path: str = Field(default="claude", description="Claude CLI 路径")
claude_timeout: int = Field(default=1000, description="Claude CLI 超时时间(秒)")
claude_model: str = Field(default="claude-opus-4-6", description="Claude 模型")
# Git
git_user_name: str = Field(default="repair-agent", description="Git 用户名")