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 sqlmodel import select, func
from .database import init_db, get_session from .database import init_db, get_session
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
from .gitea_client import GiteaClient
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, List from typing import Optional, List
from pydantic import BaseModel
import hashlib import hashlib
import json import json
@ -359,3 +361,129 @@ async def update_project(project_id: str, data: ProjectUpdate, session: AsyncSes
async def health_check(): async def health_check():
return {"status": "ok"} 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 # Repair Tracking
failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) 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 # Pydantic Models for API
class ErrorLogCreate(SQLModel): class ErrorLogCreate(SQLModel):
project_id: str 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; status: string;
retry_count: number; retry_count: number;
failure_reason: string | null; 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 { export interface Project {
@ -112,4 +121,11 @@ export const getRepairReportsByBug = (errorLogId: number) =>
params: { error_log_id: errorLogId, page_size: 100 } 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; export default api;

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link, useLocation } from 'react-router-dom'; import { useParams, Link, useLocation } from 'react-router-dom';
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle } from 'lucide-react'; import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle, Check, X, ExternalLink } from 'lucide-react';
import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api'; import { getBugDetail, triggerRepair, getRepairReportsByBug, mergePR, closePR, type ErrorLog, type RepairReport } from '../api';
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
runtime: '运行时', runtime: '运行时',
@ -21,6 +21,13 @@ const STATUS_LABELS: Record<string, string> = {
FIX_FAILED: '修复失败', FIX_FAILED: '修复失败',
}; };
const REJECT_REASON_TEMPLATES = [
'测试覆盖不足,缺少边界条件测试',
'业务逻辑需要调整',
'代码质量不符合规范',
'需要补充异常处理',
];
export default function BugDetail() { export default function BugDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const location = useLocation(); const location = useLocation();
@ -30,6 +37,13 @@ export default function BugDetail() {
const [repairMessage, setRepairMessage] = useState(''); const [repairMessage, setRepairMessage] = useState('');
const [repairHistory, setRepairHistory] = useState<RepairReport[]>([]); 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 || ''; const backSearch = location.state?.fromSearch || '';
useEffect(() => { 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) { if (loading) {
return ( return (
<div className="loading"> <div className="loading">
@ -85,6 +141,8 @@ export default function BugDetail() {
const isRuntime = !bug.source || bug.source === 'runtime'; const isRuntime = !bug.source || bug.source === 'runtime';
const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime; const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime;
const hasPR = !!bug.pr_url;
const canOperatePR = hasPR && bug.status === 'PENDING_FIX';
return ( return (
<div> <div>
@ -111,6 +169,26 @@ export default function BugDetail() {
</span> </span>
</div> </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 && ( {bug.file_path && (
<div className="detail-section"> <div className="detail-section">
<div className="detail-section-title"></div> <div className="detail-section-title"></div>
@ -175,19 +253,63 @@ export default function BugDetail() {
</div> </div>
)} )}
<div className="actions-bar"> {/* 操作按钮区 */}
<button <div className="actions-bar" style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
className="trigger-repair-btn" {/* PR 操作按钮 */}
onClick={handleTriggerRepair} {canOperatePR && (
disabled={!canTriggerRepair || repairing} <>
> <button
{repairing ? ( className="trigger-repair-btn"
<Loader2 size={14} className="spinner" /> onClick={handleMergePR}
) : ( disabled={mergingPR}
<Play size={14} /> style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
)} >
{repairing ? '触发中...' : '触发修复'} {mergingPR ? (
</button> <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}
disabled={!canTriggerRepair || repairing}
>
{repairing ? (
<Loader2 size={14} className="spinner" />
) : (
<Play size={14} />
)}
{repairing ? '触发中...' : '触发修复'}
</button>
)}
{/* 消息显示 */}
{prMessage && (
<span style={{
fontSize: '13px',
color: prMessage.includes('✅') ? 'var(--success)' : 'var(--error)'
}}>
{prMessage}
</span>
)}
{repairMessage && ( {repairMessage && (
<span style={{ <span style={{
fontSize: '13px', fontSize: '13px',
@ -196,7 +318,8 @@ export default function BugDetail() {
{repairMessage} {repairMessage}
</span> </span>
)} )}
{!canTriggerRepair && !repairing && (
{!canTriggerRepair && !repairing && !hasPR && (
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}> <span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
{!isRuntime {!isRuntime
? 'CI/CD 和部署错误暂不支持自动修复' ? 'CI/CD 和部署错误暂不支持自动修复'
@ -216,6 +339,16 @@ export default function BugDetail() {
</div> </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-card">
<div className="detail-section-title" style={{ marginBottom: '12px' }}></div> <div className="detail-section-title" style={{ marginBottom: '12px' }}></div>
<table className="meta-table"> <table className="meta-table">
@ -236,6 +369,12 @@ export default function BugDetail() {
<td className="meta-label"></td> <td className="meta-label"></td>
<td>{new Date(bug.timestamp).toLocaleString()}</td> <td>{new Date(bug.timestamp).toLocaleString()}</td>
</tr> </tr>
{bug.merged_at && (
<tr>
<td className="meta-label"></td>
<td>{new Date(bug.merged_at).toLocaleString()}</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -290,6 +429,108 @@ export default function BugDetail() {
</div> </div>
</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> </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>
);
}