fix git pr
Some checks failed
Build and Deploy Log Center / build-and-deploy (push) Failing after 1m55s

This commit is contained in:
zyc 2026-02-25 10:55:26 +08:00
parent 33db841592
commit 5611839fd8
13 changed files with 4599 additions and 16 deletions

3
app/.env.example Normal file
View 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
View 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)

View File

@ -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,
}

View File

@ -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

View 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] 文档编写
**状态**: ✅ 功能已完整实现,可以部署使用!

File diff suppressed because it is too large Load Diff

View 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 + 开发团队
- 审核:待审核

View 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. 修复原始 BugTypeError
│ 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 次,超过则人工介入
---
**下一步:是否开始实施?**

View File

@ -0,0 +1,399 @@
# Bug 自动修复流程分析与优化建议
> 分析日期: 2026-02-25
> 当前版本存在的问题和改进方案
---
## 1. 当前流程概览
### 主流程 (fix_project)
```
1. 初始化
├─ 获取项目信息(本地路径、仓库地址)
├─ 获取待修复 Bug 列表NEW/PENDING_FIX
├─ 初始化 Gitpull + 创建 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)
# 继续下一轮
```
---
### 🟡 **严重问题 2Git 分支管理混乱**
**问题描述:**
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 的核心逻辑
```
---
### 🟢 **问题 6retry_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 可以逐步优化。**

View 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()

View File

@ -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;

View File

@ -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>
);
}

View 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>
);
}