fix git pr
Some checks failed
Build and Deploy Log Center / build-and-deploy (push) Failing after 1m55s
Some checks failed
Build and Deploy Log Center / build-and-deploy (push) Failing after 1m55s
This commit is contained in:
parent
33db841592
commit
5611839fd8
3
app/.env.example
Normal file
3
app/.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
# Gitea 配置
|
||||
GITEA_URL=https://gitea.airlabs.art
|
||||
GITEA_TOKEN=your_gitea_token_here
|
||||
152
app/gitea_client.py
Normal file
152
app/gitea_client.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""
|
||||
Gitea API 客户端
|
||||
"""
|
||||
import httpx
|
||||
import os
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
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"
|
||||
self.client = httpx.Client(timeout=30)
|
||||
|
||||
def _headers(self) -> dict:
|
||||
"""生成请求头"""
|
||||
return {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _extract_owner_repo_from_pr_url(self, pr_url: str) -> Tuple[str, str, int]:
|
||||
"""
|
||||
从 PR URL 提取 owner, repo, pr_number
|
||||
例如: https://gitea.airlabs.art/owner/repo/pulls/45
|
||||
"""
|
||||
import re
|
||||
match = re.search(r'([^/]+)/([^/]+)/pulls/(\d+)', pr_url)
|
||||
if not match:
|
||||
raise ValueError(f"无法解析 PR 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
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
合并 PR
|
||||
|
||||
Args:
|
||||
owner: 仓库所有者
|
||||
repo: 仓库名称
|
||||
pr_number: PR 编号
|
||||
|
||||
Returns:
|
||||
(是否成功, 消息)
|
||||
"""
|
||||
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)",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.client.post(url, json=payload, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
return True, "PR 合并成功"
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_msg = f"HTTP {e.response.status_code}"
|
||||
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 权限不足"
|
||||
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 = ""
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
关闭 PR(可选添加评论说明原因)
|
||||
|
||||
Args:
|
||||
owner: 仓库所有者
|
||||
repo: 仓库名称
|
||||
pr_number: PR 编号
|
||||
reason: 关闭原因(将作为评论添加)
|
||||
|
||||
Returns:
|
||||
(是否成功, 消息)
|
||||
"""
|
||||
# Step 1: 如果提供了原因,先添加评论
|
||||
if reason:
|
||||
comment_success, comment_msg = self.add_pr_comment(
|
||||
owner, repo, pr_number, f"## ❌ 修复被拒绝\n\n**原因:**\n{reason}"
|
||||
)
|
||||
if not comment_success:
|
||||
return False, f"添加评论失败: {comment_msg}"
|
||||
|
||||
# Step 2: 关闭 PR
|
||||
url = f"{self.base_api_url}/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
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
添加 PR 评论
|
||||
|
||||
Args:
|
||||
owner: 仓库所有者
|
||||
repo: 仓库名称
|
||||
pr_number: PR 编号
|
||||
comment: 评论内容
|
||||
|
||||
Returns:
|
||||
(是否成功, 消息)
|
||||
"""
|
||||
# 注意: Gitea 中 PR 和 Issue 共用评论 API
|
||||
url = f"{self.base_api_url}/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 直接合并"""
|
||||
try:
|
||||
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 直接关闭"""
|
||||
try:
|
||||
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)
|
||||
128
app/main.py
128
app/main.py
@ -4,8 +4,10 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel import select, func
|
||||
from .database import init_db, get_session
|
||||
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
|
||||
from .gitea_client import GiteaClient
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
@ -359,3 +361,129 @@ async def update_project(project_id: str, data: ProjectUpdate, session: AsyncSes
|
||||
async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ==================== PR 操作 ====================
|
||||
|
||||
class PRRejectRequest(BaseModel):
|
||||
"""拒绝 PR 请求"""
|
||||
reason: str # 拒绝原因
|
||||
|
||||
|
||||
@app.post("/api/v1/bugs/{bug_id}/merge-pr", tags=["PR Operations"])
|
||||
async def merge_pr(
|
||||
bug_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
批准并合并 PR
|
||||
|
||||
流程:
|
||||
1. 调用 Gitea API 合并 PR
|
||||
2. 更新 Bug 状态 → MERGED
|
||||
"""
|
||||
statement = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||
results = await session.exec(statement)
|
||||
bug = results.first()
|
||||
|
||||
if not bug:
|
||||
raise HTTPException(status_code=404, detail="Bug not found")
|
||||
|
||||
if bug.status != LogStatus.PENDING_FIX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bug 状态不是待修复,当前状态: {bug.status}"
|
||||
)
|
||||
|
||||
if not bug.pr_url:
|
||||
raise HTTPException(status_code=400, detail="Bug 没有关联的 PR")
|
||||
|
||||
# 调用 Gitea API 合并 PR
|
||||
gitea_client = GiteaClient()
|
||||
success, message = gitea_client.merge_pr_by_url(bug.pr_url)
|
||||
|
||||
if success:
|
||||
# 更新 Bug 状态
|
||||
bug.status = LogStatus.FIXED
|
||||
bug.merged_at = datetime.utcnow()
|
||||
session.add(bug)
|
||||
await session.commit()
|
||||
await session.refresh(bug)
|
||||
|
||||
return {
|
||||
"message": "PR 已合并",
|
||||
"pr_url": bug.pr_url,
|
||||
"bug_id": bug.id,
|
||||
"new_status": bug.status,
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"合并失败: {message}")
|
||||
|
||||
|
||||
@app.post("/api/v1/bugs/{bug_id}/close-pr", tags=["PR Operations"])
|
||||
async def close_pr(
|
||||
bug_id: int,
|
||||
request: PRRejectRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
拒绝修复并关闭 PR
|
||||
|
||||
流程:
|
||||
1. 添加拒绝原因评论到 PR
|
||||
2. 调用 Gitea API 关闭 PR
|
||||
3. 更新 Bug 状态 → PENDING_FIX
|
||||
4. 记录拒绝原因
|
||||
5. Agent 会自动检测并重新修复
|
||||
"""
|
||||
statement = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||
results = await session.exec(statement)
|
||||
bug = results.first()
|
||||
|
||||
if not bug:
|
||||
raise HTTPException(status_code=404, detail="Bug not found")
|
||||
|
||||
if bug.status != LogStatus.PENDING_FIX:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bug 状态不是待修复,当前状态: {bug.status}"
|
||||
)
|
||||
|
||||
if not bug.pr_url:
|
||||
raise HTTPException(status_code=400, detail="Bug 没有关联的 PR")
|
||||
|
||||
gitea_client = GiteaClient()
|
||||
|
||||
# 关闭 PR(带拒绝原因)
|
||||
success, message = gitea_client.close_pr_by_url(bug.pr_url, request.reason)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"关闭 PR 失败: {message}")
|
||||
|
||||
# 更新 Bug 状态
|
||||
bug.status = LogStatus.PENDING_FIX
|
||||
bug.rejection_count = (bug.rejection_count or 0) + 1
|
||||
bug.last_rejected_at = datetime.utcnow()
|
||||
|
||||
# 记录拒绝原因
|
||||
rejection_info = {
|
||||
"rejected_at": datetime.utcnow().isoformat(),
|
||||
"reason": request.reason,
|
||||
"previous_pr": {
|
||||
"pr_number": bug.pr_number,
|
||||
"pr_url": bug.pr_url,
|
||||
"branch": bug.branch_name,
|
||||
},
|
||||
}
|
||||
bug.rejection_reason = json.dumps(rejection_info, ensure_ascii=False)
|
||||
|
||||
session.add(bug)
|
||||
await session.commit()
|
||||
await session.refresh(bug)
|
||||
|
||||
return {
|
||||
"message": "PR 已拒绝,Bug 将重新修复",
|
||||
"rejection_count": bug.rejection_count,
|
||||
"bug_id": bug.id,
|
||||
"new_status": bug.status,
|
||||
}
|
||||
|
||||
|
||||
@ -71,6 +71,17 @@ class ErrorLog(SQLModel, table=True):
|
||||
# Repair Tracking
|
||||
failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
|
||||
# PR Tracking
|
||||
pr_number: Optional[int] = Field(default=None)
|
||||
pr_url: Optional[str] = Field(default=None)
|
||||
branch_name: Optional[str] = Field(default=None)
|
||||
|
||||
# Rejection Tracking
|
||||
rejection_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
rejection_count: int = Field(default=0)
|
||||
last_rejected_at: Optional[datetime] = None
|
||||
merged_at: Optional[datetime] = None
|
||||
|
||||
# Pydantic Models for API
|
||||
class ErrorLogCreate(SQLModel):
|
||||
project_id: str
|
||||
|
||||
360
docs/PR_OPERATIONS_IMPLEMENTATION.md
Normal file
360
docs/PR_OPERATIONS_IMPLEMENTATION.md
Normal file
@ -0,0 +1,360 @@
|
||||
# PR 操作功能实施完成
|
||||
|
||||
> 实施日期: 2026-02-25
|
||||
> 状态: ✅ 已完成
|
||||
|
||||
---
|
||||
|
||||
## 📋 已实现功能
|
||||
|
||||
### ✅ 1. 数据库字段扩展
|
||||
|
||||
**文件**: `app/models.py`
|
||||
|
||||
新增字段:
|
||||
```python
|
||||
# PR Tracking
|
||||
pr_number: Optional[int] # PR 编号
|
||||
pr_url: Optional[str] # PR 链接
|
||||
branch_name: Optional[str] # 分支名称
|
||||
|
||||
# Rejection Tracking
|
||||
rejection_reason: Optional[str] # 拒绝原因 JSON
|
||||
rejection_count: int # 拒绝次数
|
||||
last_rejected_at: Optional[datetime] # 最后拒绝时间
|
||||
merged_at: Optional[datetime] # 合并时间
|
||||
```
|
||||
|
||||
### ✅ 2. Gitea API 客户端
|
||||
|
||||
**文件**: `app/gitea_client.py`
|
||||
|
||||
功能:
|
||||
- `merge_pr()` - 合并 PR
|
||||
- `close_pr()` - 关闭 PR(带原因评论)
|
||||
- `add_pr_comment()` - 添加评论
|
||||
- `merge_pr_by_url()` - 通过 URL 直接合并
|
||||
- `close_pr_by_url()` - 通过 URL 直接关闭
|
||||
|
||||
### ✅ 3. 后端 API 接口
|
||||
|
||||
**文件**: `app/main.py`
|
||||
|
||||
新增接口:
|
||||
|
||||
#### POST `/api/v1/bugs/{bug_id}/merge-pr`
|
||||
|
||||
批准并合并 PR
|
||||
|
||||
**请求**:无需 body
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"message": "PR 已合并",
|
||||
"pr_url": "https://gitea.xxx/owner/repo/pulls/45",
|
||||
"bug_id": 123,
|
||||
"new_status": "FIXED"
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/v1/bugs/{bug_id}/close-pr`
|
||||
|
||||
拒绝修复并关闭 PR
|
||||
|
||||
**请求 Body**:
|
||||
```json
|
||||
{
|
||||
"reason": "测试覆盖不足,缺少边界条件测试"
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"message": "PR 已拒绝,Bug 将重新修复",
|
||||
"rejection_count": 1,
|
||||
"bug_id": 123,
|
||||
"new_status": "PENDING_FIX"
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ 4. 前端界面
|
||||
|
||||
**文件**: `web/src/pages/BugDetail.tsx`
|
||||
|
||||
**新增组件**:
|
||||
|
||||
1. **PR 信息展示区**
|
||||
- 显示 PR 编号、分支名
|
||||
- "查看 PR" 外链按钮
|
||||
- 拒绝次数提示
|
||||
|
||||
2. **操作按钮**
|
||||
- ✅ **批准并合并** 按钮(绿色)
|
||||
- ❌ **拒绝修复** 按钮(红色)
|
||||
|
||||
3. **拒绝原因模态框**
|
||||
- 常用模板快速填充
|
||||
- 文本输入框(详细原因)
|
||||
- 确认/取消按钮
|
||||
|
||||
4. **状态消息**
|
||||
- 操作成功/失败提示
|
||||
- 上次拒绝原因显示
|
||||
|
||||
---
|
||||
|
||||
## 🎯 使用流程
|
||||
|
||||
### 场景 1:批准并合并
|
||||
|
||||
```
|
||||
1. 打开 Bug 详情页
|
||||
2. 查看 PR 信息(可点击"查看 PR"跳转到 Gitea)
|
||||
3. 确认修复正确
|
||||
4. 点击 "批准并合并" 按钮
|
||||
↓
|
||||
✅ PR 合并成功,Bug 状态 → FIXED
|
||||
```
|
||||
|
||||
### 场景 2:拒绝修复
|
||||
|
||||
```
|
||||
1. 打开 Bug 详情页
|
||||
2. 查看 PR 信息
|
||||
3. 发现问题,点击 "拒绝修复" 按钮
|
||||
4. 弹出模态框:
|
||||
- 可点击模板快速填充常用原因
|
||||
- 或手动输入详细原因
|
||||
5. 点击 "确认拒绝"
|
||||
↓
|
||||
✅ PR 已关闭,Bug 状态 → PENDING_FIX
|
||||
✅ 原因已记录,Agent 将结合原因重新修复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置步骤
|
||||
|
||||
### 1. 配置 Gitea 凭证
|
||||
|
||||
创建 `/log_center/app/.env` 文件:
|
||||
|
||||
```bash
|
||||
# Gitea 配置
|
||||
GITEA_URL=https://gitea.airlabs.art
|
||||
GITEA_TOKEN=your_gitea_token_here
|
||||
```
|
||||
|
||||
**获取 Gitea Token**:
|
||||
1. 登录 Gitea
|
||||
2. 设置 → 应用 → 生成新令牌
|
||||
3. 权限:`repo`(读写仓库)
|
||||
4. 复制 Token 到 `.env`
|
||||
|
||||
### 2. 数据库迁移
|
||||
|
||||
需要执行数据库迁移添加新字段:
|
||||
|
||||
```sql
|
||||
-- 手动执行或使用 Alembic
|
||||
ALTER TABLE errorlog ADD COLUMN pr_number INT;
|
||||
ALTER TABLE errorlog ADD COLUMN pr_url VARCHAR(500);
|
||||
ALTER TABLE errorlog ADD COLUMN branch_name VARCHAR(200);
|
||||
ALTER TABLE errorlog ADD COLUMN rejection_reason TEXT;
|
||||
ALTER TABLE errorlog ADD COLUMN rejection_count INT DEFAULT 0;
|
||||
ALTER TABLE errorlog ADD COLUMN last_rejected_at TIMESTAMP;
|
||||
ALTER TABLE errorlog ADD COLUMN merged_at TIMESTAMP;
|
||||
```
|
||||
|
||||
### 3. 重启服务
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd /log_center/app
|
||||
uvicorn main:app --reload
|
||||
|
||||
# 前端
|
||||
cd /log_center/web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
### 测试 Gitea API(可选)
|
||||
|
||||
使用测试脚本验证 API:
|
||||
|
||||
```bash
|
||||
cd /log_center/repair_agent
|
||||
python test_gitea_api.py \
|
||||
--gitea-url https://gitea.airlabs.art \
|
||||
--token YOUR_TOKEN \
|
||||
--owner owner \
|
||||
--repo repo \
|
||||
--pr-number 45
|
||||
```
|
||||
|
||||
### 手动测试流程
|
||||
|
||||
1. **准备测试数据**:
|
||||
- 确保有一个状态为 `PENDING_FIX` 的 Bug
|
||||
- 确保该 Bug 有 `pr_url` 字段(Repair Agent 创建的)
|
||||
|
||||
2. **测试合并**:
|
||||
- 打开 Bug 详情页
|
||||
- 点击"批准并合并"
|
||||
- 检查:
|
||||
- ✅ Bug 状态变为 `FIXED`
|
||||
- ✅ Gitea PR 状态为 `Merged`
|
||||
- ✅ `merged_at` 字段有值
|
||||
|
||||
3. **测试拒绝**:
|
||||
- 打开另一个有 PR 的 Bug
|
||||
- 点击"拒绝修复"
|
||||
- 输入原因:"测试覆盖不足"
|
||||
- 确认
|
||||
- 检查:
|
||||
- ✅ Bug 状态变为 `PENDING_FIX`
|
||||
- ✅ Gitea PR 状态为 `Closed`
|
||||
- ✅ PR 有评论记录原因
|
||||
- ✅ `rejection_count` +1
|
||||
- ✅ `rejection_reason` 有值
|
||||
|
||||
---
|
||||
|
||||
## 📸 界面预览
|
||||
|
||||
### 有 PR 的 Bug 详情页
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ TypeError: 'NoneType' object is not iterable │
|
||||
│ 项目: rtc_backend 来源: 运行时 级别: ERROR │
|
||||
│ 状态: [待修复] │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ Pull Request │
|
||||
│ PR #45 | fix/auto-20260225-1430 │
|
||||
│ [查看 PR →] 已拒绝 0 次 │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ 文件位置: app/views.py : 第 24 行 │
|
||||
│ ... │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ [✅ 批准并合并] [❌ 拒绝修复] │ ← 关键按钮
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 拒绝原因模态框
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 拒绝修复 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 请说明拒绝原因,Agent 将根据反馈重新修复 │
|
||||
│ │
|
||||
│ 常用模板: │
|
||||
│ [测试覆盖不足] [业务逻辑调整] [代码质量] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ [文本输入框] │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [取消] [❌ 确认拒绝] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 技术细节
|
||||
|
||||
### 状态流转
|
||||
|
||||
```
|
||||
【合并】
|
||||
PENDING_FIX → 点击"批准并合并" → 调用 Gitea API merge
|
||||
→ 更新 Bug 状态 FIXED → 设置 merged_at
|
||||
|
||||
【拒绝】
|
||||
PENDING_FIX → 点击"拒绝修复" → 添加评论到 PR
|
||||
→ 调用 Gitea API close → 更新 Bug 状态 PENDING_FIX
|
||||
→ rejection_count +1 → 记录 rejection_reason
|
||||
→ Agent 检测到 PENDING_FIX + 有 rejection_reason
|
||||
→ 结合原因重新修复
|
||||
```
|
||||
|
||||
### 安全性
|
||||
|
||||
1. **权限控制**:需要有效的 Gitea Token
|
||||
2. **状态检查**:只有 `PENDING_FIX` 状态的 Bug 才能操作
|
||||
3. **PR 验证**:必须有 `pr_url` 才能操作
|
||||
4. **错误处理**:API 失败会显示详细错误信息
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
### Q1: 按钮显示条件?
|
||||
|
||||
**A:** 只有同时满足以下条件才显示:
|
||||
1. Bug 有 `pr_url`(Agent 创建了 PR)
|
||||
2. Bug 状态为 `PENDING_FIX`
|
||||
|
||||
### Q2: 拒绝后 Agent 会立即修复吗?
|
||||
|
||||
**A:** 取决于 Agent 扫描频率:
|
||||
- 如果 Agent 定时扫描(如每小时),需等待下次扫描
|
||||
- 可以手动触发 Agent 立即扫描特定项目
|
||||
|
||||
### Q3: 合并失败怎么办?
|
||||
|
||||
**A:** 常见失败原因:
|
||||
1. Token 权限不足 → 检查 Token 权限
|
||||
2. PR 有冲突 → 需要手动在 Gitea 解决冲突
|
||||
3. PR 已关闭 → 检查 PR 状态
|
||||
|
||||
### Q4: 能否批量操作?
|
||||
|
||||
**A:** 当前版本不支持批量操作,计划在下个版本添加。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 后续优化
|
||||
|
||||
### Phase 2(可选)
|
||||
|
||||
1. **批量操作**:
|
||||
- 批量合并多个 PR
|
||||
- 批量拒绝
|
||||
|
||||
2. **通知系统**:
|
||||
- 邮件通知审核结果
|
||||
- 钉钉/企业微信通知
|
||||
|
||||
3. **审批流程**:
|
||||
- 指定审批人
|
||||
- 需要多人审批
|
||||
- 审批历史记录
|
||||
|
||||
4. **统计面板**:
|
||||
- PR 合并率
|
||||
- 平均拒绝次数
|
||||
- 审核耗时
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完成清单
|
||||
|
||||
- [x] 数据库字段扩展
|
||||
- [x] Gitea API 客户端
|
||||
- [x] 后端 API 接口
|
||||
- [x] 前端界面实现
|
||||
- [x] 拒绝原因模态框
|
||||
- [x] 环境变量配置
|
||||
- [x] 文档编写
|
||||
|
||||
**状态**: ✅ 功能已完整实现,可以部署使用!
|
||||
1020
docs/bug-repair-workflow-optimization.md
Normal file
1020
docs/bug-repair-workflow-optimization.md
Normal file
File diff suppressed because it is too large
Load Diff
671
docs/bug-severity-grading-system.md
Normal file
671
docs/bug-severity-grading-system.md
Normal file
@ -0,0 +1,671 @@
|
||||
# Bug 严重等级分级系统
|
||||
|
||||
> 文档版本: v1.0
|
||||
> 创建日期: 2026-02-25
|
||||
> 用途: AI 自动评估 Bug 严重程度,决定是否需要人工审核
|
||||
|
||||
---
|
||||
|
||||
## 📊 分级概述
|
||||
|
||||
### 等级范围
|
||||
|
||||
**1-10 级**,数字越大表示风险越高
|
||||
|
||||
| 等级 | 风险级别 | 审核方式 | 典型场景 |
|
||||
|------|----------|----------|----------|
|
||||
| **10** | 🔴 极高风险 | 必须人工审核 | 支付、认证、数据泄露 |
|
||||
| **9** | 🔴 极高风险 | 必须人工审核 | 核心业务逻辑 |
|
||||
| **8** | 🟠 高风险 | 人工审核(默认阈值) | 复杂业务流程 |
|
||||
| **7** | 🟠 高风险 | 人工审核 | 数据库操作 |
|
||||
| **6** | 🟡 中风险 | 自动合并 | 一般业务逻辑 |
|
||||
| **5** | 🟡 中风险 | 自动合并 | API 参数验证 |
|
||||
| **4** | 🟢 低风险 | 自动合并 | 非关键功能 |
|
||||
| **3** | 🟢 低风险 | 自动合并 | UI 显示问题 |
|
||||
| **2** | 🔵 极低风险 | 自动合并 | 语法错误 |
|
||||
| **1** | 🔵 极低风险 | 自动合并 | 代码格式 |
|
||||
|
||||
### 默认审核阈值
|
||||
|
||||
```python
|
||||
# 配置项
|
||||
severity_threshold_for_review: int = 8 # ≥8 级需要人工审核
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 详细分级标准
|
||||
|
||||
### Level 10:极高风险(必须人工审核)
|
||||
|
||||
#### 特征
|
||||
|
||||
**涉及资金安全:**
|
||||
- 支付、充值、提现相关代码
|
||||
- 订单金额计算逻辑
|
||||
- 优惠券、折扣计算
|
||||
- 余额、积分操作
|
||||
|
||||
**涉及数据安全:**
|
||||
- 用户认证(登录、注册、Token)
|
||||
- 权限控制(admin 权限、角色管理)
|
||||
- 敏感数据加密/解密
|
||||
- 数据库备份/恢复
|
||||
|
||||
**系统稳定性:**
|
||||
- 可能导致服务不可用
|
||||
- 可能导致系统崩溃
|
||||
- 数据库连接池管理
|
||||
|
||||
#### 示例
|
||||
|
||||
```python
|
||||
# ❌ 极高风险:支付金额计算错误
|
||||
def calculate_order_price(items):
|
||||
total = 0
|
||||
for item in items:
|
||||
total += item.price * item.quantity # Bug: 未考虑优惠券
|
||||
return total
|
||||
|
||||
# ❌ 极高风险:权限验证绕过
|
||||
def admin_required(func):
|
||||
def wrapper(request):
|
||||
# Bug: 缺少权限检查
|
||||
return func(request)
|
||||
return wrapper
|
||||
```
|
||||
|
||||
#### AI 判定输出示例
|
||||
|
||||
```json
|
||||
{
|
||||
"severity_level": 10,
|
||||
"severity_label": "极高风险",
|
||||
"risk_category": "资金安全",
|
||||
"reasoning": [
|
||||
"涉及订单金额计算逻辑",
|
||||
"错误可能导致用户支付金额错误",
|
||||
"修改了 payment_service.py 核心支付模块",
|
||||
"测试覆盖不足,缺少边界条件测试"
|
||||
],
|
||||
"recommendation": "必须人工审核并进行充分测试后才能上线",
|
||||
"business_impact": "可能导致财务损失和用户投诉",
|
||||
"rollback_difficulty": "困难"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Level 8-9:高风险(需要人工审核)
|
||||
|
||||
#### 特征
|
||||
|
||||
**核心业务逻辑:**
|
||||
- 用户绑定设备流程
|
||||
- 智能体(Spirit)创建/修改
|
||||
- 设备状态流转(in_stock → out_stock → bound)
|
||||
- 批次管理逻辑
|
||||
|
||||
**数据一致性:**
|
||||
- 数据库事务处理
|
||||
- 外键关联操作
|
||||
- 批量更新操作
|
||||
- 数据迁移脚本
|
||||
|
||||
**复杂业务流程:**
|
||||
- 多步骤业务流程
|
||||
- 涉及多个模块交互
|
||||
- 状态机流转逻辑
|
||||
|
||||
#### 示例
|
||||
|
||||
```python
|
||||
# ⚠️ 高风险:设备绑定逻辑
|
||||
def bind_device_to_user(device_id, user_id):
|
||||
device = Device.objects.get(id=device_id)
|
||||
# Bug: 未检查设备是否已被绑定
|
||||
device.owner_id = user_id
|
||||
device.status = "bound"
|
||||
device.save()
|
||||
|
||||
# ⚠️ 高风险:批量更新
|
||||
def batch_update_devices(device_ids, **kwargs):
|
||||
# Bug: 缺少事务保护
|
||||
for device_id in device_ids:
|
||||
device = Device.objects.get(id=device_id)
|
||||
for key, value in kwargs.items():
|
||||
setattr(device, key, value)
|
||||
device.save()
|
||||
```
|
||||
|
||||
#### AI 判定输出示例
|
||||
|
||||
```json
|
||||
{
|
||||
"severity_level": 8,
|
||||
"severity_label": "高风险",
|
||||
"risk_category": "核心业务逻辑",
|
||||
"reasoning": [
|
||||
"修改了设备绑定流程,属于核心业务",
|
||||
"未充分检查边界条件(设备已绑定的情况)",
|
||||
"修改涉及 Device 模型的状态流转",
|
||||
"测试用例覆盖了基本场景,但缺少异常场景测试"
|
||||
],
|
||||
"recommendation": "建议人工审核业务逻辑,并补充异常场景测试",
|
||||
"business_impact": "可能导致设备重复绑定或状态异常",
|
||||
"rollback_difficulty": "中等"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Level 5-7:中风险(自动合并,但需关注)
|
||||
|
||||
#### 特征
|
||||
|
||||
**一般业务逻辑:**
|
||||
- 非核心功能的实现
|
||||
- API 参数验证和错误处理
|
||||
- 数据查询和过滤逻辑
|
||||
- 边界条件处理
|
||||
|
||||
**API 层问题:**
|
||||
- 请求参数验证
|
||||
- 响应格式化
|
||||
- 错误码定义
|
||||
- 分页逻辑
|
||||
|
||||
#### 示例
|
||||
|
||||
```python
|
||||
# 🟡 中风险:参数验证
|
||||
def get_user_devices(request):
|
||||
user_id = request.GET.get("user_id")
|
||||
# Bug: 未验证 user_id 格式
|
||||
devices = Device.objects.filter(owner_id=user_id)
|
||||
return JsonResponse({"devices": list(devices.values())})
|
||||
|
||||
# 🟡 中风险:错误处理
|
||||
def create_spirit(name, avatar):
|
||||
# Bug: 缺少异常捕获
|
||||
spirit = Spirit.objects.create(name=name, avatar=avatar)
|
||||
return spirit
|
||||
```
|
||||
|
||||
#### AI 判定输出示例
|
||||
|
||||
```json
|
||||
{
|
||||
"severity_level": 6,
|
||||
"severity_label": "中风险",
|
||||
"risk_category": "API 参数验证",
|
||||
"reasoning": [
|
||||
"修改了 API 参数验证逻辑",
|
||||
"未影响核心业务流程",
|
||||
"测试覆盖充分,包含边界条件",
|
||||
"修改范围小,仅涉及单个 API 端点"
|
||||
],
|
||||
"recommendation": "可以自动合并,建议后续监控生产环境日志",
|
||||
"business_impact": "可能导致参数错误时返回不友好的错误信息",
|
||||
"rollback_difficulty": "容易"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Level 3-4:低风险(自动合并)
|
||||
|
||||
#### 特征
|
||||
|
||||
**UI 和展示:**
|
||||
- 前端显示问题
|
||||
- 错误提示文案
|
||||
- 日志输出格式
|
||||
|
||||
**非关键功能:**
|
||||
- 辅助工具函数
|
||||
- 测试代码修复
|
||||
- 文档更新
|
||||
|
||||
#### 示例
|
||||
|
||||
```python
|
||||
# 🟢 低风险:日志输出
|
||||
def process_data(data):
|
||||
# Bug: 日志格式错误
|
||||
logger.info(f"Processing data: {data}") # 修复:添加时间戳
|
||||
return process(data)
|
||||
|
||||
# 🟢 低风险:错误提示
|
||||
def validate_input(value):
|
||||
if not value:
|
||||
# Bug: 错误提示不友好
|
||||
raise ValueError("Invalid input") # 修复:改为 "请输入有效的值"
|
||||
```
|
||||
|
||||
#### AI 判定输出示例
|
||||
|
||||
```json
|
||||
{
|
||||
"severity_level": 3,
|
||||
"severity_label": "低风险",
|
||||
"risk_category": "日志和提示",
|
||||
"reasoning": [
|
||||
"仅修改了日志输出格式",
|
||||
"不影响业务逻辑",
|
||||
"不涉及数据操作",
|
||||
"测试通过"
|
||||
],
|
||||
"recommendation": "可以自动合并,无需人工审核",
|
||||
"business_impact": "无明显影响",
|
||||
"rollback_difficulty": "容易"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Level 1-2:极低风险(自动合并)
|
||||
|
||||
#### 特征
|
||||
|
||||
**语法和格式:**
|
||||
- Import 语句错误
|
||||
- 拼写错误
|
||||
- 代码格式问题(空格、换行)
|
||||
- 注释错误
|
||||
|
||||
**构建和依赖:**
|
||||
- requirements.txt 更新
|
||||
- Dockerfile 格式修复
|
||||
- CI/CD 配置优化
|
||||
|
||||
#### 示例
|
||||
|
||||
```python
|
||||
# 🔵 极低风险:Import 错误
|
||||
# Bug: from django.contrib.auth import User
|
||||
from django.contrib.auth.models import User # 修复
|
||||
|
||||
# 🔵 极低风险:拼写错误
|
||||
def get_devcie_list(): # Bug: devcie → device
|
||||
pass
|
||||
```
|
||||
|
||||
#### AI 判定输出示例
|
||||
|
||||
```json
|
||||
{
|
||||
"severity_level": 1,
|
||||
"severity_label": "极低风险",
|
||||
"risk_category": "语法错误",
|
||||
"reasoning": [
|
||||
"仅修复了 import 语句",
|
||||
"不涉及业务逻辑",
|
||||
"属于纯语法问题",
|
||||
"测试全部通过"
|
||||
],
|
||||
"recommendation": "自动合并,无风险",
|
||||
"business_impact": "无",
|
||||
"rollback_difficulty": "容易"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI 分级判断实现
|
||||
|
||||
### Claude 评估 Prompt 模板
|
||||
|
||||
**文件位置:** `log_center/repair_agent/prompts/severity_assessment.txt`
|
||||
|
||||
```markdown
|
||||
你是一个 Bug 严重等级评估专家。请根据以下信息评估 Bug 的严重等级(1-10 级)。
|
||||
|
||||
## Bug 信息
|
||||
|
||||
**错误类型:** {error_type}
|
||||
**错误消息:** {error_message}
|
||||
**文件路径:** {file_path}
|
||||
**堆栈信息:**
|
||||
```
|
||||
{stack_trace}
|
||||
```
|
||||
|
||||
**修复内容:**
|
||||
- 修改文件: {modified_files}
|
||||
- 代码 Diff:
|
||||
```diff
|
||||
{diff}
|
||||
```
|
||||
|
||||
**测试结果:** {test_passed}
|
||||
|
||||
## 评估标准
|
||||
|
||||
### Level 10(极高风险)
|
||||
- 涉及支付、充值、提现等资金交易
|
||||
- 涉及用户认证、权限控制的核心逻辑
|
||||
- 可能导致数据泄露或安全漏洞
|
||||
- 可能导致系统崩溃或服务不可用
|
||||
- **关键词:** payment, auth, security, admin, permission, password, token, encrypt, decrypt
|
||||
|
||||
### Level 8-9(高风险)
|
||||
- 核心业务逻辑错误(设备绑定、智能体管理)
|
||||
- 数据库事务处理错误
|
||||
- 复杂业务流程错误
|
||||
- 影响多个模块的错误
|
||||
- **关键词:** bind, device, spirit, transaction, batch, workflow, state_machine
|
||||
|
||||
### Level 5-7(中风险)
|
||||
- 一般业务逻辑错误
|
||||
- API 参数验证错误
|
||||
- 非关键功能的错误
|
||||
- **关键词:** validate, filter, query, api, endpoint
|
||||
|
||||
### Level 3-4(低风险)
|
||||
- UI 显示问题
|
||||
- 日志输出错误
|
||||
- 测试代码错误
|
||||
- **关键词:** ui, display, log, test, format
|
||||
|
||||
### Level 1-2(极低风险)
|
||||
- 语法错误(import、拼写)
|
||||
- 代码格式问题
|
||||
- 注释错误
|
||||
- **关键词:** import, syntax, format, comment, typo
|
||||
|
||||
## 判断依据
|
||||
|
||||
请综合考虑以下因素:
|
||||
|
||||
1. **业务重要性** (权重 30%)
|
||||
- 是否涉及核心业务流程?(设备管理、智能体、用户绑定)
|
||||
- 是否影响用户核心功能?
|
||||
|
||||
2. **安全风险** (权重 30%)
|
||||
- 是否涉及敏感数据?(用户信息、Token)
|
||||
- 是否涉及资金相关?(虽然当前项目可能不涉及)
|
||||
- 是否可能导致权限绕过?
|
||||
|
||||
3. **影响范围** (权重 20%)
|
||||
- 影响多少用户?(全部/部分/极少数)
|
||||
- 影响多少模块?(单模块/多模块)
|
||||
- 是否影响数据一致性?
|
||||
|
||||
4. **修复质量** (权重 10%)
|
||||
- 测试是否充分?
|
||||
- 修复方案是否合理?
|
||||
- 是否引入新的风险?
|
||||
|
||||
5. **回滚难度** (权重 10%)
|
||||
- 如果出错,回滚是否容易?
|
||||
- 是否涉及数据迁移?
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格按照以下 JSON 格式输出(在最后一行):
|
||||
|
||||
```json
|
||||
{
|
||||
"severity_level": <1-10 的整数>,
|
||||
"severity_label": "<极低风险|低风险|中风险|高风险|极高风险>",
|
||||
"risk_category": "<资金安全|数据安全|核心业务逻辑|一般业务|UI展示|语法问题|其他>",
|
||||
"reasoning": [
|
||||
"<理由1:为什么判定为该级别>",
|
||||
"<理由2:关键风险点>",
|
||||
"<理由3:测试覆盖情况>",
|
||||
"<理由4:建议>"
|
||||
],
|
||||
"recommendation": "<是否建议人工审核的具体说明>",
|
||||
"key_files": ["<核心修改文件列表>"],
|
||||
"business_impact": "<对业务的影响描述,50字以内>",
|
||||
"rollback_difficulty": "<容易|中等|困难>"
|
||||
}
|
||||
```
|
||||
|
||||
**重要提示:**
|
||||
- 如果涉及 `payment`, `auth`, `admin`, `permission` 等关键词,最低 8 级
|
||||
- 如果修改了 `models.py` 且涉及核心业务模型(Device, Spirit, User),最低 7 级
|
||||
- 如果测试失败或测试覆盖不足,等级 +1
|
||||
- 如果只是语法错误(ImportError, SyntaxError)且测试通过,最高 2 级
|
||||
|
||||
请立即开始评估,最后一行输出完整的 JSON。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 完整技术实现代码
|
||||
|
||||
### 文件清单
|
||||
|
||||
```
|
||||
log_center/repair_agent/
|
||||
├── agent/
|
||||
│ ├── claude_service.py # 新增 assess_bug_severity() 方法
|
||||
│ ├── core.py # 修改修复流程,集成分级判断
|
||||
│ └── git_manager.py # 新增 merge_pull_request() 方法
|
||||
├── models/
|
||||
│ └── bug.py # RepairReport 增加严重等级字段
|
||||
├── config/
|
||||
│ └── settings.py # 新增分级相关配置
|
||||
└── prompts/
|
||||
└── severity_assessment.txt # Claude 评估 Prompt
|
||||
```
|
||||
|
||||
### 详细代码实现
|
||||
|
||||
见主文档:[bug-repair-workflow-optimization.md](./bug-repair-workflow-optimization.md) 中的 "技术实施" 章节
|
||||
|
||||
---
|
||||
|
||||
## 📊 修复报告示例
|
||||
|
||||
### 高风险 Bug 修复报告
|
||||
|
||||
```json
|
||||
{
|
||||
"error_log_id": 123,
|
||||
"project_id": "rtc_backend",
|
||||
"status": "PENDING_REVIEW",
|
||||
"severity_level": 9,
|
||||
"severity_label": "高风险",
|
||||
"risk_category": "核心业务逻辑",
|
||||
"severity_reasoning": [
|
||||
"修改了设备绑定流程 (bind_device_to_user),属于核心业务",
|
||||
"涉及 Device 模型的状态流转 (in_stock → bound)",
|
||||
"未充分检查边界条件(设备已被其他用户绑定的情况)",
|
||||
"测试覆盖了基本场景,但缺少冲突场景测试"
|
||||
],
|
||||
"recommendation": "建议人工审核业务逻辑,补充异常场景测试后再合并",
|
||||
"business_impact": "可能导致设备重复绑定,影响用户使用",
|
||||
"rollback_difficulty": "中等",
|
||||
"modified_files": ["apps/device/views.py", "apps/device/models.py"],
|
||||
"test_passed": true,
|
||||
"pr_url": "https://gitea.xxx/rtc/rtc_backend/pulls/45"
|
||||
}
|
||||
```
|
||||
|
||||
### 低风险 Bug 修复报告
|
||||
|
||||
```json
|
||||
{
|
||||
"error_log_id": 124,
|
||||
"project_id": "rtc_backend",
|
||||
"status": "MERGED",
|
||||
"severity_level": 2,
|
||||
"severity_label": "极低风险",
|
||||
"risk_category": "语法问题",
|
||||
"severity_reasoning": [
|
||||
"仅修复了 import 语句路径错误",
|
||||
"不涉及任何业务逻辑",
|
||||
"属于纯语法问题 (ImportError)",
|
||||
"测试全部通过"
|
||||
],
|
||||
"recommendation": "可以自动合并,无需人工审核",
|
||||
"business_impact": "无",
|
||||
"rollback_difficulty": "容易",
|
||||
"modified_files": ["apps/device/serializers.py"],
|
||||
"test_passed": true,
|
||||
"pr_url": "https://gitea.xxx/rtc/rtc_backend/pulls/46",
|
||||
"auto_merged": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 完整工作流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 1. Agent 扫描发现 Bug │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 2. AI 修复代码 (Claude Code CLI) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 3. 运行测试 │
|
||||
│ ├─ 测试通过 → 继续 │
|
||||
│ └─ 测试失败 → 下一轮重试 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 4. AI 评估严重等级 (1-10) │
|
||||
│ - 分析错误类型 │
|
||||
│ - 评估业务影响 │
|
||||
│ - 检查修改范围 │
|
||||
│ - 综合判定等级 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
判定等级?
|
||||
↙ ↘
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ ≥8 级(高风险)│ │ <8 级(低风险)│
|
||||
└──────────────┘ └──────────────┘
|
||||
↓ ↓
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ 创建 PR │ │ 创建 PR │
|
||||
│ 标注高风险 │ │ 标注低风险 │
|
||||
└──────────────┘ └──────────────┘
|
||||
↓ ↓
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ 状态 → │ │ 自动合并 PR │
|
||||
│ PENDING_REVIEW│ └──────────────┘
|
||||
└──────────────┘ ↓
|
||||
↓ ┌──────────────┐
|
||||
┌──────────────┐ │ 状态 → MERGED │
|
||||
│ 等待人工审核 │ └──────────────┘
|
||||
└──────────────┘ ↓
|
||||
↓ │
|
||||
┌──────────────┐ │
|
||||
│ 人工点击 Merge│ │
|
||||
└──────────────┘ │
|
||||
↓ │
|
||||
┌──────────────┐ │
|
||||
│ 状态 → MERGED │◀────────────────┘
|
||||
└──────────────┘
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ CI/CD 部署 │
|
||||
└──────────────┘
|
||||
↓
|
||||
┌──────────────┐
|
||||
│状态 → DEPLOYED│
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 预期效果
|
||||
|
||||
### 效率提升
|
||||
|
||||
| 场景 | 当前方式 | 优化后 |
|
||||
|------|----------|--------|
|
||||
| **低风险 Bug (1-7级)** | 每个都需人工审核 | 自动合并,秒级完成 |
|
||||
| **高风险 Bug (8-10级)** | 人工审核 | 人工审核(保持不变) |
|
||||
| **整体效率** | 100% 人工介入 | 70% 自动处理 + 30% 人工审核 |
|
||||
|
||||
### 预估比例(基于典型项目)
|
||||
|
||||
```
|
||||
低风险 Bug (1-7级): 约 70% → 自动合并
|
||||
高风险 Bug (8-10级): 约 30% → 人工审核
|
||||
```
|
||||
|
||||
### 人工工作量
|
||||
|
||||
```
|
||||
假设每天 20 个 Bug:
|
||||
- 优化前:20 个 × 3 分钟/个 = 60 分钟
|
||||
- 优化后:6 个 × 3 分钟/个 = 18 分钟
|
||||
|
||||
节省时间:70%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置示例
|
||||
|
||||
```python
|
||||
# .env 或 settings.py
|
||||
|
||||
# Bug 严重等级评估
|
||||
SEVERITY_THRESHOLD_FOR_REVIEW=8 # ≥8 级需要人工审核
|
||||
ENABLE_AUTO_MERGE=true # 启用低风险 PR 自动合并
|
||||
|
||||
# 严格模式(可选)
|
||||
STRICT_MODE=false # true: ≥6 级就需审核
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 FAQ
|
||||
|
||||
### Q1: AI 评估等级不准确怎么办?
|
||||
|
||||
**A:** 有以下保障措施:
|
||||
1. **阈值可调整**:默认 ≥8 级需审核,可根据实际情况调整为 6 或 7
|
||||
2. **人工复核**:在 Web 界面可以看到 AI 的判定理由,如果不合理可以手动调整
|
||||
3. **持续优化**:收集误判案例,优化 Prompt 模板
|
||||
4. **保守策略**:当 AI 无法评估时,默认设为 6 级(中风险),偏向安全
|
||||
|
||||
### Q2: 自动合并的 PR 出错了怎么办?
|
||||
|
||||
**A:**
|
||||
1. **快速回滚**:在 Gitea 可以一键 revert commit
|
||||
2. **降级处理**:临时关闭自动合并,所有 PR 都需人工审核
|
||||
3. **监控告警**:部署后持续监控错误日志,发现问题立即回滚
|
||||
|
||||
### Q3: 能否针对不同项目设置不同的阈值?
|
||||
|
||||
**A:** 可以!配置支持项目级覆盖:
|
||||
|
||||
```python
|
||||
# 默认阈值
|
||||
severity_threshold_for_review: int = 8
|
||||
|
||||
# 项目级覆盖
|
||||
project_severity_thresholds: dict = {
|
||||
"rtc_backend": 7, # 后端更谨慎
|
||||
"rtc_web": 8, # 前端正常
|
||||
"airhub_app": 9, # 移动端更宽松
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步
|
||||
|
||||
1. ✅ **实施 Phase 1**:实现 AI 分级评估功能
|
||||
2. ✅ **测试验证**:运行 50+ Bug 修复,验证分级准确性
|
||||
3. ✅ **调优阈值**:根据实际情况调整默认阈值
|
||||
4. ✅ **上线监控**:部署后持续监控自动合并的 PR 质量
|
||||
|
||||
---
|
||||
|
||||
**维护信息:**
|
||||
- 创建:2026-02-25
|
||||
- 作者:AI + 开发团队
|
||||
- 审核:待审核
|
||||
897
docs/pr-review-and-retry-workflow.md
Normal file
897
docs/pr-review-and-retry-workflow.md
Normal file
@ -0,0 +1,897 @@
|
||||
# PR 审核与重试修复完整流程
|
||||
|
||||
> 文档版本: v1.0
|
||||
> 创建日期: 2026-02-25
|
||||
> 用途: 日志中台直接操作 PR + Close 后重新修复流程设计
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [Gitea API 支持情况](#gitea-api-支持情况)
|
||||
- [日志中台直接操作 PR](#日志中台直接操作-pr)
|
||||
- [Close PR 后重新修复流程](#close-pr-后重新修复流程)
|
||||
- [技术实现](#技术实现)
|
||||
- [测试验证](#测试验证)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Gitea API 支持情况
|
||||
|
||||
### 测试结论
|
||||
|
||||
经过 API 测试,**Gitea 完全支持在日志中台直接操作 PR**:
|
||||
|
||||
| 功能 | API 端点 | 是否支持 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| **创建 PR** | `POST /repos/{owner}/{repo}/pulls` | ✅ 支持 | 可自动创建 PR |
|
||||
| **合并 PR** | `POST /repos/{owner}/{repo}/pulls/{index}/merge` | ✅ 支持 | **可在日志中台直接 merge** |
|
||||
| **关闭 PR** | `PATCH /repos/{owner}/{repo}/pulls/{index}` | ✅ 支持 | **可在日志中台直接 close** |
|
||||
| **添加评论** | `POST /repos/{owner}/{repo}/issues/{index}/comments` | ✅ 支持 | 记录 close 原因 |
|
||||
| **获取评论** | `GET /repos/{owner}/{repo}/issues/{index}/comments` | ✅ 支持 | 读取 close 原因 |
|
||||
| **重新打开** | `PATCH /repos/{owner}/{repo}/pulls/{index}` | ✅ 支持 | 可重新激活 PR |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. ✅ **无需跳转到 Gitea**:所有操作都可以通过 API 在日志中台完成
|
||||
2. ✅ **支持添加原因**:Close PR 时可以通过评论记录详细原因
|
||||
3. ✅ **可以重新打开**:Close 后可以重新激活 PR(如果需要)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 日志中台直接操作 PR
|
||||
|
||||
### 方案 B:在日志中台内审核(推荐)
|
||||
|
||||
由于 Gitea API 完全支持,**推荐使用方案 B**,用户无需跳转到 Gitea。
|
||||
|
||||
### Web 界面设计
|
||||
|
||||
#### Bug 详情页 - PR 审核区
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Bug #123: TypeError in user_login │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 状态: 🟡 PENDING_REVIEW (待审核) │
|
||||
│ │
|
||||
│ 📋 修复报告 │
|
||||
│ ├─ AI 分析: request.user 可能为 None │
|
||||
│ ├─ 修改文件: 2 个 │
|
||||
│ ├─ 测试结果: ✅ 通过 │
|
||||
│ ├─ 严重等级: 🟠 8/10 (高风险 - 核心业务逻辑) │
|
||||
│ └─ PR: #45 │
|
||||
│ │
|
||||
│ 📊 风险评估详情 │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ **风险类别:** 核心业务逻辑 │ │
|
||||
│ │ **业务影响:** 可能导致用户登录失败 │ │
|
||||
│ │ **回滚难度:** 中等 │ │
|
||||
│ │ │ │
|
||||
│ │ **判定理由:** │ │
|
||||
│ │ • 修改了用户登录流程,属于核心业务 │ │
|
||||
│ │ • 添加了 null 检查,降低了 TypeError 风险 │ │
|
||||
│ │ • 测试覆盖充分,包含边界条件 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 🔗 Pull Request #45 │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ fix/auto-20260225-1430 → main │ │
|
||||
│ │ 修改: 2 files (+15, -1) │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 📄 代码变更(展开查看) │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ app/views.py [展开 ▼] │ │
|
||||
│ │ ───────────────────────────────────── │ │
|
||||
│ │ 23 │ def user_login(request): │ │
|
||||
│ │ -24 │ for item in request.user: │ │
|
||||
│ │ +24 │ if request.user is not None: │ │
|
||||
│ │ +25 │ for item in request.user: │ │
|
||||
│ │ 26 │ # ... rest of code │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 💬 审核意见(可选) │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ [输入框:添加审核意见或拒绝原因...] │ │
|
||||
│ │ │ │
|
||||
│ │ 常用模板: │ │
|
||||
│ │ • 测试覆盖不足 │ │
|
||||
│ │ • 业务逻辑需要调整 │ │
|
||||
│ │ • 建议添加更多边界条件测试 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⚡ 审核操作 │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ [ ✓ 批准并合并 ] [ ✗ 拒绝修复 ] │ │ ← 核心操作
|
||||
│ │ │ │
|
||||
│ │ 提示:拒绝后 Bug 将回到待修复状态, │ │
|
||||
│ │ Agent 会结合拒绝原因进行二次修复 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 用户操作流程
|
||||
|
||||
#### 场景 1:批准并合并
|
||||
|
||||
```
|
||||
1. 用户在 Bug 详情页查看修复报告
|
||||
2. 查看代码 diff(展开查看)
|
||||
3. 确认修复正确
|
||||
4. 点击 "批准并合并" 按钮
|
||||
↓
|
||||
后端操作:
|
||||
5. 调用 Gitea API 合并 PR
|
||||
6. 更新 Bug 状态: PENDING_REVIEW → MERGED
|
||||
7. 触发 CI/CD 自动部署
|
||||
```
|
||||
|
||||
#### 场景 2:拒绝修复(重点)
|
||||
|
||||
```
|
||||
1. 用户在 Bug 详情页查看修复报告
|
||||
2. 发现问题(例如:测试不足、业务逻辑有误)
|
||||
3. 在审核意见框输入拒绝原因:
|
||||
"测试覆盖不足,缺少边界条件测试。
|
||||
建议添加以下测试场景:
|
||||
1. request.user 为 None 的情况
|
||||
2. request.user 为空列表的情况"
|
||||
|
||||
4. 点击 "拒绝修复" 按钮
|
||||
↓
|
||||
后端操作:
|
||||
5. 调用 Gitea API 添加评论(记录拒绝原因)
|
||||
6. 调用 Gitea API 关闭 PR
|
||||
7. 更新 Bug 状态: PENDING_REVIEW → PENDING_FIX
|
||||
8. 记录拒绝原因到数据库
|
||||
↓
|
||||
Agent 自动检测:
|
||||
9. Agent 定时扫描发现 PENDING_FIX 状态的 Bug
|
||||
10. 读取拒绝原因
|
||||
11. 结合 Bug 本身 + 拒绝原因进行二次修复
|
||||
12. 创建新的 PR
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Close PR 后重新修复流程
|
||||
|
||||
### 完整流程图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 第一次修复尝试 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ NEW → FIXING → PENDING_REVIEW (PR #45) │
|
||||
│ ↓ │
|
||||
│ 【人工审核】 │
|
||||
│ ↓ │
|
||||
│ 发现问题,拒绝 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 拒绝处理流程 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. 添加拒绝原因评论到 PR │
|
||||
│ "测试覆盖不足,缺少边界条件测试" │
|
||||
│ │
|
||||
│ 2. Close PR #45 │
|
||||
│ │
|
||||
│ 3. 更新 Bug 状态: PENDING_REVIEW → PENDING_FIX │
|
||||
│ │
|
||||
│ 4. 记录拒绝原因到 error_logs.rejection_reason │
|
||||
│ │
|
||||
│ 5. 删除本地 fix 分支(git branch -D fix/auto-xxx) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Agent 二次修复 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. Agent 定时扫描,发现 Bug #123 状态为 PENDING_FIX │
|
||||
│ │
|
||||
│ 2. 检查是否有拒绝原因(rejection_reason 不为空) │
|
||||
│ │
|
||||
│ 3. 构造增强 Prompt: │
|
||||
│ """ │
|
||||
│ 你是 Bug 修复代理。这个 Bug 之前修复过一次,但被拒绝。 │
|
||||
│ │
|
||||
│ 【原始 Bug 信息】 │
|
||||
│ - 错误类型: TypeError │
|
||||
│ - 错误消息: 'NoneType' object is not iterable │
|
||||
│ - 文件: app/views.py:24 │
|
||||
│ │
|
||||
│ 【第一次修复被拒原因】 │
|
||||
│ 测试覆盖不足,缺少边界条件测试。 │
|
||||
│ 建议添加以下测试场景: │
|
||||
│ 1. request.user 为 None 的情况 │
|
||||
│ 2. request.user 为空列表的情况 │
|
||||
│ │
|
||||
│ 【第一次修复内容】(供参考) │
|
||||
│ - 修改了 app/views.py,添加了 null 检查 │
|
||||
│ - 但测试用例只覆盖了正常场景 │
|
||||
│ │
|
||||
│ 请针对上述拒绝原因,重新修复 Bug: │
|
||||
│ 1. 修复原始 Bug(TypeError) │
|
||||
│ 2. 补充被指出缺失的测试场景 │
|
||||
│ 3. 确保测试覆盖充分 │
|
||||
│ """ │
|
||||
│ │
|
||||
│ 4. 调用 Claude Code CLI 进行二次修复 │
|
||||
│ │
|
||||
│ 5. 运行测试验证 │
|
||||
│ │
|
||||
│ 6. AI 评估严重等级 │
|
||||
│ │
|
||||
│ 7. 创建新的 PR #46(附带改进说明) │
|
||||
│ │
|
||||
│ 8. 更新状态: PENDING_FIX → PENDING_REVIEW │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 第二次人工审核 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 查看 PR #46: │
|
||||
│ - 看到改进说明:"已根据反馈补充测试" │
|
||||
│ - 查看新增的测试用例 │
|
||||
│ - 确认修复质量 │
|
||||
│ │
|
||||
│ 审核结果: │
|
||||
│ ├─ 通过 → 合并 PR → MERGED → DEPLOYED │
|
||||
│ └─ 拒绝 → 再次回到 PENDING_FIX(可设置最大重试次数) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 关键设计点
|
||||
|
||||
#### 1. 拒绝原因记录
|
||||
|
||||
**数据库字段:**
|
||||
```sql
|
||||
ALTER TABLE error_logs ADD COLUMN rejection_reason TEXT;
|
||||
ALTER TABLE error_logs ADD COLUMN rejection_count INT DEFAULT 0;
|
||||
ALTER TABLE error_logs ADD COLUMN last_rejected_at TIMESTAMP;
|
||||
```
|
||||
|
||||
**记录内容:**
|
||||
```json
|
||||
{
|
||||
"rejected_at": "2026-02-25T15:30:00Z",
|
||||
"rejected_by": "张三",
|
||||
"reason": "测试覆盖不足,缺少边界条件测试。建议添加以下测试场景...",
|
||||
"previous_pr": {
|
||||
"pr_number": 45,
|
||||
"pr_url": "https://gitea.xxx/owner/repo/pulls/45",
|
||||
"branch": "fix/auto-20260225-1430",
|
||||
"modified_files": ["app/views.py", "tests/test_views.py"],
|
||||
"diff": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 二次修复 Prompt 增强
|
||||
|
||||
**Prompt 模板:**
|
||||
```python
|
||||
def build_retry_prompt_with_rejection(bug, rejection_info):
|
||||
"""构造包含拒绝原因的重试 Prompt"""
|
||||
prompt = f"""你是一个 Bug 修复代理。这个 Bug 之前修复过 {rejection_info['rejection_count']} 次,但被审核人员拒绝。
|
||||
|
||||
## 原始 Bug 信息
|
||||
|
||||
{bug.format_for_prompt()}
|
||||
|
||||
## 第 {rejection_info['rejection_count']} 次修复被拒原因
|
||||
|
||||
**拒绝时间:** {rejection_info['rejected_at']}
|
||||
**审核人员:** {rejection_info['rejected_by']}
|
||||
|
||||
**拒绝原因:**
|
||||
{rejection_info['reason']}
|
||||
|
||||
## 上次修复内容(供参考)
|
||||
|
||||
**修改的文件:**
|
||||
{', '.join(rejection_info['previous_pr']['modified_files'])}
|
||||
|
||||
**代码变更:**
|
||||
```diff
|
||||
{rejection_info['previous_pr']['diff'][:1000]}
|
||||
```
|
||||
|
||||
**上次修复的问题:**
|
||||
根据审核人员的反馈,上次修复存在以下问题:
|
||||
{parse_rejection_issues(rejection_info['reason'])}
|
||||
|
||||
## 本次修复要求
|
||||
|
||||
请针对审核人员指出的问题,重新修复 Bug:
|
||||
|
||||
1. **修复原始 Bug**:{bug.error.type} - {bug.error.message}
|
||||
|
||||
2. **解决被指出的问题**:
|
||||
{generate_fix_requirements(rejection_info['reason'])}
|
||||
|
||||
3. **测试要求**:
|
||||
- 必须覆盖审核人员提出的测试场景
|
||||
- 确保测试用例能够验证修复效果
|
||||
- 测试必须通过
|
||||
|
||||
4. **代码质量**:
|
||||
- 参考上次修复的方向,但避免同样的错误
|
||||
- 代码应该更加健壮和完善
|
||||
- 添加必要的注释说明改进点
|
||||
|
||||
## 修复说明要求
|
||||
|
||||
修复完成后,请在测试文件顶部添加注释说明:
|
||||
- 本次修复针对哪些审核意见进行了改进
|
||||
- 新增了哪些测试场景
|
||||
- 与上次修复的主要区别
|
||||
|
||||
请立即开始修复。
|
||||
"""
|
||||
return prompt
|
||||
```
|
||||
|
||||
#### 3. PR 标题和描述增强
|
||||
|
||||
**第二次 PR 标题:**
|
||||
```
|
||||
fix: auto repair bug #123 (重试 #2 - 已补充测试)
|
||||
```
|
||||
|
||||
**PR 描述:**
|
||||
```markdown
|
||||
## 🔄 二次修复
|
||||
|
||||
本 PR 是针对 Bug #123 的第 2 次修复尝试。
|
||||
|
||||
### 📌 第一次修复被拒原因
|
||||
|
||||
> 时间: 2026-02-25 15:30
|
||||
> 审核人: 张三
|
||||
> PR: #45 (已关闭)
|
||||
|
||||
**拒绝原因:**
|
||||
测试覆盖不足,缺少边界条件测试。建议添加以下测试场景:
|
||||
1. request.user 为 None 的情况
|
||||
2. request.user 为空列表的情况
|
||||
|
||||
### ✅ 本次改进
|
||||
|
||||
**1. 代码修复(保持不变)**
|
||||
- 添加了 `if request.user is not None` 检查(与上次相同)
|
||||
|
||||
**2. 测试增强(新增)**
|
||||
- ✅ 新增测试:`test_user_login_with_none_user`
|
||||
- ✅ 新增测试:`test_user_login_with_empty_user_list`
|
||||
- ✅ 新增测试:`test_user_login_with_anonymous_user`
|
||||
|
||||
**3. 测试覆盖率**
|
||||
- 上次:60% (仅正常场景)
|
||||
- 本次:95% (包含边界条件)
|
||||
|
||||
### 📄 修改文件
|
||||
|
||||
- `app/views.py` (+3, -1) - 添加 null 检查
|
||||
- `tests/test_views.py` (+45, -0) - 新增 3 个测试用例
|
||||
|
||||
### 🧪 测试结果
|
||||
|
||||
```
|
||||
test_user_login_normal ................ PASSED
|
||||
test_user_login_with_none_user ........ PASSED ← 新增
|
||||
test_user_login_with_empty_list ....... PASSED ← 新增
|
||||
test_user_login_with_anonymous ........ PASSED ← 新增
|
||||
----------------------------------------------
|
||||
4 tests passed
|
||||
```
|
||||
|
||||
---
|
||||
**请审核:** 已根据反馈补充测试,请重新审核。
|
||||
```
|
||||
|
||||
#### 4. 防止无限重试
|
||||
|
||||
**配置项:**
|
||||
```python
|
||||
max_rejection_retry_count: int = 3 # 最多拒绝 3 次
|
||||
```
|
||||
|
||||
**逻辑:**
|
||||
```python
|
||||
if bug.rejection_count >= settings.max_rejection_retry_count:
|
||||
logger.warning(f"Bug #{bug.id} 已被拒绝 {bug.rejection_count} 次,标记为 FAILED")
|
||||
task_manager.update_status(bug.id, BugStatus.FAILED, "多次修复被拒,需人工处理")
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 技术实现
|
||||
|
||||
### 1. 日志中台 API(新增接口)
|
||||
|
||||
**文件:`log_center/app/api/bugs.py`**
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
import httpx
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/bugs/{bug_id}/approve-pr")
|
||||
async def approve_and_merge_pr(
|
||||
bug_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批准并合并 PR
|
||||
|
||||
流程:
|
||||
1. 调用 Gitea API 合并 PR
|
||||
2. 更新 Bug 状态 → MERGED
|
||||
"""
|
||||
bug = db.query(ErrorLog).filter(ErrorLog.id == bug_id).first()
|
||||
if not bug:
|
||||
raise HTTPException(status_code=404, detail="Bug not found")
|
||||
|
||||
if bug.status != BugStatus.PENDING_REVIEW.value:
|
||||
raise HTTPException(status_code=400, detail="Bug 状态不是待审核")
|
||||
|
||||
if not bug.pr_number:
|
||||
raise HTTPException(status_code=400, detail="Bug 没有关联的 PR")
|
||||
|
||||
# 调用 Gitea API 合并 PR
|
||||
gitea_client = GiteaClient(
|
||||
gitea_url=settings.gitea_url,
|
||||
token=settings.gitea_token,
|
||||
)
|
||||
|
||||
success, message = gitea_client.merge_pr(
|
||||
owner=bug.project_info.owner,
|
||||
repo=bug.project_info.repo,
|
||||
pr_number=bug.pr_number,
|
||||
)
|
||||
|
||||
if success:
|
||||
# 更新 Bug 状态
|
||||
bug.status = BugStatus.MERGED.value
|
||||
bug.merged_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return {"message": "PR 已合并", "pr_url": bug.pr_url}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"合并失败: {message}")
|
||||
|
||||
|
||||
@router.post("/bugs/{bug_id}/reject-pr")
|
||||
async def reject_and_close_pr(
|
||||
bug_id: int,
|
||||
reason: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(get_current_user), # 获取审核人
|
||||
):
|
||||
"""
|
||||
拒绝修复并关闭 PR
|
||||
|
||||
流程:
|
||||
1. 添加拒绝原因评论到 PR
|
||||
2. 调用 Gitea API 关闭 PR
|
||||
3. 更新 Bug 状态 → PENDING_FIX
|
||||
4. 记录拒绝原因
|
||||
5. Agent 会自动检测并重新修复
|
||||
"""
|
||||
bug = db.query(ErrorLog).filter(ErrorLog.id == bug_id).first()
|
||||
if not bug:
|
||||
raise HTTPException(status_code=404, detail="Bug not found")
|
||||
|
||||
if bug.status != BugStatus.PENDING_REVIEW.value:
|
||||
raise HTTPException(status_code=400, detail="Bug 状态不是待审核")
|
||||
|
||||
if not bug.pr_number:
|
||||
raise HTTPException(status_code=400, detail="Bug 没有关联的 PR")
|
||||
|
||||
gitea_client = GiteaClient(
|
||||
gitea_url=settings.gitea_url,
|
||||
token=settings.gitea_token,
|
||||
)
|
||||
|
||||
# Step 1: 添加拒绝原因评论
|
||||
comment_body = f"""## ❌ 修复被拒绝
|
||||
|
||||
**审核人:** {current_user.username}
|
||||
**时间:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
**拒绝原因:**
|
||||
{reason}
|
||||
|
||||
---
|
||||
**操作:** 系统将自动重新修复此 Bug,并根据上述反馈改进。
|
||||
"""
|
||||
|
||||
gitea_client.add_pr_comment(
|
||||
owner=bug.project_info.owner,
|
||||
repo=bug.project_info.repo,
|
||||
pr_number=bug.pr_number,
|
||||
comment=comment_body,
|
||||
)
|
||||
|
||||
# Step 2: 关闭 PR
|
||||
success, message = gitea_client.close_pr(
|
||||
owner=bug.project_info.owner,
|
||||
repo=bug.project_info.repo,
|
||||
pr_number=bug.pr_number,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"关闭 PR 失败: {message}")
|
||||
|
||||
# Step 3: 更新 Bug 状态
|
||||
bug.status = BugStatus.PENDING_FIX.value
|
||||
bug.rejection_count = (bug.rejection_count or 0) + 1
|
||||
bug.last_rejected_at = datetime.now()
|
||||
|
||||
# Step 4: 记录拒绝原因
|
||||
rejection_info = {
|
||||
"rejected_at": datetime.now().isoformat(),
|
||||
"rejected_by": current_user.username,
|
||||
"reason": reason,
|
||||
"previous_pr": {
|
||||
"pr_number": bug.pr_number,
|
||||
"pr_url": bug.pr_url,
|
||||
"branch": bug.branch_name,
|
||||
},
|
||||
}
|
||||
bug.rejection_reason = json.dumps(rejection_info, ensure_ascii=False)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "PR 已拒绝,Bug 将重新修复",
|
||||
"rejection_count": bug.rejection_count,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Gitea Client 封装
|
||||
|
||||
**文件:`log_center/app/utils/gitea_client.py`**
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Gitea API 客户端"""
|
||||
|
||||
def __init__(self, gitea_url: str, token: str):
|
||||
self.gitea_url = gitea_url.rstrip("/")
|
||||
self.token = token
|
||||
self.base_api_url = f"{self.gitea_url}/api/v1"
|
||||
self.client = httpx.Client(timeout=30)
|
||||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def merge_pr(self, owner: str, repo: str, pr_number: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
合并 PR
|
||||
|
||||
Returns:
|
||||
(是否成功, 消息)
|
||||
"""
|
||||
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}/merge"
|
||||
payload = {
|
||||
"Do": "merge",
|
||||
"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, "合并成功"
|
||||
except httpx.HTTPStatusError as e:
|
||||
return False, f"HTTP {e.response.status_code}: {e.response.text}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def close_pr(self, owner: str, repo: str, pr_number: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
关闭 PR
|
||||
|
||||
Returns:
|
||||
(是否成功, 消息)
|
||||
"""
|
||||
url = f"{self.base_api_url}/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, "关闭成功"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def add_pr_comment(
|
||||
self, owner: str, repo: str, pr_number: int, comment: str
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
添加 PR 评论
|
||||
|
||||
Returns:
|
||||
(是否成功, 消息)
|
||||
"""
|
||||
url = f"{self.base_api_url}/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 get_pr_comments(
|
||||
self, owner: str, repo: str, pr_number: int
|
||||
) -> list[dict]:
|
||||
"""获取 PR 所有评论"""
|
||||
url = f"{self.base_api_url}/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
||||
|
||||
try:
|
||||
response = self.client.get(url, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception:
|
||||
return []
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Repair Agent 二次修复逻辑
|
||||
|
||||
**文件:`log_center/repair_agent/agent/core.py`**
|
||||
|
||||
在 `fix_project()` 中检测拒绝原因:
|
||||
|
||||
```python
|
||||
def fix_project(self, project_id: str, ...) -> BatchFixResult:
|
||||
"""修复项目的所有待修复 Bug"""
|
||||
|
||||
# 获取待修复的 Bug
|
||||
bugs = self.task_manager.fetch_pending_bugs(project_id)
|
||||
|
||||
# 分类:首次修复 vs 重试修复
|
||||
first_time_bugs = []
|
||||
retry_bugs = []
|
||||
|
||||
for bug in bugs:
|
||||
if bug.rejection_reason:
|
||||
# 有拒绝原因 → 重试修复
|
||||
retry_bugs.append(bug)
|
||||
else:
|
||||
# 无拒绝原因 → 首次修复
|
||||
first_time_bugs.append(bug)
|
||||
|
||||
# 先处理首次修复
|
||||
if first_time_bugs:
|
||||
logger.info(f"首次修复 {len(first_time_bugs)} 个 Bug")
|
||||
self._fix_bugs_batch(first_time_bugs, project_id, ...)
|
||||
|
||||
# 再处理重试修复
|
||||
if retry_bugs:
|
||||
logger.info(f"重试修复 {len(retry_bugs)} 个 Bug")
|
||||
self._retry_fix_bugs_with_rejection(retry_bugs, project_id, ...)
|
||||
|
||||
|
||||
def _retry_fix_bugs_with_rejection(
|
||||
self, bugs: list[Bug], project_id: str, ...
|
||||
):
|
||||
"""针对被拒绝的 Bug 进行二次修复"""
|
||||
|
||||
for bug in bugs:
|
||||
# 检查重试次数
|
||||
if bug.rejection_count >= settings.max_rejection_retry_count:
|
||||
logger.warning(
|
||||
f"Bug #{bug.id} 已被拒绝 {bug.rejection_count} 次,标记为 FAILED"
|
||||
)
|
||||
self.task_manager.update_status(
|
||||
bug.id, BugStatus.FAILED, "多次修复被拒,需人工处理"
|
||||
)
|
||||
continue
|
||||
|
||||
# 解析拒绝原因
|
||||
rejection_info = json.loads(bug.rejection_reason)
|
||||
|
||||
logger.info(f"Bug #{bug.id} 第 {bug.rejection_count + 1} 次修复")
|
||||
logger.info(f"上次拒绝原因: {rejection_info['reason'][:100]}...")
|
||||
|
||||
# 构造增强 Prompt
|
||||
prompt = self._build_retry_prompt_with_rejection(bug, rejection_info)
|
||||
|
||||
# 调用 Claude 修复
|
||||
success, output = self.claude_service.execute_prompt(
|
||||
prompt=prompt,
|
||||
cwd=project_path,
|
||||
)
|
||||
|
||||
# 后续流程与首次修复相同
|
||||
# ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试验证
|
||||
|
||||
### 测试脚本
|
||||
|
||||
已创建测试脚本:`log_center/repair_agent/test_gitea_api.py`
|
||||
|
||||
**运行测试:**
|
||||
```bash
|
||||
cd log_center/repair_agent
|
||||
|
||||
python test_gitea_api.py \
|
||||
--gitea-url https://gitea.airlabs.art \
|
||||
--token YOUR_TOKEN \
|
||||
--owner airlabs \
|
||||
--repo rtc_backend \
|
||||
--pr-number 45
|
||||
```
|
||||
|
||||
**测试内容:**
|
||||
1. ✅ 创建 PR
|
||||
2. ✅ 合并 PR(验证可在日志中台直接操作)
|
||||
3. ✅ 关闭 PR(验证可在日志中台直接操作)
|
||||
4. ✅ 添加拒绝原因评论
|
||||
5. ✅ 获取评论(验证可读取拒绝原因)
|
||||
6. ✅ 重新打开 PR(可选)
|
||||
|
||||
---
|
||||
|
||||
## 📊 完整数据流
|
||||
|
||||
### 数据库变更
|
||||
|
||||
```sql
|
||||
-- 新增字段
|
||||
ALTER TABLE error_logs ADD COLUMN rejection_reason TEXT COMMENT '拒绝原因JSON';
|
||||
ALTER TABLE error_logs ADD COLUMN rejection_count INT DEFAULT 0 COMMENT '拒绝次数';
|
||||
ALTER TABLE error_logs ADD COLUMN last_rejected_at TIMESTAMP COMMENT '最后拒绝时间';
|
||||
ALTER TABLE error_logs ADD COLUMN merged_at TIMESTAMP COMMENT '合并时间';
|
||||
```
|
||||
|
||||
### rejection_reason JSON 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"rejected_at": "2026-02-25T15:30:00Z",
|
||||
"rejected_by": "张三",
|
||||
"reason": "测试覆盖不足,缺少边界条件测试...",
|
||||
"previous_pr": {
|
||||
"pr_number": 45,
|
||||
"pr_url": "https://gitea.xxx/owner/repo/pulls/45",
|
||||
"branch": "fix/auto-20260225-1430",
|
||||
"modified_files": ["app/views.py"],
|
||||
"diff": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 实施计划
|
||||
|
||||
### Phase 1:日志中台 PR 操作(必须)
|
||||
|
||||
**时间:2 天**
|
||||
|
||||
- [ ] 后端
|
||||
- [ ] 创建 `GiteaClient` 工具类
|
||||
- [ ] 新增 `/bugs/{id}/approve-pr` 接口
|
||||
- [ ] 新增 `/bugs/{id}/reject-pr` 接口
|
||||
- [ ] 数据库添加拒绝相关字段
|
||||
|
||||
- [ ] 前端
|
||||
- [ ] Bug 详情页添加"批准并合并"按钮
|
||||
- [ ] Bug 详情页添加"拒绝修复"按钮
|
||||
- [ ] 添加拒绝原因输入框
|
||||
- [ ] 添加常用拒绝理由模板
|
||||
|
||||
- [ ] 测试
|
||||
- [ ] 运行 `test_gitea_api.py` 验证 API
|
||||
- [ ] 端到端测试完整流程
|
||||
|
||||
### Phase 2:二次修复流程(必须)
|
||||
|
||||
**时间:3 天**
|
||||
|
||||
- [ ] Repair Agent
|
||||
- [ ] 修改 `fix_project()` 区分首次/重试
|
||||
- [ ] 新增 `_retry_fix_bugs_with_rejection()` 方法
|
||||
- [ ] 新增 `_build_retry_prompt_with_rejection()` 方法
|
||||
- [ ] 添加重试次数限制(默认 3 次)
|
||||
|
||||
- [ ] 配置
|
||||
- [ ] 添加 `max_rejection_retry_count` 配置
|
||||
- [ ] 添加 Gitea URL 和 Token 配置
|
||||
|
||||
- [ ] 测试
|
||||
- [ ] 完整测试拒绝 → 重新修复 → 再次审核流程
|
||||
|
||||
---
|
||||
|
||||
## 🔍 FAQ
|
||||
|
||||
### Q1: 为什么要在日志中台操作 PR,而不是跳转到 Gitea?
|
||||
|
||||
**A:**
|
||||
1. **用户体验更好**:无需在两个系统间跳转
|
||||
2. **操作更直观**:直接在 Bug 详情页操作,上下文清晰
|
||||
3. **可扩展性强**:可以添加更多审核功能(如批量审核)
|
||||
4. **统一管理**:所有操作记录在日志中台
|
||||
|
||||
### Q2: 拒绝后 Agent 会立即重新修复吗?
|
||||
|
||||
**A:** 取决于配置:
|
||||
- **定时扫描模式**:等待下一次扫描(如 1 小时后)
|
||||
- **实时模式**:可以配置 Webhook,拒绝后立即触发修复
|
||||
- **手动触发**:提供"立即重新修复"按钮
|
||||
|
||||
### Q3: 如果 Agent 三次修复都被拒绝怎么办?
|
||||
|
||||
**A:**
|
||||
- Bug 状态标记为 `FAILED`
|
||||
- 需要人工介入处理
|
||||
- 可配置最大重试次数(默认 3 次)
|
||||
|
||||
### Q4: 拒绝原因会显示在新的 PR 中吗?
|
||||
|
||||
**A:** 会!新 PR 的描述会包含:
|
||||
- 上次拒绝原因
|
||||
- 本次改进说明
|
||||
- 对比改进前后的差异
|
||||
|
||||
---
|
||||
|
||||
## 📋 总结
|
||||
|
||||
### ✅ Gitea API 完全支持
|
||||
|
||||
| 操作 | 支持情况 | 说明 |
|
||||
|------|---------|------|
|
||||
| 在日志中台 merge PR | ✅ 支持 | 无需跳转到 Gitea |
|
||||
| 在日志中台 close PR | ✅ 支持 | 可添加拒绝原因 |
|
||||
| 获取拒绝原因 | ✅ 支持 | 通过评论获取 |
|
||||
| 二次修复 | ✅ 支持 | Agent 自动检测并重试 |
|
||||
|
||||
### 🔄 完整闭环
|
||||
|
||||
```
|
||||
首次修复 → PR 审核 → 拒绝 → 记录原因 → Agent 检测
|
||||
↓ ↓ ↓
|
||||
通过 close PR 结合原因二次修复
|
||||
↓ ↓
|
||||
MERGED 新 PR(改进版)
|
||||
↓
|
||||
再次审核
|
||||
```
|
||||
|
||||
### 🎯 核心价值
|
||||
|
||||
1. **提升审核效率**:在日志中台直接操作,无需跳转
|
||||
2. **智能重试**:Agent 根据拒绝原因针对性改进
|
||||
3. **可追溯**:完整记录拒绝原因和改进过程
|
||||
4. **防止死循环**:最多重试 3 次,超过则人工介入
|
||||
|
||||
---
|
||||
|
||||
**下一步:是否开始实施?**
|
||||
399
repair_agent/REPAIR_FLOW_ANALYSIS.md
Normal file
399
repair_agent/REPAIR_FLOW_ANALYSIS.md
Normal file
@ -0,0 +1,399 @@
|
||||
# Bug 自动修复流程分析与优化建议
|
||||
|
||||
> 分析日期: 2026-02-25
|
||||
> 当前版本存在的问题和改进方案
|
||||
|
||||
---
|
||||
|
||||
## 1. 当前流程概览
|
||||
|
||||
### 主流程 (fix_project)
|
||||
|
||||
```
|
||||
1. 初始化
|
||||
├─ 获取项目信息(本地路径、仓库地址)
|
||||
├─ 获取待修复 Bug 列表(NEW/PENDING_FIX)
|
||||
├─ 初始化 Git(pull + 创建 fix 分支)
|
||||
└─ 更新所有 Bug 状态 → FIXING
|
||||
|
||||
2. 多轮修复循环(max 3 轮)
|
||||
└─ 每轮:
|
||||
├─ 调用 Claude CLI 修复代码
|
||||
├─ 获取 diff 和修改文件列表
|
||||
├─ 安全检查(文件数/行数/核心文件)
|
||||
├─ 运行 Claude 生成的测试文件
|
||||
├─ 上传修复报告
|
||||
└─ 标记为 FIXED(无论测试是否通过!)
|
||||
|
||||
3. 自动提交(可选)
|
||||
├─ commit → push fix 分支
|
||||
└─ **直接合并到 main 并推送**
|
||||
```
|
||||
|
||||
### 重试失败流程 (retry_failed_project)
|
||||
|
||||
```
|
||||
1. 获取所有 FIX_FAILED Bug
|
||||
2. 逐个分诊(triage)
|
||||
├─ VERDICT:CANNOT_REPRODUCE → 标记为 CANNOT_REPRODUCE
|
||||
└─ VERDICT:FIX → 重置为 PENDING_FIX
|
||||
3. 批量调用 fix_project 修复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 主要缺陷分析
|
||||
|
||||
### 🔴 **严重问题 1:测试验证与重试逻辑脱节**
|
||||
|
||||
**问题描述:**
|
||||
- 代码中有 `max_rounds=3` 的多轮重试机制
|
||||
- 但在 [core.py:241-254](log_center/repair_agent/agent/core.py#L241-L254),无论测试通过与否都标记为 `FIXED` 并 `break`
|
||||
- **测试失败不会触发下一轮重试**
|
||||
|
||||
**问题代码:**
|
||||
```python
|
||||
# core.py Line 230-254
|
||||
test_passed = bool(test_output) and "FAILED" not in test_output
|
||||
|
||||
# 无论 test_passed 是 True 还是 False,都标记为 FIXED
|
||||
for bug in bugs:
|
||||
self.task_manager.update_status(bug.id, BugStatus.FIXED)
|
||||
# ...
|
||||
results.append(FixResult(bug_id=bug.id, success=True, ...)) # ❌
|
||||
|
||||
# 然后直接 break,不会进入第 2、3 轮
|
||||
break # Line 271
|
||||
```
|
||||
|
||||
**影响:**
|
||||
- 多轮重试机制形同虚设
|
||||
- 测试失败的 Bug 被错误标记为已修复
|
||||
- 浪费了 Claude 重试优化的能力
|
||||
|
||||
**建议修复:**
|
||||
```python
|
||||
# 第 1 步:运行测试
|
||||
test_output = run_repair_test_file(project_path, test_file)
|
||||
test_passed = self._check_test_passed(test_output)
|
||||
cleanup_repair_test_file(project_path, test_file)
|
||||
|
||||
# 第 2 步:根据测试结果决定下一步
|
||||
if test_passed:
|
||||
# 测试通过,标记为 FIXED
|
||||
for bug in bugs:
|
||||
self.task_manager.update_status(bug.id, BugStatus.FIXED)
|
||||
self._upload_round_report(...)
|
||||
break # 成功,退出循环
|
||||
else:
|
||||
# 测试失败,记录上下文供下一轮使用
|
||||
last_diff = diff
|
||||
last_test_output = test_output
|
||||
logger.warning(f"第 {round_num} 轮测试失败,尝试下一轮")
|
||||
|
||||
if round_num == max_rounds:
|
||||
# 最后一轮仍失败,标记为 FIX_FAILED
|
||||
for bug in bugs:
|
||||
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED)
|
||||
self._upload_round_report(..., status=BugStatus.FIX_FAILED)
|
||||
# 继续下一轮
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 **严重问题 2:Git 分支管理混乱**
|
||||
|
||||
**问题描述:**
|
||||
1. [git_manager.py:55-75](log_center/repair_agent/agent/git_manager.py#L55-L75) `pull()` 会切回 main/master
|
||||
2. [core.py:152-154](log_center/repair_agent/agent/core.py#L152-L154) 然后又从 main 创建新分支
|
||||
3. [core.py:264](log_center/repair_agent/agent/core.py#L264) `auto_commit` 时**直接合并到 main 并推送**
|
||||
|
||||
**问题:**
|
||||
- 没有 PR 审核流程,变更直接进入主分支
|
||||
- 每次修复都创建新分支,但合并后删除,无法回溯
|
||||
- 不符合现代 Git 工作流(应该创建 PR 供人工审核)
|
||||
|
||||
**建议修复方案:**
|
||||
|
||||
**方案 A:保留 fix 分支,推送后创建 PR**
|
||||
```python
|
||||
# core.py Line 256-267
|
||||
if git_enabled and auto_commit and modified_files and git_manager:
|
||||
bug_ids = ", ".join([f"#{b.id}" for b in bugs])
|
||||
git_manager.commit(f"fix: auto repair bugs {bug_ids}")
|
||||
git_manager.push()
|
||||
logger.info("fix 分支已推送,请手动审核并合并")
|
||||
|
||||
# 可选:调用 gh CLI 创建 PR
|
||||
# subprocess.run(["gh", "pr", "create", "--title", f"Auto fix {bug_ids}", ...])
|
||||
```
|
||||
|
||||
**方案 B:如果确实需要直接合并(慎用)**
|
||||
```python
|
||||
# 添加配置项控制
|
||||
if settings.auto_merge_to_main: # 默认 False
|
||||
if git_manager.merge_to_main_and_push():
|
||||
logger.info("已合并到 main 并推送")
|
||||
else:
|
||||
logger.info("fix 分支已推送,需人工审核")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 **问题 3:安全检查位置不当**
|
||||
|
||||
**问题代码:** [core.py:209-225](log_center/repair_agent/agent/core.py#L209-L225)
|
||||
|
||||
```python
|
||||
# Step 3: 安全检查(在修改后)
|
||||
if git_manager and not self._safety_check(modified_files, diff):
|
||||
failure_reason = "安全检查未通过"
|
||||
git_manager.reset_hard() # ❌ 丢失所有修改
|
||||
|
||||
# 标记为 FIX_FAILED,但不会重试
|
||||
for bug in bugs:
|
||||
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED)
|
||||
break
|
||||
```
|
||||
|
||||
**问题:**
|
||||
- Claude 已经修改了代码,安全检查失败后 `reset_hard` 丢失所有修改
|
||||
- 状态已经更新为 `FIXING`,造成不一致
|
||||
- 不会重试,浪费了 Claude 的工作
|
||||
|
||||
**建议:**
|
||||
|
||||
**方案 A:软性警告 + 继续测试**
|
||||
```python
|
||||
# 安全检查改为警告,记录但不阻断
|
||||
if git_manager:
|
||||
safety_passed, warnings = self._safety_check(modified_files, diff)
|
||||
if not safety_passed:
|
||||
logger.warning(f"安全检查警告: {warnings}")
|
||||
# 将警告加入报告,但继续执行测试
|
||||
|
||||
# 让测试结果决定是否成功
|
||||
```
|
||||
|
||||
**方案 B:预检查机制**
|
||||
```python
|
||||
# 在调用 Claude 前,先用只读工具做预分析
|
||||
success, analysis = self.claude_service.analyze_bug(bug, project_path)
|
||||
if "core file" in analysis.lower():
|
||||
logger.warning("分析发现可能涉及核心文件,跳过自动修复")
|
||||
# 标记为需要人工介入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 **问题 4:测试通过判断过于简单**
|
||||
|
||||
**问题代码:** [core.py:231](log_center/repair_agent/agent/core.py#L231)
|
||||
|
||||
```python
|
||||
test_passed = bool(test_output) and "FAILED" not in test_output and "Error" not in test_output.split("\n")[-5:].__repr__()
|
||||
```
|
||||
|
||||
**问题:**
|
||||
- 只检查 `"FAILED"` 字符串,不严谨
|
||||
- 没有测试文件也会被判定为通过(`test_output` 为空时返回 `False`)
|
||||
- 但代码逻辑:没有测试文件 → `test_output = "Claude 未生成测试文件"` → `bool(test_output) = True` → 可能误判
|
||||
|
||||
**建议:**
|
||||
```python
|
||||
def _check_test_passed(self, test_output: str) -> bool:
|
||||
"""检查测试是否通过"""
|
||||
if not test_output or "Claude 未生成测试文件" in test_output:
|
||||
logger.warning("没有测试输出,无法验证修复")
|
||||
return False # 没有测试视为未通过
|
||||
|
||||
# 检查常见失败标记
|
||||
fail_markers = ["FAILED", "ERROR", "AssertionError", "Exception"]
|
||||
for marker in fail_markers:
|
||||
if marker in test_output:
|
||||
return False
|
||||
|
||||
# 检查是否有通过标记(更严格)
|
||||
if "OK" in test_output or "passed" in test_output.lower():
|
||||
return True
|
||||
|
||||
# 无明确通过标记,保守判断
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 **问题 5:代码重复**
|
||||
|
||||
**问题:**
|
||||
- [core.py:95-290](log_center/repair_agent/agent/core.py#L95-L290) `fix_project`
|
||||
- [core.py:467-562](log_center/repair_agent/agent/core.py#L467-L562) `fix_single_bug`
|
||||
- 两个方法的核心逻辑完全一样,只是处理单个 vs 批量
|
||||
|
||||
**建议:**
|
||||
```python
|
||||
def fix_project(self, project_id: str, ...) -> BatchFixResult:
|
||||
bugs = self.task_manager.fetch_pending_bugs(project_id)
|
||||
return self._fix_bugs_batch(bugs, project_id, ...)
|
||||
|
||||
def fix_single_bug(self, bug_id: int, ...) -> FixResult:
|
||||
bug = self.task_manager.get_bug_detail(bug_id)
|
||||
result = self._fix_bugs_batch([bug], bug.project_id, ...)
|
||||
return result.results[0] if result.results else FixResult(...)
|
||||
|
||||
def _fix_bugs_batch(self, bugs: list[Bug], project_id: str, ...) -> BatchFixResult:
|
||||
"""通用的批量修复逻辑"""
|
||||
# 原 fix_project 的核心逻辑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟢 **问题 6:retry_failed_project 分诊逻辑可优化**
|
||||
|
||||
**当前流程:** [core.py:292-398](log_center/repair_agent/agent/core.py#L292-L398)
|
||||
|
||||
```
|
||||
FIX_FAILED Bug → 分诊 → CANNOT_REPRODUCE / 重置为 PENDING_FIX → fix_project
|
||||
```
|
||||
|
||||
**问题:**
|
||||
- 分诊失败会保留 `FIX_FAILED` 状态,下次仍会重复分诊
|
||||
- 没有分诊次数限制,可能死循环
|
||||
|
||||
**建议:**
|
||||
```python
|
||||
# 在 Bug 模型中增加 triage_count 字段
|
||||
if bug.retry_count >= settings.max_triage_count:
|
||||
logger.info(f"Bug #{bug.id} 已分诊 {bug.retry_count} 次,标记为 CANNOT_REPRODUCE")
|
||||
self.task_manager.update_status(bug.id, BugStatus.CANNOT_REPRODUCE)
|
||||
continue
|
||||
|
||||
# 分诊
|
||||
success, output = self.claude_service.triage_bug(bug, project_path)
|
||||
if not success:
|
||||
# 分诊执行失败,增加计数但保留状态
|
||||
self.task_manager.increment_retry_count(bug.id)
|
||||
continue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 优化后的理想流程
|
||||
|
||||
### 新流程设计
|
||||
|
||||
```
|
||||
1. 初始化
|
||||
├─ 获取项目信息和 Bug 列表
|
||||
├─ Git pull + 创建 fix 分支
|
||||
└─ 更新状态 → FIXING
|
||||
|
||||
2. 多轮修复循环(最多 3 轮)
|
||||
└─ 每轮:
|
||||
├─ 调用 Claude CLI 修复
|
||||
├─ 获取 diff 和修改文件
|
||||
├─ 【新增】软性安全检查(警告不阻断)
|
||||
├─ 运行测试文件
|
||||
├─ 【关键】严格判断测试是否通过
|
||||
│ ├─ 通过 → 标记 FIXED + break
|
||||
│ └─ 失败 → 记录上下文,继续下一轮
|
||||
└─ 最后一轮仍失败 → 标记 FIX_FAILED
|
||||
|
||||
3. 提交代码
|
||||
├─ commit + push fix 分支
|
||||
├─ 【推荐】创建 PR 供人工审核
|
||||
└─ 【慎用】auto_merge_to_main(需配置开关)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 实施建议
|
||||
|
||||
### 优先级排序
|
||||
|
||||
#### P0(必须修复)
|
||||
1. ✅ **修复测试验证逻辑** - 测试失败应触发重试
|
||||
2. ✅ **移除自动合并到 main** - 改为创建 PR
|
||||
|
||||
#### P1(强烈建议)
|
||||
3. ✅ **优化测试通过判断** - 更严格的检测逻辑
|
||||
4. ✅ **重构代码消除重复** - 提取 `_fix_bugs_batch`
|
||||
|
||||
#### P2(建议优化)
|
||||
5. ✅ **改进安全检查** - 改为软性警告
|
||||
6. ✅ **添加分诊次数限制** - 防止重复分诊
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置建议
|
||||
|
||||
### 新增配置项
|
||||
|
||||
```python
|
||||
# config/settings.py
|
||||
|
||||
# Git 工作流
|
||||
auto_merge_to_main: bool = False # 默认不自动合并
|
||||
create_pr_after_fix: bool = True # 自动创建 PR
|
||||
|
||||
# 测试验证
|
||||
require_test_pass: bool = True # 必须测试通过才标记 FIXED
|
||||
test_timeout: int = 300 # 测试超时时间(秒)
|
||||
|
||||
# 重试策略
|
||||
max_retry_count: int = 3 # 最大重试轮数
|
||||
max_triage_count: int = 2 # 最大分诊次数
|
||||
|
||||
# 安全检查
|
||||
safety_check_mode: str = "warn" # warn / block
|
||||
max_modified_files: int = 10
|
||||
max_modified_lines: int = 500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试建议
|
||||
|
||||
### 需要覆盖的场景
|
||||
|
||||
1. **测试失败重试**
|
||||
- Bug 修复后测试失败 → 第 2 轮修复 → 测试通过
|
||||
- 3 轮都失败 → 标记为 FIX_FAILED
|
||||
|
||||
2. **Git 分支管理**
|
||||
- 修复后创建 PR 而不是直接合并
|
||||
- 多个 Bug 修复复用同一个 fix 分支
|
||||
|
||||
3. **安全检查**
|
||||
- 修改超限文件 → 警告但继续
|
||||
- 核心文件修改 → 警告并记录
|
||||
|
||||
4. **分诊流程**
|
||||
- FIX_FAILED → 分诊 → CANNOT_REPRODUCE
|
||||
- 分诊失败达到上限 → 标记为 CANNOT_REPRODUCE
|
||||
|
||||
---
|
||||
|
||||
## 7. 总结
|
||||
|
||||
### 关键改进点
|
||||
|
||||
| 问题 | 现状 | 改进后 |
|
||||
|------|------|--------|
|
||||
| 测试验证 | 无论通过与否都标记 FIXED | 测试失败触发重试,最终失败标记 FIX_FAILED |
|
||||
| Git 工作流 | 直接合并到 main | 创建 PR 供人工审核 |
|
||||
| 安全检查 | reset_hard 丢失修改 | 软性警告,继续测试 |
|
||||
| 测试判断 | 简单字符串匹配 | 严格检测通过/失败标记 |
|
||||
| 代码质量 | 逻辑重复 | 提取公共方法 |
|
||||
| 分诊流程 | 可能重复分诊 | 添加次数限制 |
|
||||
|
||||
### 预期收益
|
||||
|
||||
- ✅ 多轮重试机制真正发挥作用,提高修复成功率
|
||||
- ✅ Git 工作流更安全,避免直接污染主分支
|
||||
- ✅ 测试验证更严格,减少假阳性
|
||||
- ✅ 代码更简洁,维护成本降低
|
||||
|
||||
---
|
||||
|
||||
**建议:优先实施 P0 和 P1 的改进,P2 可以逐步优化。**
|
||||
390
repair_agent/test_gitea_api.py
Normal file
390
repair_agent/test_gitea_api.py
Normal file
@ -0,0 +1,390 @@
|
||||
"""
|
||||
Gitea API 功能测试脚本
|
||||
|
||||
测试内容:
|
||||
1. 创建 PR
|
||||
2. Merge PR (在日志中台直接合并)
|
||||
3. Close PR (在日志中台直接拒绝)
|
||||
4. 获取 PR 评论(close 原因)
|
||||
5. 重新打开 PR (可选)
|
||||
|
||||
使用方法:
|
||||
python test_gitea_api.py --gitea-url https://gitea.xxx.com --token YOUR_TOKEN --owner owner --repo repo
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class GiteaAPITester:
|
||||
"""Gitea API 测试器"""
|
||||
|
||||
def __init__(self, gitea_url: str, token: str, owner: str, repo: str):
|
||||
self.gitea_url = gitea_url.rstrip("/")
|
||||
self.token = token
|
||||
self.owner = owner
|
||||
self.repo = repo
|
||||
self.base_api_url = f"{self.gitea_url}/api/v1"
|
||||
self.client = httpx.Client(timeout=30)
|
||||
|
||||
def _headers(self) -> dict:
|
||||
"""生成请求头"""
|
||||
return {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def test_create_pr(self, head_branch: str, base_branch: str = "main") -> Optional[int]:
|
||||
"""
|
||||
测试 1: 创建 PR
|
||||
|
||||
Returns:
|
||||
PR 编号,失败返回 None
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print("测试 1: 创建 Pull Request")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{self.base_api_url}/repos/{self.owner}/{self.repo}/pulls"
|
||||
payload = {
|
||||
"title": "[测试] Gitea API 测试 PR",
|
||||
"head": head_branch,
|
||||
"base": base_branch,
|
||||
"body": "这是一个测试 PR,用于验证 Gitea API 功能。\n\n## 测试内容\n- 创建 PR\n- Merge PR\n- Close PR",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.client.post(url, json=payload, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
pr_number = data.get("number")
|
||||
pr_url = data.get("html_url")
|
||||
|
||||
print(f"✅ PR 创建成功")
|
||||
print(f" PR 编号: #{pr_number}")
|
||||
print(f" PR URL: {pr_url}")
|
||||
print(f" 状态: {data.get('state')}")
|
||||
|
||||
return pr_number
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"❌ PR 创建失败: HTTP {e.response.status_code}")
|
||||
print(f" 错误信息: {e.response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ PR 创建失败: {e}")
|
||||
return None
|
||||
|
||||
def test_get_pr_info(self, pr_number: int):
|
||||
"""获取 PR 信息"""
|
||||
print("\n" + "=" * 60)
|
||||
print(f"获取 PR #{pr_number} 信息")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{self.base_api_url}/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
|
||||
|
||||
try:
|
||||
response = self.client.get(url, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
print(f"✅ PR 信息获取成功")
|
||||
print(f" 标题: {data.get('title')}")
|
||||
print(f" 状态: {data.get('state')}")
|
||||
print(f" 是否已合并: {data.get('merged')}")
|
||||
print(f" 创建时间: {data.get('created_at')}")
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 获取 PR 信息失败: {e}")
|
||||
return None
|
||||
|
||||
def test_merge_pr(self, pr_number: int) -> bool:
|
||||
"""
|
||||
测试 2: 合并 PR(在日志中台直接操作)
|
||||
|
||||
API: POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print(f"测试 2: 合并 PR #{pr_number}")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{self.base_api_url}/repos/{self.owner}/{self.repo}/pulls/{pr_number}/merge"
|
||||
payload = {
|
||||
"Do": "merge", # merge / squash / rebase
|
||||
"MergeMessageField": f"Merge PR #{pr_number} via API",
|
||||
"MergeTitleField": f"Merge pull request #{pr_number}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.client.post(url, json=payload, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
|
||||
print(f"✅ PR 合并成功")
|
||||
print(f" 方式: merge commit")
|
||||
print(f" 结论: ✅ Gitea 支持通过 API 直接合并 PR")
|
||||
|
||||
return True
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"❌ PR 合并失败: HTTP {e.response.status_code}")
|
||||
print(f" 错误信息: {e.response.text}")
|
||||
|
||||
# 分析失败原因
|
||||
if e.response.status_code == 405:
|
||||
print(f" 可能原因: PR 已经合并或已关闭")
|
||||
elif e.response.status_code == 409:
|
||||
print(f" 可能原因: 有合并冲突")
|
||||
elif e.response.status_code == 403:
|
||||
print(f" 可能原因: Token 权限不足")
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ PR 合并失败: {e}")
|
||||
return False
|
||||
|
||||
def test_close_pr(self, pr_number: int, reason: str = "") -> bool:
|
||||
"""
|
||||
测试 3: 关闭 PR(在日志中台直接拒绝)
|
||||
|
||||
API: PATCH /api/v1/repos/{owner}/{repo}/pulls/{index}
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print(f"测试 3: 关闭 PR #{pr_number}")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: 添加关闭原因评论
|
||||
if reason:
|
||||
comment_success = self.add_pr_comment(pr_number, reason)
|
||||
if comment_success:
|
||||
print(f"✅ 已添加关闭原因评论")
|
||||
|
||||
# Step 2: 关闭 PR
|
||||
url = f"{self.base_api_url}/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
|
||||
payload = {
|
||||
"state": "closed", # open / closed
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.client.patch(url, json=payload, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
|
||||
print(f"✅ PR 关闭成功")
|
||||
print(f" 结论: ✅ Gitea 支持通过 API 直接关闭 PR")
|
||||
|
||||
return True
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
print(f"❌ PR 关闭失败: HTTP {e.response.status_code}")
|
||||
print(f" 错误信息: {e.response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ PR 关闭失败: {e}")
|
||||
return False
|
||||
|
||||
def add_pr_comment(self, pr_number: int, comment: str) -> bool:
|
||||
"""
|
||||
添加 PR 评论(用于记录 close 原因)
|
||||
|
||||
API: POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments
|
||||
|
||||
注意: Gitea 中 PR 和 Issue 共用评论 API
|
||||
"""
|
||||
url = f"{self.base_api_url}/repos/{self.owner}/{self.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:
|
||||
print(f"⚠️ 添加评论失败: {e}")
|
||||
return False
|
||||
|
||||
def test_get_pr_comments(self, pr_number: int) -> list[dict]:
|
||||
"""
|
||||
测试 4: 获取 PR 评论(获取 close 原因)
|
||||
|
||||
API: GET /api/v1/repos/{owner}/{repo}/issues/{index}/comments
|
||||
|
||||
Returns:
|
||||
评论列表
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print(f"测试 4: 获取 PR #{pr_number} 的评论")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{self.base_api_url}/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments"
|
||||
|
||||
try:
|
||||
response = self.client.get(url, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
|
||||
comments = response.json()
|
||||
print(f"✅ 获取评论成功,共 {len(comments)} 条")
|
||||
|
||||
for i, comment in enumerate(comments, 1):
|
||||
print(f"\n 评论 #{i}:")
|
||||
print(f" - 作者: {comment.get('user', {}).get('login')}")
|
||||
print(f" - 时间: {comment.get('created_at')}")
|
||||
print(f" - 内容: {comment.get('body')[:100]}...")
|
||||
|
||||
print(f"\n 结论: ✅ 可以通过评论获取 PR 关闭原因")
|
||||
|
||||
return comments
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 获取评论失败: {e}")
|
||||
return []
|
||||
|
||||
def test_reopen_pr(self, pr_number: int) -> bool:
|
||||
"""
|
||||
测试 5: 重新打开 PR(可选)
|
||||
|
||||
API: PATCH /api/v1/repos/{owner}/{repo}/pulls/{index}
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print(f"测试 5: 重新打开 PR #{pr_number}")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{self.base_api_url}/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
|
||||
payload = {
|
||||
"state": "open",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.client.patch(url, json=payload, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
|
||||
print(f"✅ PR 重新打开成功")
|
||||
print(f" 结论: ✅ Gitea 支持重新打开已关闭的 PR")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 重新打开 PR 失败: {e}")
|
||||
return False
|
||||
|
||||
def run_all_tests(self, test_pr_number: Optional[int] = None):
|
||||
"""运行所有测试"""
|
||||
print("\n")
|
||||
print("╔" + "=" * 58 + "╗")
|
||||
print("║" + " " * 15 + "Gitea API 功能测试" + " " * 23 + "║")
|
||||
print("╚" + "=" * 58 + "╝")
|
||||
|
||||
print(f"\n配置信息:")
|
||||
print(f" Gitea URL: {self.gitea_url}")
|
||||
print(f" 仓库: {self.owner}/{self.repo}")
|
||||
print(f" Token: {'*' * 20} (已配置)")
|
||||
|
||||
if test_pr_number:
|
||||
# 使用已存在的 PR 进行测试
|
||||
pr_number = test_pr_number
|
||||
print(f"\n使用已存在的 PR #{pr_number} 进行测试")
|
||||
|
||||
# 获取 PR 信息
|
||||
self.test_get_pr_info(pr_number)
|
||||
|
||||
else:
|
||||
# 创建新 PR 进行测试(需要先在仓库中创建测试分支)
|
||||
print("\n⚠️ 提示: 需要先在仓库中创建一个测试分支(如 test-gitea-api)")
|
||||
test_branch = input("请输入测试分支名称(或直接回车跳过创建 PR): ").strip()
|
||||
|
||||
if test_branch:
|
||||
pr_number = self.test_create_pr(test_branch)
|
||||
if not pr_number:
|
||||
print("\n❌ 测试终止:无法创建 PR")
|
||||
return
|
||||
else:
|
||||
pr_number = int(input("请输入已存在的 PR 编号: "))
|
||||
|
||||
# 测试关闭 PR(带原因)
|
||||
close_reason = """## ❌ PR 被拒绝
|
||||
|
||||
**拒绝原因:**
|
||||
1. 测试覆盖不足,缺少边界条件测试
|
||||
2. 修改了核心业务逻辑,但未添加集成测试
|
||||
3. 代码中存在潜在的 null 指针风险
|
||||
|
||||
**建议:**
|
||||
- 补充测试用例
|
||||
- 重新审核业务逻辑
|
||||
- 修复后重新提交
|
||||
|
||||
---
|
||||
**操作人:** 审核人员 (通过日志中台)
|
||||
"""
|
||||
|
||||
self.test_close_pr(pr_number, close_reason)
|
||||
|
||||
# 获取评论(验证可以获取 close 原因)
|
||||
comments = self.test_get_pr_comments(pr_number)
|
||||
|
||||
# 可选:重新打开 PR
|
||||
reopen = input("\n是否测试重新打开 PR?(y/n): ").strip().lower()
|
||||
if reopen == "y":
|
||||
self.test_reopen_pr(pr_number)
|
||||
|
||||
# 可选:测试合并 PR
|
||||
if reopen == "y":
|
||||
merge = input("是否测试合并 PR?(y/n): ").strip().lower()
|
||||
if merge == "y":
|
||||
self.test_merge_pr(pr_number)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试总结")
|
||||
print("=" * 60)
|
||||
print("✅ Gitea 支持的功能:")
|
||||
print(" 1. ✅ 通过 API 创建 PR")
|
||||
print(" 2. ✅ 通过 API 合并 PR(在日志中台直接操作)")
|
||||
print(" 3. ✅ 通过 API 关闭 PR(在日志中台直接操作)")
|
||||
print(" 4. ✅ 通过 API 添加评论(记录 close 原因)")
|
||||
print(" 5. ✅ 通过 API 获取评论(读取 close 原因)")
|
||||
print(" 6. ✅ 通过 API 重新打开 PR")
|
||||
print("\n结论: Gitea 完全支持在日志中台直接 merge/close PR!")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Gitea API 功能测试")
|
||||
parser.add_argument("--gitea-url", required=True, help="Gitea 服务器地址")
|
||||
parser.add_argument("--token", required=True, help="Gitea API Token")
|
||||
parser.add_argument("--owner", required=True, help="仓库所有者")
|
||||
parser.add_argument("--repo", required=True, help="仓库名称")
|
||||
parser.add_argument("--pr-number", type=int, help="测试用的 PR 编号(可选)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
tester = GiteaAPITester(
|
||||
gitea_url=args.gitea_url,
|
||||
token=args.token,
|
||||
owner=args.owner,
|
||||
repo=args.repo,
|
||||
)
|
||||
|
||||
tester.run_all_tests(test_pr_number=args.pr_number)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 示例用法
|
||||
print("示例用法:")
|
||||
print("python test_gitea_api.py \\")
|
||||
print(" --gitea-url https://gitea.airlabs.art \\")
|
||||
print(" --token your_token_here \\")
|
||||
print(" --owner airlabs \\")
|
||||
print(" --repo rtc_backend \\")
|
||||
print(" --pr-number 45")
|
||||
print("\n")
|
||||
|
||||
main()
|
||||
@ -27,6 +27,15 @@ export interface ErrorLog {
|
||||
status: string;
|
||||
retry_count: number;
|
||||
failure_reason: string | null;
|
||||
// PR Tracking
|
||||
pr_number?: number | null;
|
||||
pr_url?: string | null;
|
||||
branch_name?: string | null;
|
||||
// Rejection Tracking
|
||||
rejection_reason?: string | null;
|
||||
rejection_count?: number;
|
||||
last_rejected_at?: string | null;
|
||||
merged_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
@ -112,4 +121,11 @@ export const getRepairReportsByBug = (errorLogId: number) =>
|
||||
params: { error_log_id: errorLogId, page_size: 100 }
|
||||
});
|
||||
|
||||
// PR Operations
|
||||
export const mergePR = (bugId: number) =>
|
||||
api.post(`/api/v1/bugs/${bugId}/merge-pr`);
|
||||
|
||||
export const closePR = (bugId: number, reason: string) =>
|
||||
api.post(`/api/v1/bugs/${bugId}/close-pr`, { reason });
|
||||
|
||||
export default api;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle } from 'lucide-react';
|
||||
import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api';
|
||||
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle, Check, X, ExternalLink } from 'lucide-react';
|
||||
import { getBugDetail, triggerRepair, getRepairReportsByBug, mergePR, closePR, type ErrorLog, type RepairReport } from '../api';
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
runtime: '运行时',
|
||||
@ -21,6 +21,13 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
FIX_FAILED: '修复失败',
|
||||
};
|
||||
|
||||
const REJECT_REASON_TEMPLATES = [
|
||||
'测试覆盖不足,缺少边界条件测试',
|
||||
'业务逻辑需要调整',
|
||||
'代码质量不符合规范',
|
||||
'需要补充异常处理',
|
||||
];
|
||||
|
||||
export default function BugDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
@ -30,6 +37,13 @@ export default function BugDetail() {
|
||||
const [repairMessage, setRepairMessage] = useState('');
|
||||
const [repairHistory, setRepairHistory] = useState<RepairReport[]>([]);
|
||||
|
||||
// PR 操作相关状态
|
||||
const [mergingPR, setMergingPR] = useState(false);
|
||||
const [closingPR, setClosingPR] = useState(false);
|
||||
const [prMessage, setPRMessage] = useState('');
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const backSearch = location.state?.fromSearch || '';
|
||||
|
||||
useEffect(() => {
|
||||
@ -71,6 +85,48 @@ export default function BugDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMergePR = async () => {
|
||||
if (!bug) return;
|
||||
setMergingPR(true);
|
||||
setPRMessage('');
|
||||
try {
|
||||
await mergePR(bug.id);
|
||||
setBug({ ...bug, status: 'FIXED', merged_at: new Date().toISOString() });
|
||||
setPRMessage('✅ PR 已成功合并');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to merge PR:', error);
|
||||
setPRMessage(`❌ 合并失败: ${error.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setMergingPR(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePR = async () => {
|
||||
if (!bug || !rejectReason.trim()) {
|
||||
setPRMessage('❌ 请输入拒绝原因');
|
||||
return;
|
||||
}
|
||||
setClosingPR(true);
|
||||
setPRMessage('');
|
||||
try {
|
||||
await closePR(bug.id, rejectReason);
|
||||
setBug({
|
||||
...bug,
|
||||
status: 'PENDING_FIX',
|
||||
rejection_count: (bug.rejection_count || 0) + 1,
|
||||
last_rejected_at: new Date().toISOString()
|
||||
});
|
||||
setPRMessage('✅ PR 已拒绝,Bug 将重新修复');
|
||||
setShowRejectModal(false);
|
||||
setRejectReason('');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to close PR:', error);
|
||||
setPRMessage(`❌ 关闭失败: ${error.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setClosingPR(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
@ -85,6 +141,8 @@ export default function BugDetail() {
|
||||
|
||||
const isRuntime = !bug.source || bug.source === 'runtime';
|
||||
const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime;
|
||||
const hasPR = !!bug.pr_url;
|
||||
const canOperatePR = hasPR && bug.status === 'PENDING_FIX';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -111,6 +169,26 @@ export default function BugDetail() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* PR 信息显示 */}
|
||||
{hasPR && (
|
||||
<div className="detail-section" style={{ background: 'var(--bg-secondary)', padding: '12px', borderRadius: '6px', marginTop: '16px' }}>
|
||||
<div className="detail-section-title" style={{ marginBottom: '8px' }}>Pull Request</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '14px' }}>
|
||||
PR #{bug.pr_number} | {bug.branch_name || 'fix branch'}
|
||||
</span>
|
||||
<a href={bug.pr_url} target="_blank" rel="noopener noreferrer" className="btn-link" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
查看 PR <ExternalLink size={12} />
|
||||
</a>
|
||||
{bug.rejection_count > 0 && (
|
||||
<span style={{ fontSize: '13px', color: 'var(--warning)' }}>
|
||||
已拒绝 {bug.rejection_count} 次
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bug.file_path && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-title">文件位置</div>
|
||||
@ -175,7 +253,39 @@ export default function BugDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="actions-bar">
|
||||
{/* 操作按钮区 */}
|
||||
<div className="actions-bar" style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* PR 操作按钮 */}
|
||||
{canOperatePR && (
|
||||
<>
|
||||
<button
|
||||
className="trigger-repair-btn"
|
||||
onClick={handleMergePR}
|
||||
disabled={mergingPR}
|
||||
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
|
||||
>
|
||||
{mergingPR ? (
|
||||
<Loader2 size={14} className="spinner" />
|
||||
) : (
|
||||
<Check size={14} />
|
||||
)}
|
||||
{mergingPR ? '合并中...' : '批准并合并'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="trigger-repair-btn"
|
||||
onClick={() => setShowRejectModal(true)}
|
||||
disabled={closingPR}
|
||||
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||
>
|
||||
<X size={14} />
|
||||
拒绝修复
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 原有的触发修复按钮 */}
|
||||
{!hasPR && (
|
||||
<button
|
||||
className="trigger-repair-btn"
|
||||
onClick={handleTriggerRepair}
|
||||
@ -188,6 +298,18 @@ export default function BugDetail() {
|
||||
)}
|
||||
{repairing ? '触发中...' : '触发修复'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 消息显示 */}
|
||||
{prMessage && (
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: prMessage.includes('✅') ? 'var(--success)' : 'var(--error)'
|
||||
}}>
|
||||
{prMessage}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{repairMessage && (
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
@ -196,7 +318,8 @@ export default function BugDetail() {
|
||||
{repairMessage}
|
||||
</span>
|
||||
)}
|
||||
{!canTriggerRepair && !repairing && (
|
||||
|
||||
{!canTriggerRepair && !repairing && !hasPR && (
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
|
||||
{!isRuntime
|
||||
? 'CI/CD 和部署错误暂不支持自动修复'
|
||||
@ -216,6 +339,16 @@ export default function BugDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bug.rejection_reason && (
|
||||
<div className="detail-card" style={{ borderLeft: '3px solid var(--warning)' }}>
|
||||
<div className="detail-section-title" style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--warning)' }}>
|
||||
<AlertTriangle size={14} />
|
||||
上次拒绝原因
|
||||
</div>
|
||||
<pre className="code-block error">{JSON.parse(bug.rejection_reason).reason}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-card">
|
||||
<div className="detail-section-title" style={{ marginBottom: '12px' }}>元数据</div>
|
||||
<table className="meta-table">
|
||||
@ -236,6 +369,12 @@ export default function BugDetail() {
|
||||
<td className="meta-label">上报时间</td>
|
||||
<td>{new Date(bug.timestamp).toLocaleString()}</td>
|
||||
</tr>
|
||||
{bug.merged_at && (
|
||||
<tr>
|
||||
<td className="meta-label">合并时间</td>
|
||||
<td>{new Date(bug.merged_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -290,6 +429,108 @@ export default function BugDetail() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拒绝原因模态框 */}
|
||||
{showRejectModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-primary)',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}>
|
||||
<h3 style={{ marginBottom: '16px' }}>拒绝修复</h3>
|
||||
<p style={{ marginBottom: '12px', fontSize: '14px', color: 'var(--text-secondary)' }}>
|
||||
请说明拒绝原因,Agent 将根据您的反馈重新修复:
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ fontSize: '13px', color: 'var(--text-tertiary)', marginBottom: '6px', display: 'block' }}>
|
||||
常用模板(点击填充):
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{REJECT_REASON_TEMPLATES.map((template, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setRejectReason(template)}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
padding: '4px 8px',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{template}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="请输入详细的拒绝原因..."
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
padding: '8px',
|
||||
fontSize: '14px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRejectModal(false);
|
||||
setRejectReason('');
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClosePR}
|
||||
disabled={closingPR || !rejectReason.trim()}
|
||||
className="trigger-repair-btn"
|
||||
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||
>
|
||||
{closingPR ? (
|
||||
<Loader2 size={14} className="spinner" />
|
||||
) : (
|
||||
<X size={14} />
|
||||
)}
|
||||
{closingPR ? '提交中...' : '确认拒绝'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
295
web/src/pages/BugDetail.tsx.backup
Normal file
295
web/src/pages/BugDetail.tsx.backup
Normal file
@ -0,0 +1,295 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle } from 'lucide-react';
|
||||
import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api';
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
runtime: '运行时',
|
||||
cicd: 'CI/CD',
|
||||
deployment: '部署',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
NEW: '新发现',
|
||||
VERIFYING: '验证中',
|
||||
CANNOT_REPRODUCE: '无法复现',
|
||||
PENDING_FIX: '待修复',
|
||||
FIXING: '修复中',
|
||||
FIXED: '已修复',
|
||||
VERIFIED: '已验证',
|
||||
DEPLOYED: '已部署',
|
||||
FIX_FAILED: '修复失败',
|
||||
};
|
||||
|
||||
export default function BugDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const [bug, setBug] = useState<ErrorLog | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [repairing, setRepairing] = useState(false);
|
||||
const [repairMessage, setRepairMessage] = useState('');
|
||||
const [repairHistory, setRepairHistory] = useState<RepairReport[]>([]);
|
||||
|
||||
const backSearch = location.state?.fromSearch || '';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBug = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await getBugDetail(parseInt(id));
|
||||
setBug(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bug:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchBug();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
getRepairReportsByBug(parseInt(id)).then(res => {
|
||||
setRepairHistory(res.data.items);
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleTriggerRepair = async () => {
|
||||
if (!bug) return;
|
||||
setRepairing(true);
|
||||
setRepairMessage('');
|
||||
try {
|
||||
await triggerRepair(bug.id);
|
||||
setBug({ ...bug, status: 'PENDING_FIX' });
|
||||
setRepairMessage('已成功触发修复');
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger repair:', error);
|
||||
setRepairMessage('触发修复失败');
|
||||
} finally {
|
||||
setRepairing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bug) {
|
||||
return <div className="loading">未找到该缺陷</div>;
|
||||
}
|
||||
|
||||
const isRuntime = !bug.source || bug.source === 'runtime';
|
||||
const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to={`/bugs${backSearch ? `?${backSearch}` : ''}`} className="back-link">
|
||||
<ArrowLeft size={14} />
|
||||
返回缺陷列表
|
||||
</Link>
|
||||
|
||||
<div className="detail-card">
|
||||
<div className="detail-header">
|
||||
<div>
|
||||
<h2 className="detail-title" style={{ color: 'var(--error)' }}>
|
||||
{bug.error_type}: {bug.error_message}
|
||||
</h2>
|
||||
<div className="detail-meta">
|
||||
<span>项目:{bug.project_id}</span>
|
||||
<span>来源:<span className={`source-badge source-${bug.source || 'runtime'}`}>{SOURCE_LABELS[bug.source] || '运行时'}</span></span>
|
||||
<span>环境:{bug.environment}</span>
|
||||
<span>级别:{bug.level}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`status-badge status-${bug.status}`}>
|
||||
{STATUS_LABELS[bug.status] || bug.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{bug.file_path && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-title">文件位置</div>
|
||||
<div className="detail-section-value">
|
||||
<FileCode size={14} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '6px' }} />
|
||||
{bug.file_path} : 第 {bug.line_number} 行
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bug.source === 'cicd' && bug.context?.workflow_name && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-title">CI/CD 信息</div>
|
||||
<div className="detail-section-value">
|
||||
工作流:{bug.context.workflow_name} / {bug.context.job_name} / {bug.context.step_name}
|
||||
{bug.context.branch && <><br />分支:{bug.context.branch}</>}
|
||||
{bug.context.run_url && (
|
||||
<><br /><a href={bug.context.run_url} target="_blank" rel="noopener noreferrer">查看 CI 日志</a></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bug.source === 'deployment' && bug.context?.pod_name && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-title">部署信息</div>
|
||||
<div className="detail-section-value">
|
||||
命名空间:{bug.context.namespace} | Pod:{bug.context.pod_name}
|
||||
<br />
|
||||
容器:{bug.context.container_name} | 重启次数:{bug.context.restart_count}
|
||||
{bug.context.node_name && <><br />节点:{bug.context.node_name}</>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bug.commit_hash && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-title">Git 信息</div>
|
||||
<div className="detail-section-value">
|
||||
<GitCommit size={14} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '6px' }} />
|
||||
{bug.commit_hash}
|
||||
{bug.version && ` | v${bug.version}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-title">堆栈跟踪</div>
|
||||
<pre className="code-block error">
|
||||
{typeof bug.stack_trace === 'string'
|
||||
? bug.stack_trace
|
||||
: JSON.stringify(bug.stack_trace, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{bug.context && Object.keys(bug.context).length > 0 && (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-title">上下文信息</div>
|
||||
<pre className="code-block accent">
|
||||
{JSON.stringify(bug.context, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="actions-bar">
|
||||
<button
|
||||
className="trigger-repair-btn"
|
||||
onClick={handleTriggerRepair}
|
||||
disabled={!canTriggerRepair || repairing}
|
||||
>
|
||||
{repairing ? (
|
||||
<Loader2 size={14} className="spinner" />
|
||||
) : (
|
||||
<Play size={14} />
|
||||
)}
|
||||
{repairing ? '触发中...' : '触发修复'}
|
||||
</button>
|
||||
{repairMessage && (
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: repairMessage.includes('成功') ? 'var(--success)' : 'var(--error)'
|
||||
}}>
|
||||
{repairMessage}
|
||||
</span>
|
||||
)}
|
||||
{!canTriggerRepair && !repairing && (
|
||||
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
|
||||
{!isRuntime
|
||||
? 'CI/CD 和部署错误暂不支持自动修复'
|
||||
: '仅"新发现"或"修复失败"状态的缺陷可触发修复'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bug.failure_reason && (
|
||||
<div className="detail-card" style={{ borderLeft: '3px solid var(--error)' }}>
|
||||
<div className="detail-section-title" style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--error)' }}>
|
||||
<AlertTriangle size={14} />
|
||||
修复失败原因
|
||||
</div>
|
||||
<pre className="code-block error">{bug.failure_reason}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-card">
|
||||
<div className="detail-section-title" style={{ marginBottom: '12px' }}>元数据</div>
|
||||
<table className="meta-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="meta-label">缺陷编号</td>
|
||||
<td>{bug.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="meta-label">指纹</td>
|
||||
<td className="cell-mono">{bug.fingerprint}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="meta-label">重试次数</td>
|
||||
<td>{bug.retry_count}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="meta-label">上报时间</td>
|
||||
<td>{new Date(bug.timestamp).toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{repairHistory.length > 0 && (
|
||||
<div className="detail-card">
|
||||
<div className="detail-section-title" style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<History size={14} />
|
||||
修复历史 ({repairHistory.length} 次尝试)
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>轮次</th>
|
||||
<th>状态</th>
|
||||
<th>测试结果</th>
|
||||
<th>失败原因</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repairHistory.map(report => (
|
||||
<tr key={report.id}>
|
||||
<td>第 {report.repair_round} 轮</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${report.status}`}>
|
||||
{STATUS_LABELS[report.status] || report.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
|
||||
{report.test_passed ? '通过' : '失败'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{report.failure_reason || '-'}
|
||||
</td>
|
||||
<td className="cell-secondary">
|
||||
{new Date(report.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<Link to={`/repairs/${report.id}`} className="btn-link">
|
||||
查看
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user