fix pr
Some checks failed
Build and Deploy Log Center / build-and-deploy (push) Failing after 5m9s

This commit is contained in:
zyc 2026-02-25 16:35:28 +08:00
parent 5611839fd8
commit b178d24e73
18 changed files with 1429 additions and 126 deletions

View File

@ -34,6 +34,9 @@ async def init_db():
"CREATE INDEX IF NOT EXISTS ix_errorlog_source ON errorlog (source)", "CREATE INDEX IF NOT EXISTS ix_errorlog_source ON errorlog (source)",
# ErrorLog failure_reason # ErrorLog failure_reason
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS failure_reason TEXT", "ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS failure_reason TEXT",
# Bug severity (1-10 AI评估等级)
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity INTEGER",
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity_reason TEXT",
# Seed Project table from existing ErrorLog data # Seed Project table from existing ErrorLog data
"""INSERT INTO project (project_id, created_at, updated_at) """INSERT INTO project (project_id, created_at, updated_at)
SELECT DISTINCT e.project_id, NOW(), NOW() SELECT DISTINCT e.project_id, NOW(), NOW()

View File

@ -1,7 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, Query from fastapi import FastAPI, Depends, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel import select, func from sqlmodel import select, func, text
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 .gitea_client import GiteaClient
@ -135,17 +135,82 @@ async def update_task_status(
return {"message": "Status updated", "id": task.id, "status": task.status} return {"message": "Status updated", "id": task.id, "status": task.status}
class PRInfoUpdate(BaseModel):
pr_number: int
pr_url: str
branch_name: str
class SeverityUpdate(BaseModel):
severity: int
severity_reason: Optional[str] = None
@app.put("/api/v1/bugs/{bug_id}/pr-info", tags=["Tasks"])
async def update_bug_pr_info(
bug_id: int,
pr_info: PRInfoUpdate,
session: AsyncSession = Depends(get_session),
):
"""repair agent 创建 PR 后回写 PR 信息"""
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")
bug.pr_number = pr_info.pr_number
bug.pr_url = pr_info.pr_url
bug.branch_name = pr_info.branch_name
session.add(bug)
await session.commit()
await session.refresh(bug)
return {"message": "PR info updated", "bug_id": bug.id}
@app.put("/api/v1/bugs/{bug_id}/severity", tags=["Tasks"])
async def update_bug_severity(
bug_id: int,
data: SeverityUpdate,
session: AsyncSession = Depends(get_session),
):
"""repair agent 评估后回写 Bug 严重等级"""
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")
bug.severity = data.severity
bug.severity_reason = data.severity_reason
session.add(bug)
await session.commit()
await session.refresh(bug)
return {"message": "Severity updated", "bug_id": bug.id, "severity": bug.severity}
class PRRejectRequest(BaseModel):
"""拒绝/驳回请求"""
reason: str
# ==================== Repair Reports ==================== # ==================== Repair Reports ====================
@app.post("/api/v1/repair/reports", tags=["Repair"]) @app.post("/api/v1/repair/reports", tags=["Repair"])
async def create_repair_report(report: RepairTaskCreate, session: AsyncSession = Depends(get_session)): async def create_repair_report(report: RepairTaskCreate, session: AsyncSession = Depends(get_session)):
"""Upload a new repair report""" """Upload a new repair report (one per batch, may cover multiple bugs)"""
# 1. Create repair task record
repair_task = RepairTask.from_orm(report) repair_task = RepairTask.from_orm(report)
session.add(repair_task) session.add(repair_task)
# 2. Update error log status and failure_reason # Update all related bugs' status and failure_reason
if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED]: if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED, LogStatus.PENDING_FIX, LogStatus.FIXING]:
log_stmt = select(ErrorLog).where(ErrorLog.id == report.error_log_id) for bug_id in report.error_log_ids:
log_stmt = select(ErrorLog).where(ErrorLog.id == bug_id)
results = await session.exec(log_stmt) results = await session.exec(log_stmt)
error_log = results.first() error_log = results.first()
if error_log: if error_log:
@ -172,7 +237,10 @@ async def get_repair_reports(
if project_id: if project_id:
query = query.where(RepairTask.project_id == project_id) query = query.where(RepairTask.project_id == project_id)
if error_log_id: if error_log_id:
query = query.where(RepairTask.error_log_id == error_log_id) # PostgreSQL JSONB contains: error_log_ids @> '[28]'
query = query.where(
text(f"error_log_ids @> '{json.dumps([error_log_id])}'::jsonb")
)
offset = (page - 1) * page_size offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size) query = query.offset(offset).limit(page_size)
@ -185,7 +253,9 @@ async def get_repair_reports(
if project_id: if project_id:
count_query = count_query.where(RepairTask.project_id == project_id) count_query = count_query.where(RepairTask.project_id == project_id)
if error_log_id: if error_log_id:
count_query = count_query.where(RepairTask.error_log_id == error_log_id) count_query = count_query.where(
text(f"error_log_ids @> '{json.dumps([error_log_id])}'::jsonb")
)
count_result = await session.exec(count_query) count_result = await session.exec(count_query)
total = count_result.one() total = count_result.one()
@ -210,6 +280,113 @@ async def get_repair_report_detail(report_id: int, session: AsyncSession = Depen
return task return task
@app.post("/api/v1/repair/reports/{report_id}/approve", tags=["Repair"])
async def approve_report(report_id: int, session: AsyncSession = Depends(get_session)):
"""
批准修复报告合并 PR如有并将所有关联 Bug 标记为 FIXED
"""
statement = select(RepairTask).where(RepairTask.id == report_id)
results = await session.exec(statement)
report = results.first()
if not report:
raise HTTPException(status_code=404, detail="Report not found")
if report.status != LogStatus.PENDING_FIX:
raise HTTPException(status_code=400, detail=f"报告状态不是等待审核: {report.status}")
# 如有 PR调用 Gitea API 合并
if report.pr_url:
gitea_client = GiteaClient()
success, message = gitea_client.merge_pr_by_url(report.pr_url)
if not success:
raise HTTPException(status_code=500, detail=f"合并 PR 失败: {message}")
# 更新报告状态
report.status = LogStatus.FIXED
session.add(report)
# 更新所有关联 Bug 状态
updated_ids = []
for bug_id in (report.error_log_ids or []):
bug_stmt = select(ErrorLog).where(ErrorLog.id == bug_id)
bug_results = await session.exec(bug_stmt)
bug = bug_results.first()
if bug:
bug.status = LogStatus.FIXED
bug.merged_at = datetime.utcnow()
session.add(bug)
updated_ids.append(bug_id)
await session.commit()
return {
"message": f"修复已批准,{len(updated_ids)} 个缺陷已更新",
"report_id": report_id,
"updated_bug_ids": updated_ids,
}
@app.post("/api/v1/repair/reports/{report_id}/reject", tags=["Repair"])
async def reject_report(
report_id: int,
request: PRRejectRequest,
session: AsyncSession = Depends(get_session),
):
"""
驳回修复报告关闭 PR如有并将所有关联 Bug 重置为 NEW
"""
statement = select(RepairTask).where(RepairTask.id == report_id)
results = await session.exec(statement)
report = results.first()
if not report:
raise HTTPException(status_code=404, detail="Report not found")
if report.status != LogStatus.PENDING_FIX:
raise HTTPException(status_code=400, detail=f"报告状态不是等待审核: {report.status}")
# 如有 PR关闭
if report.pr_url:
gitea_client = GiteaClient()
success, message = gitea_client.close_pr_by_url(report.pr_url, request.reason)
if not success:
raise HTTPException(status_code=500, detail=f"关闭 PR 失败: {message}")
# 更新报告状态
report.status = LogStatus.FIX_FAILED
report.failure_reason = f"人工驳回: {request.reason}"
session.add(report)
# 重置所有关联 Bug 为 NEW
updated_ids = []
rejection_info = json.dumps({
"rejected_at": datetime.utcnow().isoformat(),
"reason": request.reason,
"report_id": report_id,
}, ensure_ascii=False)
for bug_id in (report.error_log_ids or []):
bug_stmt = select(ErrorLog).where(ErrorLog.id == bug_id)
bug_results = await session.exec(bug_stmt)
bug = bug_results.first()
if bug:
bug.status = LogStatus.NEW
bug.rejection_count = (bug.rejection_count or 0) + 1
bug.last_rejected_at = datetime.utcnow()
bug.rejection_reason = rejection_info
session.add(bug)
updated_ids.append(bug_id)
await session.commit()
return {
"message": f"修复已驳回,{len(updated_ids)} 个缺陷将重新修复",
"report_id": report_id,
"updated_bug_ids": updated_ids,
}
# ==================== Dashboard APIs ==================== # ==================== Dashboard APIs ====================
@app.get("/api/v1/dashboard/stats", tags=["Dashboard"]) @app.get("/api/v1/dashboard/stats", tags=["Dashboard"])
@ -364,10 +541,6 @@ async def health_check():
# ==================== PR 操作 ==================== # ==================== PR 操作 ====================
class PRRejectRequest(BaseModel):
"""拒绝 PR 请求"""
reason: str # 拒绝原因
@app.post("/api/v1/bugs/{bug_id}/merge-pr", tags=["PR Operations"]) @app.post("/api/v1/bugs/{bug_id}/merge-pr", tags=["PR Operations"])
async def merge_pr( async def merge_pr(
@ -487,3 +660,124 @@ async def close_pr(
"new_status": bug.status, "new_status": bug.status,
} }
@app.post("/api/v1/bugs/{bug_id}/retry", tags=["PR Operations"])
async def retry_fix(
bug_id: int,
session: AsyncSession = Depends(get_session),
):
"""
重新尝试修复失败的 Bug
FIX_FAILED 状态的 Bug 重置为 NEW repair 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.FIX_FAILED:
raise HTTPException(
status_code=400,
detail=f"只能重试修复失败的 Bug当前状态: {bug.status}"
)
# 重置状态为 NEW
bug.status = LogStatus.NEW
# 清除失败原因,让 agent 重新分析
bug.failure_reason = None
session.add(bug)
await session.commit()
await session.refresh(bug)
return {
"message": "Bug 已重置为新发现状态repair agent 将重新扫描修复",
"bug_id": bug.id,
"new_status": bug.status,
}
@app.post("/api/v1/bugs/{bug_id}/approve-fix", tags=["PR Operations"])
async def approve_fix(
bug_id: int,
session: AsyncSession = Depends(get_session),
):
"""
人工确认修复不依赖 PR
用于 PENDING_FIX 状态但没有关联 PR Bug人工审核后直接标记为 FIXED
"""
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}"
)
bug.status = LogStatus.FIXED
bug.merged_at = datetime.utcnow()
session.add(bug)
await session.commit()
await session.refresh(bug)
return {
"message": "修复已确认",
"bug_id": bug.id,
"new_status": bug.status,
}
@app.post("/api/v1/bugs/{bug_id}/reject-fix", tags=["PR Operations"])
async def reject_fix(
bug_id: int,
request: PRRejectRequest,
session: AsyncSession = Depends(get_session),
):
"""
人工驳回修复不依赖 PR
用于 PENDING_FIX 状态但没有关联 PR Bug驳回后重置为 NEW 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}"
)
bug.status = LogStatus.NEW
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,
}
bug.rejection_reason = json.dumps(rejection_info, ensure_ascii=False)
session.add(bug)
await session.commit()
await session.refresh(bug)
return {
"message": "修复已驳回Bug 将重新修复",
"rejection_count": bug.rejection_count,
"bug_id": bug.id,
"new_status": bug.status,
}

View File

@ -64,6 +64,10 @@ class ErrorLog(SQLModel, table=True):
timestamp: datetime = Field(default_factory=datetime.utcnow) timestamp: datetime = Field(default_factory=datetime.utcnow)
fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number
# Severity (1-10, AI 评估的严重等级8+ 需人工审核)
severity: Optional[int] = Field(default=None)
severity_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
# Status Tracking # Status Tracking
status: LogStatus = Field(default=LogStatus.NEW) status: LogStatus = Field(default=LogStatus.NEW)
retry_count: int = Field(default=0) retry_count: int = Field(default=0)
@ -101,16 +105,16 @@ class TaskStatusUpdate(SQLModel):
message: Optional[str] = None message: Optional[str] = None
class RepairTask(SQLModel, table=True): class RepairTask(SQLModel, table=True):
"""Record of a repair attempt""" """Record of a repair attempt (one per batch, may cover multiple bugs)"""
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
error_log_id: int = Field(foreign_key="errorlog.id") error_log_ids: List[int] = Field(sa_column=Column(JSON)) # 关联的 Bug ID 列表
status: LogStatus status: LogStatus
project_id: str project_id: str
# Repair Details # Repair Details
ai_analysis: str = Field(sa_column=Column(Text)) # Analysis from LLM ai_analysis: str = Field(sa_column=Column(Text))
fix_plan: str = Field(sa_column=Column(Text)) # Proposed fix plan fix_plan: str = Field(sa_column=Column(Text))
code_diff: str = Field(sa_column=Column(Text)) # Git diff code_diff: str = Field(sa_column=Column(Text))
modified_files: List[str] = Field(sa_column=Column(JSON)) modified_files: List[str] = Field(sa_column=Column(JSON))
# Test Results # Test Results
@ -118,14 +122,19 @@ class RepairTask(SQLModel, table=True):
test_passed: bool test_passed: bool
# Repair Round Tracking # Repair Round Tracking
repair_round: int = Field(default=1) # Which round (1, 2, 3...) repair_round: int = Field(default=1)
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 Info
pr_url: Optional[str] = Field(default=None)
pr_number: Optional[int] = Field(default=None)
branch_name: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
class RepairTaskCreate(SQLModel): class RepairTaskCreate(SQLModel):
"""Schema for creating a repair report via API""" """Schema for creating a repair report via API"""
error_log_id: int error_log_ids: List[int]
status: LogStatus status: LogStatus
project_id: str project_id: str
ai_analysis: str ai_analysis: str
@ -136,4 +145,7 @@ class RepairTaskCreate(SQLModel):
test_passed: bool test_passed: bool
repair_round: int = 1 repair_round: int = 1
failure_reason: Optional[str] = None failure_reason: Optional[str] = None
pr_url: Optional[str] = None
pr_number: Optional[int] = None
branch_name: Optional[str] = None

74
create_test_bugs.sql Normal file
View File

@ -0,0 +1,74 @@
-- 创建不同等级的测试Bug
-- 使用方法在数据库管理工具中执行此SQL或使用命令行
-- PGPASSWORD='JogNQdtrd3WY8CBCAiYfYEGx' psql -h pgm-7xv4811oj11j86htzo.pg.rds.aliyuncs.com -U log_center -d log_center < create_test_bugs.sql
-- Bug 1: 10级 - 支付安全漏洞(最高优先级)
INSERT INTO errorlog (
project_id, environment, level, source, error_type, error_message,
file_path, line_number, stack_trace, context, status, fingerprint,
timestamp, retry_count, version, commit_hash
) VALUES (
'rtc_backend', 'production', 'CRITICAL', 'runtime',
'PaymentSecurityError',
'支付金额验证绕过:可通过修改客户端金额完成支付',
'app/services/payment_service.py', 156,
'PaymentSecurityError: 支付金额未在服务端验证',
'{"vulnerability": "payment_bypass", "severity": 10}',
'NEW', 'bug_payment_sec_10',
CURRENT_TIMESTAMP, 0, '1.0.0', 'abc123'
);
-- Bug 2: 9级 - 用户数据泄露
INSERT INTO errorlog (
project_id, environment, level, source, error_type, error_message,
file_path, line_number, stack_trace, context, status, fingerprint,
timestamp, retry_count, version, commit_hash
) VALUES (
'rtc_backend', 'production', 'CRITICAL', 'runtime',
'DataLeakError',
'API返回了其他用户的敏感信息',
'app/api/user_api.py', 89,
'DataLeakError: 未授权访问其他用户数据',
'{"vulnerability": "data_leak", "severity": 9}',
'NEW', 'bug_data_leak_9',
CURRENT_TIMESTAMP, 0, '1.0.0', 'def456'
);
-- Bug 3: 5级 - 业务逻辑错误
INSERT INTO errorlog (
project_id, environment, level, source, error_type, error_message,
file_path, line_number, stack_trace, context, status, fingerprint,
timestamp, retry_count, version, commit_hash
) VALUES (
'rtc_backend', 'production', 'ERROR', 'runtime',
'BusinessLogicError',
'设备绑定时未检查设备状态',
'app/api/device_api.py', 234,
'BusinessLogicError: 设备已绑定但未检查状态',
'{"device_sn": "BRAND-P01-001", "severity": 5}',
'NEW', 'bug_business_logic_5',
CURRENT_TIMESTAMP, 0, '1.0.0', 'ghi789'
);
-- Bug 4: 3级 - 简单拼写错误
INSERT INTO errorlog (
project_id, environment, level, source, error_type, error_message,
file_path, line_number, stack_trace, context, status, fingerprint,
timestamp, retry_count, version, commit_hash
) VALUES (
'rtc_backend', 'production', 'WARNING', 'runtime',
'NameError',
'变量名拼写错误devce_id 应为 device_id',
'app/utils/validator.py', 45,
'NameError: name devce_id is not defined',
'{"typo": "devce_id", "severity": 3}',
'NEW', 'bug_typo_3',
CURRENT_TIMESTAMP, 0, '1.0.0', 'jkl012'
);
-- 查询刚创建的Bug
SELECT id, error_type, error_message, status,
context->>'severity' as severity_level
FROM errorlog
WHERE fingerprint IN ('bug_payment_sec_10', 'bug_data_leak_9', 'bug_business_logic_5', 'bug_typo_3')
ORDER BY (context->>'severity')::int DESC;

131
insert_new_bugs.py Normal file
View File

@ -0,0 +1,131 @@
"""
插入 3 个新 bug errorlog 对应 rtc_backend 中引入的 3 个代码 bug
"""
import asyncio
import json
import hashlib
from datetime import datetime, timezone
import asyncpg
DB_URL = "postgresql://log_center:JogNQdtrd3WY8CBCAiYfYEGx@pgm-7xv4811oj11j86htzo.pg.rds.aliyuncs.com:5432/log_center"
def make_fingerprint(project_id, error_type, file_path, line_number):
raw = f"{project_id}:{error_type}:{file_path}:{line_number}"
return hashlib.md5(raw.encode()).hexdigest()
BUGS = [
{
"project_id": "rtc_backend",
"environment": "production",
"level": "CRITICAL",
"source": "code_review",
"error_type": "SecurityVulnerability",
"error_message": "PaymentService.calc_refund_amount 退款比例校验缺失上限refund_ratio > 1 时可超额退款(如 200% 退款),造成严重资金损失",
"file_path": "app/services/payment_service.py",
"line_number": 139,
"stack_trace": json.dumps({
"file": "app/services/payment_service.py",
"line": 139,
"function": "calc_refund_amount",
"code": "if refund_ratio < 0:",
"detail": "原校验为 if not (0 < refund_ratio <= 1),现改为仅检查 < 0允许 refund_ratio > 1 通过"
}),
"context": json.dumps({
"expected": "if not (0 < refund_ratio <= 1): raise ValueError(...)",
"actual": "if refund_ratio < 0: raise ValueError(...)",
"impact": "攻击者可传入 refund_ratio=2.0 实现 200% 退款,直接导致资金损失"
}),
"severity": 10,
"severity_reason": "支付核心逻辑漏洞,可直接导致资金损失,攻击者可利用超额退款窃取资金",
},
{
"project_id": "rtc_backend",
"environment": "production",
"level": "ERROR",
"source": "code_review",
"error_type": "LogicError",
"error_message": "list_all_devices_admin 管理员设备列表分页偏移量计算错误page=1 时跳过前 page_size 条记录",
"file_path": "app/api/device_api.py",
"line_number": 177,
"stack_trace": json.dumps({
"file": "app/api/device_api.py",
"line": 177,
"function": "list_all_devices_admin",
"code": "start = page * page_size",
"detail": "应为 start = (page - 1) * page_size当前 page=1 时 start=20 跳过首页数据"
}),
"context": json.dumps({
"expected": "start = (page - 1) * page_size",
"actual": "start = page * page_size",
"impact": "管理员查看设备列表第一页会跳过前20条记录数据展示错误"
}),
"severity": 5,
"severity_reason": "分页逻辑错误导致管理端数据展示不完整,但不涉及数据泄露或安全问题",
},
{
"project_id": "rtc_backend",
"environment": "production",
"level": "ERROR",
"source": "code_review",
"error_type": "LogicError",
"error_message": "UserService.search_users 搜索用户时 is_active=False 过滤条件不生效,无法搜索已停用用户",
"file_path": "app/services/user_service.py",
"line_number": 104,
"stack_trace": json.dumps({
"file": "app/services/user_service.py",
"line": 104,
"function": "search_users",
"code": "if is_active:",
"detail": "应为 if is_active is not None:,当传入 is_active=False 时条件为假不会执行过滤"
}),
"context": json.dumps({
"expected": "if is_active is not None:",
"actual": "if is_active:",
"impact": "管理员搜索停用用户时过滤不生效,返回所有用户而非仅停用用户"
}),
"severity": 4,
"severity_reason": "布尔条件判断错误导致特定搜索场景失效,影响范围有限,不涉及安全问题",
},
]
async def main():
conn = await asyncpg.connect(DB_URL)
try:
for bug in BUGS:
fp = make_fingerprint(
bug["project_id"], bug["error_type"],
bug["file_path"], bug["line_number"]
)
row = await conn.fetchrow(
"""
INSERT INTO errorlog (
project_id, environment, level, source,
error_type, error_message, file_path, line_number,
stack_trace, context, fingerprint, status,
severity, severity_reason, timestamp,
retry_count, rejection_count
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8,
$9::jsonb, $10::jsonb, $11, $12,
$13, $14, $15,
$16, $17
)
RETURNING id
""",
bug["project_id"], bug["environment"], bug["level"], bug["source"],
bug["error_type"], bug["error_message"], bug["file_path"], bug["line_number"],
bug["stack_trace"], bug["context"], fp, "NEW",
bug["severity"], bug["severity_reason"], datetime.utcnow(),
0, 0,
)
print(f"Inserted bug #{row['id']}: severity={bug['severity']} - {bug['error_message'][:60]}...")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())

110
insert_test_bugs.py Normal file
View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""插入测试 Bug 数据到数据库"""
import asyncio
import asyncpg
import os
import json
from datetime import datetime, timedelta
from dotenv import load_dotenv
load_dotenv()
async def insert_test_bugs():
# 连接数据库
conn = await asyncpg.connect(
host=os.getenv("DB_HOST"),
port=int(os.getenv("DB_PORT", "5432")),
database=os.getenv("DB_NAME"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD")
)
try:
# Bug 1: 简单拼写错误NEW- 3级
await conn.execute("""
INSERT INTO errorlog (
project_id, environment, level, source, error_type, error_message,
file_path, line_number, stack_trace, context, status, fingerprint,
timestamp, retry_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11, $12, $13, $14
)
""", 'rtc_backend', 'production', 'ERROR', 'runtime', 'NameError',
'name ''usre_id'' is not defined',
'app/services/user_service.py', 45,
json.dumps('NameError: name ''usre_id'' is not defined at line 45'),
json.dumps({"typo": "usre_id"}), 'NEW', 'bug_typo_001',
datetime.now() - timedelta(days=1), 0)
print("✅ Bug 1 插入成功: 3级 - 简单拼写错误")
# Bug 2: 空指针NEW- 5级
await conn.execute("""
INSERT INTO errorlog (
project_id, environment, level, source, error_type, error_message,
file_path, line_number, stack_trace, context, status, fingerprint,
timestamp, retry_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11, $12, $13, $14
)
""", 'rtc_backend', 'production', 'ERROR', 'runtime', 'AttributeError',
'''NoneType'' object has no attribute ''id''',
'app/api/device_api.py', 89,
json.dumps('AttributeError at line 89'),
json.dumps({"device_sn": "BRAND-P01-999"}), 'NEW', 'bug_null_002',
datetime.now() - timedelta(days=2), 0)
print("✅ Bug 2 插入成功: 5级 - 空指针错误")
# Bug 3: 支付逻辑错误NEW- 9级
await conn.execute("""
INSERT INTO errorlog (
project_id, environment, level, source, error_type, error_message,
file_path, line_number, stack_trace, context, status, fingerprint,
timestamp, retry_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11, $12, $13, $14
)
""", 'rtc_backend', 'production', 'CRITICAL', 'runtime', 'PaymentLogicError',
'支付金额计算错误:折扣后金额为负数',
'app/services/payment_service.py', 234,
json.dumps('PaymentLogicError at line 234'),
json.dumps({"original_price": 99.0, "discount": 120.0}), 'NEW', 'bug_payment_004',
datetime.now() - timedelta(days=3), 0)
print("✅ Bug 3 插入成功: 9级 - 支付逻辑错误")
# Bug 4: 数据泄露风险NEW- 10级
await conn.execute("""
INSERT INTO errorlog (
project_id, environment, level, source, error_type, error_message,
file_path, line_number, stack_trace, context, status, fingerprint,
timestamp, retry_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11, $12, $13, $14
)
""", 'rtc_backend', 'production', 'CRITICAL', 'runtime', 'DataLeakError',
'未授权访问:用户可以查询到其他用户的设备列表',
'app/api/device_api.py', 156,
json.dumps('DataLeakError: unauthorized device list access'),
json.dumps({"user_id": 12345, "accessed_devices": ["BRAND-P01-001", "BRAND-P01-002"]}),
'NEW', 'bug_dataleak_005',
datetime.now() - timedelta(days=4), 0)
print("✅ Bug 4 插入成功: 10级 - 数据泄露风险")
print("\n✅ 所有测试 Bug 插入完成!")
# 查询确认
result = await conn.fetch("""
SELECT id, error_type, error_message, status, fingerprint
FROM errorlog
WHERE fingerprint IN ('bug_typo_001', 'bug_null_002', 'bug_payment_004', 'bug_dataleak_005')
ORDER BY timestamp DESC
""")
print(f"\n📊 已插入 {len(result)} 条测试数据:")
for row in result:
print(f" - ID {row['id']}: {row['error_type']} - {row['error_message'][:50]}...")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(insert_test_bugs())

View File

@ -185,14 +185,14 @@ class RepairEngine:
failure_reason = f"Claude CLI 执行失败: {output[:500]}" failure_reason = f"Claude CLI 执行失败: {output[:500]}"
logger.error(f"{failure_reason} (round {round_num})") logger.error(f"{failure_reason} (round {round_num})")
for bug in bugs:
self._upload_round_report( self._upload_round_report(
bug=bug, project_id=project_id, round_num=round_num, bugs=bugs, project_id=project_id, round_num=round_num,
ai_analysis=output, diff="", modified_files=[], ai_analysis=output, diff="", modified_files=[],
test_output="", test_passed=False, test_output="", test_passed=False,
failure_reason=failure_reason, failure_reason=failure_reason,
status=BugStatus.FIX_FAILED, status=BugStatus.FIX_FAILED,
) )
for bug in bugs:
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason) self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason)
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason)) results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
break break
@ -206,23 +206,9 @@ class RepairEngine:
logger.info(f"{round_num} 轮修复完成,修改了 {len(modified_files)} 个文件") logger.info(f"{round_num} 轮修复完成,修改了 {len(modified_files)} 个文件")
# Step 3: 安全检查(仅在 Git 启用时,不重试 # Step 3: 安全检查(软性警告,不阻断修复流程
if git_manager and not self._safety_check(modified_files, diff): if git_manager and not self._safety_check(modified_files, diff):
failure_reason = "安全检查未通过" logger.warning(f"安全检查警告 (round {round_num}):修改涉及核心文件或超出限制,继续修复流程")
logger.warning(f"{failure_reason} (round {round_num})")
git_manager.reset_hard()
for bug in bugs:
self._upload_round_report(
bug=bug, project_id=project_id, round_num=round_num,
ai_analysis=output, diff=diff, modified_files=modified_files,
test_output="", test_passed=False,
failure_reason=failure_reason,
status=BugStatus.FIX_FAILED,
)
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason)
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
break
# Step 4: 运行 Claude 生成的测试文件 # Step 4: 运行 Claude 生成的测试文件
bug_ids_str = "_".join(str(b.id) for b in bugs) bug_ids_str = "_".join(str(b.id) for b in bugs)
@ -233,42 +219,115 @@ class RepairEngine:
if not test_output: if not test_output:
test_output = "Claude 未生成测试文件" test_output = "Claude 未生成测试文件"
logger.warning(f"测试文件 {test_file} 不存在,跳过测试验证") logger.warning(f"测试文件 {test_file} 不存在,跳过测试验证")
# 没有测试文件时视为通过(无法验证)
test_passed = True
# 清理临时测试文件 # 清理临时测试文件
cleanup_repair_test_file(project_path, test_file) cleanup_repair_test_file(project_path, test_file)
for bug in bugs: # Step 5: 测试失败 → 记录本轮结果,进入下一轮重试
self.task_manager.update_status(bug.id, BugStatus.FIXED) if not test_passed:
self._upload_round_report( logger.warning(f"{round_num} 轮测试未通过,{'进入下一轮重试' if round_num < max_rounds else '已达最大轮次'}")
bug=bug, project_id=project_id, round_num=round_num, last_test_output = test_output
ai_analysis=output, diff=diff, modified_files=modified_files, last_diff = diff
test_output=test_output,
test_passed=test_passed,
failure_reason=None,
status=BugStatus.FIXED,
)
results.append(FixResult(
bug_id=bug.id, success=True,
message=f"修复成功 (第 {round_num} 轮)",
modified_files=modified_files, diff=diff,
))
# 自动提交、合并到 main 并推送(仅在 Git 启用时) self._upload_round_report(
bugs=bugs, project_id=project_id, round_num=round_num,
ai_analysis=output, diff=diff, modified_files=modified_files,
test_output=test_output, test_passed=False,
failure_reason=f"{round_num} 轮测试失败",
status=BugStatus.FIXING,
)
# 重置代码变更,让下一轮从干净状态开始
if git_manager:
git_manager.reset_hard()
# 最后一轮还失败 → 标记为 FIX_FAILED
if round_num == max_rounds:
failure_reason = f"经过 {max_rounds} 轮修复,测试仍未通过"
for bug in bugs:
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason)
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
continue # 进入下一轮
# Step 6: 判断是否需要强制 PR 审核severity >= 8 的 bug 必须走 PR
max_severity = max((b.severity or 0) for b in bugs)
needs_pr_review = max_severity >= 8
if needs_pr_review:
logger.info(f"批次中存在高严重等级 bug (max severity={max_severity}),强制走 PR 审核流程")
# Step 7: 测试通过 → 提交代码并创建 PR
pr_info = None
if git_enabled and auto_commit and modified_files and git_manager: if git_enabled and auto_commit and modified_files and git_manager:
bug_ids = ", ".join([f"#{b.id}" for b in bugs]) bug_ids = ", ".join([f"#{b.id}" for b in bugs])
git_manager.commit(f"fix: auto repair bugs {bug_ids}") git_manager.commit(f"fix: auto repair bugs {bug_ids}")
logger.info("代码已提交") logger.info("代码已提交")
git_manager.push() git_manager.push()
logger.info("fix 分支已推送") logger.info("fix 分支已推送")
# 合并 fix 分支到 main 并推送,触发 CI/CD
if git_manager.merge_to_main_and_push():
logger.info("已合并到 main 并推送")
else:
logger.warning("合并到 main 失败,请手动合并")
elif not git_enabled and auto_commit:
logger.info("未配置 GitHub 仓库,跳过自动提交")
break # 成功,退出循环 # 创建 PR
bug_summary = "\n".join([
f"- #{b.id}: {b.error.type} - {b.error.message[:80]}"
for b in bugs
])
severity_note = f"\n\n⚠️ **包含高严重等级 Bug (max={max_severity}/10),需人工审核**" if needs_pr_review else ""
pr_title = f"fix: auto repair bugs {bug_ids}"
pr_body = f"## 自动修复报告\n\n修复的 Bug:\n{bug_summary}{severity_note}\n\n修改文件:\n" + \
"\n".join([f"- `{f}`" for f in modified_files])
success, pr_info = git_manager.create_pr(pr_title, pr_body)
if success and pr_info:
logger.info(f"PR 创建成功: {pr_info['pr_url']}")
else:
logger.warning("创建 PR 失败,修复已提交到 fix 分支")
elif not git_enabled and auto_commit:
logger.info("未配置仓库地址,跳过自动提交")
# 根据是否有 PR 或是否需要强制审核来决定 Bug 状态
# 高严重等级 bug即使 PR 创建失败也不能直接标记 FIXED保持 PENDING_FIX 等待人工处理
if pr_info:
final_status = BugStatus.PENDING_FIX
elif needs_pr_review:
final_status = BugStatus.PENDING_FIX
logger.warning("高严重等级 bug PR 创建失败,状态设为 PENDING_FIX 等待人工处理")
else:
final_status = BugStatus.FIXED
# 上传一条批量修复报告(含 PR 信息)
self._upload_round_report(
bugs=bugs, project_id=project_id, round_num=round_num,
ai_analysis=output, diff=diff, modified_files=modified_files,
test_output=test_output,
test_passed=True,
failure_reason=None,
status=final_status,
pr_url=pr_info["pr_url"] if pr_info else None,
pr_number=pr_info["pr_number"] if pr_info else None,
branch_name=pr_info["branch_name"] if pr_info else None,
)
for bug in bugs:
self.task_manager.update_status(bug.id, final_status)
# 回写 PR 信息到 Bug
if pr_info:
self.task_manager.update_pr_info(
bug.id,
pr_info["pr_number"],
pr_info["pr_url"],
pr_info["branch_name"],
)
results.append(FixResult(
bug_id=bug.id, success=True,
message=f"修复成功 (第 {round_num} 轮)" + (f", PR #{pr_info['pr_number']}" if pr_info else ""),
modified_files=modified_files, diff=diff,
))
break # 测试通过,退出循环
except Exception as e: except Exception as e:
# 兜底:标记为 FIX_FAILED防止死循环可通过 retry 命令重新处理) # 兜底:标记为 FIX_FAILED防止死循环可通过 retry 命令重新处理)
@ -357,7 +416,7 @@ class RepairEngine:
"AI 分诊判定:非代码缺陷或无法复现", "AI 分诊判定:非代码缺陷或无法复现",
) )
self._upload_round_report( self._upload_round_report(
bug=bug, project_id=bug.project_id, round_num=0, bugs=[bug], project_id=bug.project_id, round_num=0,
ai_analysis=output, diff="", modified_files=[], ai_analysis=output, diff="", modified_files=[],
test_output="", test_passed=False, test_output="", test_passed=False,
failure_reason="AI 分诊:无法复现", failure_reason="AI 分诊:无法复现",
@ -399,7 +458,7 @@ class RepairEngine:
def _upload_round_report( def _upload_round_report(
self, self,
bug: Bug, bugs: list[Bug],
project_id: str, project_id: str,
round_num: int, round_num: int,
ai_analysis: str, ai_analysis: str,
@ -409,11 +468,14 @@ class RepairEngine:
test_passed: bool, test_passed: bool,
failure_reason: Optional[str], failure_reason: Optional[str],
status: BugStatus, status: BugStatus,
pr_url: Optional[str] = None,
pr_number: Optional[int] = None,
branch_name: Optional[str] = None,
): ):
"""上传某一轮的修复报告""" """上传某一轮的修复报告(一次批量修复 = 一条报告)"""
try: try:
report = RepairReport( report = RepairReport(
error_log_id=bug.id, error_log_ids=[b.id for b in bugs],
status=status, status=status,
project_id=project_id, project_id=project_id,
ai_analysis=ai_analysis, ai_analysis=ai_analysis,
@ -424,6 +486,9 @@ class RepairEngine:
test_passed=test_passed, test_passed=test_passed,
repair_round=round_num, repair_round=round_num,
failure_reason=failure_reason, failure_reason=failure_reason,
pr_url=pr_url,
pr_number=pr_number,
branch_name=branch_name,
) )
self.task_manager.upload_report(report) self.task_manager.upload_report(report)
except Exception as e: except Exception as e:
@ -515,7 +580,7 @@ class RepairEngine:
if not success: if not success:
failure_reason = f"Claude CLI 执行失败: {output[:500]}" failure_reason = f"Claude CLI 执行失败: {output[:500]}"
self._upload_round_report( self._upload_round_report(
bug=bug, project_id=bug.project_id, round_num=round_num, bugs=[bug], project_id=bug.project_id, round_num=round_num,
ai_analysis=output, diff="", modified_files=[], ai_analysis=output, diff="", modified_files=[],
test_output="", test_passed=False, test_output="", test_passed=False,
failure_reason=failure_reason, status=BugStatus.FIX_FAILED, failure_reason=failure_reason, status=BugStatus.FIX_FAILED,
@ -540,7 +605,7 @@ class RepairEngine:
self.task_manager.update_status(bug_id, BugStatus.FIXED) self.task_manager.update_status(bug_id, BugStatus.FIXED)
self._upload_round_report( self._upload_round_report(
bug=bug, project_id=bug.project_id, round_num=round_num, bugs=[bug], project_id=bug.project_id, round_num=round_num,
ai_analysis=output, diff=diff, modified_files=modified_files, ai_analysis=output, diff=diff, modified_files=modified_files,
test_output=test_output, test_output=test_output,
test_passed=test_passed, failure_reason=None, status=BugStatus.FIXED, test_passed=test_passed, failure_reason=None, status=BugStatus.FIXED,

View File

@ -2,7 +2,8 @@
Git Manager - Git 操作管理 Git Manager - Git 操作管理
""" """
import os import os
from typing import Optional from typing import Optional, Tuple
import requests
from git import Repo, InvalidGitRepositoryError from git import Repo, InvalidGitRepositoryError
from loguru import logger from loguru import logger
@ -52,6 +53,37 @@ class GitManager:
except Exception as e: except Exception as e:
logger.error(f"配置 remote 失败: {e}") logger.error(f"配置 remote 失败: {e}")
def _parse_owner_repo(self) -> Tuple[str, str]:
"""
github_repo 解析出 owner repo 名称
支持格式
- "owner/repo"
- "https://gitea.airlabs.art/owner/repo.git"
- "https://gitea.airlabs.art/owner/repo"
- "git@gitea.airlabs.art:owner/repo.git"
"""
raw = self.github_repo.strip()
# 去掉 .git 后缀
if raw.endswith(".git"):
raw = raw[:-4]
# HTTP(S) URL
if raw.startswith("http://") or raw.startswith("https://"):
parts = raw.rstrip("/").split("/")
if len(parts) >= 2:
return parts[-2], parts[-1]
# SSH 格式 git@host:owner/repo
elif ":" in raw and "@" in raw:
path = raw.split(":")[-1]
segments = path.split("/")
if len(segments) == 2:
return segments[0], segments[1]
# 简单 owner/repo 格式
else:
segments = raw.split("/")
if len(segments) == 2:
return segments[0], segments[1]
return "", ""
def pull(self) -> bool: def pull(self) -> bool:
"""拉取最新代码(自动切回 main/master 分支)""" """拉取最新代码(自动切回 main/master 分支)"""
if not self.repo: if not self.repo:
@ -214,6 +246,62 @@ class GitManager:
logger.error(f"合并到 main 失败: {e}") logger.error(f"合并到 main 失败: {e}")
return False return False
def create_pr(self, title: str, body: str, base: str = "main") -> Tuple[bool, Optional[dict]]:
"""
通过 Gitea API 创建 Pull Request
Returns:
(success, pr_info) - pr_info 包含 pr_number, pr_url, branch_name
"""
if not self.repo or not self.github_repo:
logger.error("未配置仓库地址,无法创建 PR")
return False, None
head_branch = self.repo.active_branch.name
gitea_url = settings.gitea_url.rstrip("/")
token = settings.gitea_token
if not token:
logger.error("未配置 Gitea Token无法创建 PR")
return False, None
# 从 github_repo 提取 owner/repo支持完整 URL 和 owner/repo 格式
owner, repo = self._parse_owner_repo()
if not owner or not repo:
logger.error(f"无法解析仓库 owner/repo: {self.github_repo}")
return False, None
api_url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/pulls"
try:
resp = requests.post(
api_url,
json={
"title": title,
"body": body,
"head": head_branch,
"base": base,
},
headers={"Authorization": f"token {token}"},
timeout=30,
)
if resp.status_code in (200, 201):
data = resp.json()
pr_info = {
"pr_number": data["number"],
"pr_url": data["html_url"],
"branch_name": head_branch,
}
logger.info(f"PR 创建成功: #{data['number']} - {data['html_url']}")
return True, pr_info
else:
logger.error(f"创建 PR 失败 ({resp.status_code}): {resp.text}")
return False, None
except Exception as e:
logger.error(f"创建 PR 请求异常: {e}")
return False, None
def reset_hard(self) -> bool: def reset_hard(self) -> bool:
"""重置所有更改""" """重置所有更改"""
if not self.repo: if not self.repo:

View File

@ -18,7 +18,9 @@ class TaskManager:
def fetch_pending_bugs(self, project_id: Optional[str] = None) -> list[Bug]: def fetch_pending_bugs(self, project_id: Optional[str] = None) -> list[Bug]:
""" """
获取待修复的 Bug 列表包括 NEW PENDING_FIX 状态 获取待修复的 Bug 列表 NEW 状态
PENDING_FIX 表示已修复等待 PR 审核不应被重新拉取修复
Args: Args:
project_id: 可选筛选特定项目 project_id: 可选筛选特定项目
@ -28,8 +30,8 @@ class TaskManager:
""" """
all_bugs: dict[int, Bug] = {} all_bugs: dict[int, Bug] = {}
for source in ("runtime", "cicd", "deployment"): for source in ("runtime", "cicd", "deployment", "code_review"):
for status in ("NEW", "PENDING_FIX"): for status in ("NEW",):
try: try:
params: dict[str, str] = {"status": status, "source": source} params: dict[str, str] = {"status": status, "source": source}
if project_id: if project_id:
@ -50,6 +52,8 @@ class TaskManager:
stack_trace = item.get("stack_trace") stack_trace = item.get("stack_trace")
if isinstance(stack_trace, str): if isinstance(stack_trace, str):
stack_trace = stack_trace.split("\n") stack_trace = stack_trace.split("\n")
elif isinstance(stack_trace, dict):
stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()]
all_bugs[bug_id] = Bug( all_bugs[bug_id] = Bug(
id=bug_id, id=bug_id,
@ -66,12 +70,13 @@ class TaskManager:
context=item.get("context"), context=item.get("context"),
status=BugStatus(item.get("status", "NEW")), status=BugStatus(item.get("status", "NEW")),
retry_count=item.get("retry_count", 0), retry_count=item.get("retry_count", 0),
severity=item.get("severity"),
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"获取 {source}/{status} 状态 Bug 列表失败: {e}") logger.error(f"获取 {source}/{status} 状态 Bug 列表失败: {e}")
bugs = list(all_bugs.values()) bugs = list(all_bugs.values())
logger.info(f"获取到 {len(bugs)} 个待修复 Bugruntime + cicd + deployment") logger.info(f"获取到 {len(bugs)} 个待修复 Bug")
return bugs return bugs
def fetch_failed_bugs(self, project_id: Optional[str] = None) -> list[Bug]: def fetch_failed_bugs(self, project_id: Optional[str] = None) -> list[Bug]:
@ -79,7 +84,7 @@ class TaskManager:
获取修复失败的 Bug 列表FIX_FAILED 状态所有来源 获取修复失败的 Bug 列表FIX_FAILED 状态所有来源
""" """
all_bugs: dict[int, Bug] = {} all_bugs: dict[int, Bug] = {}
for source in ("runtime", "cicd", "deployment"): for source in ("runtime", "cicd", "deployment", "code_review"):
try: try:
params: dict[str, str] = {"status": "FIX_FAILED", "source": source} params: dict[str, str] = {"status": "FIX_FAILED", "source": source}
if project_id: if project_id:
@ -100,6 +105,8 @@ class TaskManager:
stack_trace = item.get("stack_trace") stack_trace = item.get("stack_trace")
if isinstance(stack_trace, str): if isinstance(stack_trace, str):
stack_trace = stack_trace.split("\n") stack_trace = stack_trace.split("\n")
elif isinstance(stack_trace, dict):
stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()]
all_bugs[bug_id] = Bug( all_bugs[bug_id] = Bug(
id=item["id"], id=item["id"],
@ -116,6 +123,7 @@ class TaskManager:
context=item.get("context"), context=item.get("context"),
status=BugStatus.FIX_FAILED, status=BugStatus.FIX_FAILED,
retry_count=item.get("retry_count", 0), retry_count=item.get("retry_count", 0),
severity=item.get("severity"),
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"获取 {source}/FIX_FAILED Bug 列表失败: {e}") logger.error(f"获取 {source}/FIX_FAILED Bug 列表失败: {e}")
@ -160,10 +168,12 @@ class TaskManager:
item = response.json() item = response.json()
# stack_trace 可能是列表或字符串 # stack_trace 可能是列表、字符串或字典
stack_trace = item.get("stack_trace") stack_trace = item.get("stack_trace")
if isinstance(stack_trace, str): if isinstance(stack_trace, str):
stack_trace = stack_trace.split("\n") stack_trace = stack_trace.split("\n")
elif isinstance(stack_trace, dict):
stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()]
return Bug( return Bug(
id=item["id"], id=item["id"],
@ -180,6 +190,7 @@ class TaskManager:
context=item.get("context"), context=item.get("context"),
status=BugStatus(item.get("status", "NEW")), status=BugStatus(item.get("status", "NEW")),
retry_count=item.get("retry_count", 0), retry_count=item.get("retry_count", 0),
severity=item.get("severity"),
) )
except httpx.HTTPError as e: except httpx.HTTPError as e:
@ -194,12 +205,44 @@ class TaskManager:
json=report.model_dump() json=report.model_dump()
) )
response.raise_for_status() response.raise_for_status()
logger.info(f"Bug #{report.error_log_id} 修复报告已上传") logger.info(f"修复报告已上传 (bugs: {report.error_log_ids})")
return True return True
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"上传修复报告失败: {e}") logger.error(f"上传修复报告失败: {e}")
return False return False
def update_pr_info(self, bug_id: int, pr_number: int, pr_url: str, branch_name: str) -> bool:
"""回写 PR 信息到 Bug"""
try:
response = self.client.put(
f"{self.base_url}/api/v1/bugs/{bug_id}/pr-info",
json={
"pr_number": pr_number,
"pr_url": pr_url,
"branch_name": branch_name,
},
)
response.raise_for_status()
logger.info(f"Bug #{bug_id} PR 信息已更新: #{pr_number}")
return True
except httpx.HTTPError as e:
logger.error(f"更新 PR 信息失败: {e}")
return False
def update_severity(self, bug_id: int, severity: int, severity_reason: str = "") -> bool:
"""回写 Bug 严重等级"""
try:
response = self.client.put(
f"{self.base_url}/api/v1/bugs/{bug_id}/severity",
json={"severity": severity, "severity_reason": severity_reason},
)
response.raise_for_status()
logger.info(f"Bug #{bug_id} 严重等级已更新: {severity}/10")
return True
except httpx.HTTPError as e:
logger.error(f"更新严重等级失败: {e}")
return False
def get_project_info(self, project_id: str) -> Optional[dict]: def get_project_info(self, project_id: str) -> Optional[dict]:
"""从 Log Center API 获取项目配置repo_url + local_path""" """从 Log Center API 获取项目配置repo_url + local_path"""
try: try:

View File

@ -23,9 +23,12 @@ class Settings(BaseSettings):
# Git # Git
git_user_name: str = Field(default="repair-agent", description="Git 用户名") git_user_name: str = Field(default="repair-agent", description="Git 用户名")
git_user_email: str = Field(default="agent@airlabs.art", description="Git 邮箱") git_user_email: str = Field(default="agent@airlabs.art", description="Git 邮箱")
# Gitea
gitea_url: str = Field(default="https://gitea.airlabs.art", description="Gitea 地址")
gitea_token: Optional[str] = Field(default=None, description="Gitea Token") gitea_token: Optional[str] = Field(default=None, description="Gitea Token")
# GitHub 仓库地址(为空则不执行 git 操作) # 仓库地址(owner/repo 格式,为空则不执行 git 操作)
github_repo_rtc_backend: str = Field(default="", description="rtc_backend GitHub 仓库地址") github_repo_rtc_backend: str = Field(default="", description="rtc_backend GitHub 仓库地址")
github_repo_rtc_web: str = Field(default="", description="rtc_web GitHub 仓库地址") github_repo_rtc_web: str = Field(default="", description="rtc_web GitHub 仓库地址")
github_repo_airhub_app: str = Field(default="", description="airhub_app GitHub 仓库地址") github_repo_airhub_app: str = Field(default="", description="airhub_app GitHub 仓库地址")

View File

@ -39,6 +39,7 @@ class Bug(BaseModel):
context: Optional[dict[str, Any]] = Field(default=None, description="上下文") context: Optional[dict[str, Any]] = Field(default=None, description="上下文")
status: BugStatus = Field(default=BugStatus.NEW, description="状态") status: BugStatus = Field(default=BugStatus.NEW, description="状态")
retry_count: int = Field(default=0, description="重试次数") retry_count: int = Field(default=0, description="重试次数")
severity: Optional[int] = Field(default=None, description="严重等级 1-10")
created_at: Optional[datetime] = Field(default=None, description="创建时间") created_at: Optional[datetime] = Field(default=None, description="创建时间")
def format_for_prompt(self) -> str: def format_for_prompt(self) -> str:
@ -81,8 +82,8 @@ class BatchFixResult(BaseModel):
class RepairReport(BaseModel): class RepairReport(BaseModel):
"""修复报告""" """修复报告(一次批量修复 = 一条报告)"""
error_log_id: int error_log_ids: list[int]
status: BugStatus status: BugStatus
project_id: str project_id: str
ai_analysis: str ai_analysis: str
@ -93,4 +94,7 @@ class RepairReport(BaseModel):
test_passed: bool test_passed: bool
repair_round: int = 1 repair_round: int = 1
failure_reason: Optional[str] = None failure_reason: Optional[str] = None
pr_url: Optional[str] = None
pr_number: Optional[int] = None
branch_name: Optional[str] = None

55
test_bugs.sql Normal file
View File

@ -0,0 +1,55 @@
-- 测试Bug数据 - 用于演示完整修复流程
-- 执行方式psql -h pgm-7xv4811oj11j86htzo.pg.rds.aliyuncs.com -U log_center -d log_center -f test_bugs.sql
-- Bug 1: 简单拼写错误NEW
INSERT INTO errorlog (project_id, environment, level, source, error_type, error_message, file_path, line_number, stack_trace, context, status, fingerprint, timestamp, retry_count)
VALUES ('rtc_backend', 'production', 'ERROR', 'runtime', 'NameError', 'name ''usre_id'' is not defined',
'app/services/user_service.py', 45, 'NameError: name ''usre_id'' is not defined at line 45',
'{"typo": "usre_id"}', 'NEW', 'bug_typo_001', NOW() - INTERVAL '1 day', 0);
-- Bug 2: 空指针NEW
INSERT INTO errorlog (project_id, environment, level, source, error_type, error_message, file_path, line_number, stack_trace, context, status, fingerprint, timestamp, retry_count)
VALUES ('rtc_backend', 'production', 'ERROR', 'runtime', 'AttributeError', '''None Type'' object has no attribute ''id''',
'app/api/device_api.py', 89, 'AttributeError at line 89',
'{"device_sn": "BRAND-P01-999"}', 'NEW', 'bug_null_002', NOW() - INTERVAL '2 days', 0);
-- Bug 3: 列表越界有PR待审核
INSERT INTO errorlog (project_id, environment, level, source, error_type, error_message, file_path, line_number, stack_trace, context, status, fingerprint, pr_url, pr_number, branch_name, timestamp, retry_count)
VALUES ('rtc_backend', 'production', 'ERROR', 'runtime', 'IndexError', 'list index out of range',
'app/utils/data_processor.py', 127, 'IndexError at line 127',
'{"batch_size": 0}', 'PENDING_FIX', 'bug_index_003',
'https://gitea.airlabs.art/qiyuan/rtc_backend/pulls/301', 301, 'fix/auto-indexerror',
NOW() - INTERVAL '3 days', 0);
-- Bug 4: 支付逻辑错误高级有PR
INSERT INTO errorlog (project_id, environment, level, source, error_type, error_message, file_path, line_number, stack_trace, context, status, fingerprint, pr_url, pr_number, branch_name, timestamp, retry_count)
VALUES ('rtc_backend', 'production', 'CRITICAL', 'runtime', 'PaymentLogicError', '支付金额计算错误:折扣后金额为负数',
'app/services/payment_service.py', 234, 'PaymentLogicError at line 234',
'{"original_price": 99.0, "discount": 120.0}', 'PENDING_FIX', 'bug_payment_004',
'https://gitea.airlabs.art/qiyuan/rtc_backend/pulls/302', 302, 'fix/auto-payment-logic',
NOW() - INTERVAL '4 days', 0);
-- Bug 5: 并发问题高级有PR
INSERT INTO errorlog (project_id, environment, level, source, error_type, error_message, file_path, line_number, stack_trace, context, status, fingerprint, pr_url, pr_number, branch_name, timestamp, retry_count)
VALUES ('rtc_backend', 'production', 'CRITICAL', 'runtime', 'DatabaseIntegrityError', '用户积分并发更新冲突',
'app/services/points_service.py', 156, 'DatabaseIntegrityError: race condition',
'{"user_id": 12345, "add_amount": 100}', 'PENDING_FIX', 'bug_concurrency_005',
'https://gitea.airlabs.art/qiyuan/rtc_backend/pulls/303', 303, 'fix/auto-points-race',
NOW() - INTERVAL '5 days', 0);
-- Bug 6: 已修复(已合并)
INSERT INTO errorlog (project_id, environment, level, source, error_type, error_message, file_path, line_number, stack_trace, context, status, fingerprint, pr_url, pr_number, branch_name, merged_at, timestamp, retry_count)
VALUES ('rtc_backend', 'production', 'WARNING', 'runtime', 'KeyError', 'Key ''age'' not found',
'app/utils/validator.py', 67, 'KeyError: ''age'' at line 67',
'{"keys": ["name", "email"]}', 'FIXED', 'bug_keyerror_006',
'https://gitea.airlabs.art/qiyuan/rtc_backend/pulls/300', 300, 'fix/auto-keyerror',
NOW() - INTERVAL '2 hours', NOW() - INTERVAL '6 days', 0);
-- Bug 7: 被拒绝的有PR已拒绝1次
INSERT INTO errorlog (project_id, environment, level, source, error_type, error_message, file_path, line_number, stack_trace, context, status, fingerprint, pr_url, pr_number, branch_name, rejection_count, rejection_reason, last_rejected_at, timestamp, retry_count)
VALUES ('rtc_backend', 'production', 'ERROR', 'runtime', 'ValidationError', '手机号格式验证失败',
'app/validators/phone_validator.py', 23, 'ValidationError: invalid phone format',
'{"phone": "12345"}', 'PENDING_FIX', 'bug_phone_007',
'https://gitea.airlabs.art/qiyuan/rtc_backend/pulls/304', 304, 'fix/auto-phone-validation',
1, '{"reason": "测试覆盖不足,缺少边界条件测试", "rejected_at": "2026-02-25T02:00:00"}',
NOW() - INTERVAL '1 hour', NOW() - INTERVAL '7 days', 0);

View File

@ -27,6 +27,9 @@ export interface ErrorLog {
status: string; status: string;
retry_count: number; retry_count: number;
failure_reason: string | null; failure_reason: string | null;
// Severity (1-10)
severity?: number | null;
severity_reason?: string | null;
// PR Tracking // PR Tracking
pr_number?: number | null; pr_number?: number | null;
pr_url?: string | null; pr_url?: string | null;
@ -67,7 +70,7 @@ export interface PaginatedResponse<T> {
export interface RepairReport { export interface RepairReport {
id: number; id: number;
error_log_id: number; error_log_ids: number[];
project_id: string; project_id: string;
status: string; status: string;
ai_analysis: string; ai_analysis: string;
@ -79,6 +82,9 @@ export interface RepairReport {
created_at: string; created_at: string;
repair_round: number; repair_round: number;
failure_reason: string | null; failure_reason: string | null;
pr_url?: string | null;
pr_number?: number | null;
branch_name?: string | null;
} }
// API Functions // API Functions
@ -128,4 +134,21 @@ export const mergePR = (bugId: number) =>
export const closePR = (bugId: number, reason: string) => export const closePR = (bugId: number, reason: string) =>
api.post(`/api/v1/bugs/${bugId}/close-pr`, { reason }); api.post(`/api/v1/bugs/${bugId}/close-pr`, { reason });
export const retryFix = (bugId: number) =>
api.post(`/api/v1/bugs/${bugId}/retry`);
// Manual review operations (without PR)
export const approveFix = (bugId: number) =>
api.post(`/api/v1/bugs/${bugId}/approve-fix`);
export const rejectFix = (bugId: number, reason: string) =>
api.post(`/api/v1/bugs/${bugId}/reject-fix`, { reason });
// Report-level approve/reject (batch operation)
export const approveReport = (reportId: number) =>
api.post(`/api/v1/repair/reports/${reportId}/approve`);
export const rejectReport = (reportId: number, reason: string) =>
api.post(`/api/v1/repair/reports/${reportId}/reject`, { 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, Check, X, ExternalLink } from 'lucide-react'; 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'; import { getBugDetail, triggerRepair, getRepairReportsByBug, mergePR, closePR, retryFix, approveFix, rejectFix, type ErrorLog, type RepairReport } from '../api';
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
runtime: '运行时', runtime: '运行时',
@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, string> = {
NEW: '新发现', NEW: '新发现',
VERIFYING: '验证中', VERIFYING: '验证中',
CANNOT_REPRODUCE: '无法复现', CANNOT_REPRODUCE: '无法复现',
PENDING_FIX: '待修复', PENDING_FIX: '等待审核',
FIXING: '修复中', FIXING: '修复中',
FIXED: '已修复', FIXED: '已修复',
VERIFIED: '已验证', VERIFIED: '已验证',
@ -44,6 +44,15 @@ export default function BugDetail() {
const [showRejectModal, setShowRejectModal] = useState(false); const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
// 人工审核操作状态(无 PR 时使用)
const [approving, setApproving] = useState(false);
const [rejecting, setRejecting] = useState(false);
const [reviewMessage, setReviewMessage] = useState('');
// 重试操作状态
const [retrying, setRetrying] = useState(false);
const [retryMessage, setRetryMessage] = useState('');
const backSearch = location.state?.fromSearch || ''; const backSearch = location.state?.fromSearch || '';
useEffect(() => { useEffect(() => {
@ -127,6 +136,64 @@ export default function BugDetail() {
} }
}; };
const handleRetry = async () => {
if (!bug) return;
setRetrying(true);
setRetryMessage('');
try {
await retryFix(bug.id);
setBug({ ...bug, status: 'NEW', failure_reason: null });
setRetryMessage('✅ Bug 已重置repair agent 将重新扫描');
} catch (error: any) {
console.error('Failed to retry:', error);
setRetryMessage(`❌ 重试失败: ${error.response?.data?.detail || error.message}`);
} finally {
setRetrying(false);
}
};
const handleApproveFix = async () => {
if (!bug) return;
setApproving(true);
setReviewMessage('');
try {
await approveFix(bug.id);
setBug({ ...bug, status: 'FIXED', merged_at: new Date().toISOString() });
setReviewMessage('修复已确认');
} catch (error: any) {
console.error('Failed to approve fix:', error);
setReviewMessage(`确认失败: ${error.response?.data?.detail || error.message}`);
} finally {
setApproving(false);
}
};
const handleRejectFix = async () => {
if (!bug || !rejectReason.trim()) {
setReviewMessage('请输入驳回原因');
return;
}
setRejecting(true);
setReviewMessage('');
try {
await rejectFix(bug.id, rejectReason);
setBug({
...bug,
status: 'NEW',
rejection_count: (bug.rejection_count || 0) + 1,
last_rejected_at: new Date().toISOString()
});
setReviewMessage('修复已驳回Bug 将重新修复');
setShowRejectModal(false);
setRejectReason('');
} catch (error: any) {
console.error('Failed to reject fix:', error);
setReviewMessage(`驳回失败: ${error.response?.data?.detail || error.message}`);
} finally {
setRejecting(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="loading"> <div className="loading">
@ -142,7 +209,12 @@ 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 hasPR = !!bug.pr_url;
const canOperatePR = hasPR && bug.status === 'PENDING_FIX'; const isPendingReview = bug.status === 'PENDING_FIX';
const canOperatePR = hasPR && isPendingReview;
const canManualReview = !hasPR && isPendingReview;
// PR 信息只在 PENDING_FIX 或 FIXED 状态时显示
const shouldShowPR = hasPR && (isPendingReview || bug.status === 'FIXED');
const canRetry = bug.status === 'FIX_FAILED';
return ( return (
<div> <div>
@ -164,13 +236,48 @@ export default function BugDetail() {
<span>{bug.level}</span> <span>{bug.level}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{bug.severity != null && bug.status !== 'NEW' && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 10px',
borderRadius: '12px',
fontSize: '13px',
fontWeight: 600,
color: '#fff',
background: bug.severity >= 8 ? 'var(--error)' : bug.severity >= 5 ? 'var(--warning)' : 'var(--success)',
}}>
{bug.severity}/10
</span>
)}
<span className={`status-badge status-${bug.status}`}> <span className={`status-badge status-${bug.status}`}>
{STATUS_LABELS[bug.status] || bug.status} {STATUS_LABELS[bug.status] || bug.status}
</span> </span>
</div> </div>
</div>
{/* PR 信息显示 */} {/* 严重等级说明 */}
{hasPR && ( {bug.severity != null && bug.severity_reason && bug.status !== 'NEW' && (
<div className="detail-section" style={{
background: bug.severity >= 8 ? 'rgba(239,68,68,0.08)' : 'var(--bg-secondary)',
padding: '12px',
borderRadius: '6px',
marginTop: '12px',
borderLeft: `3px solid ${bug.severity >= 8 ? 'var(--error)' : bug.severity >= 5 ? 'var(--warning)' : 'var(--success)'}`,
}}>
<div className="detail-section-title" style={{ marginBottom: '4px' }}>
{bug.severity}/10 {bug.severity >= 8 ? '(需人工审核)' : ''}
</div>
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
{bug.severity_reason}
</div>
</div>
)}
{/* PR 信息显示 - 仅在 PENDING_FIX 或 FIXED 状态时显示 */}
{shouldShowPR && (
<div className="detail-section" style={{ background: 'var(--bg-secondary)', padding: '12px', borderRadius: '6px', marginTop: '16px' }}> <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 className="detail-section-title" style={{ marginBottom: '8px' }}>Pull Request</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
@ -284,8 +391,37 @@ export default function BugDetail() {
</> </>
)} )}
{/* 人工审核按钮(无 PR 时) */}
{canManualReview && (
<>
<button
className="trigger-repair-btn"
onClick={handleApproveFix}
disabled={approving}
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
>
{approving ? (
<Loader2 size={14} className="spinner" />
) : (
<Check size={14} />
)}
{approving ? '确认中...' : '确认修复'}
</button>
<button
className="trigger-repair-btn"
onClick={() => setShowRejectModal(true)}
disabled={rejecting}
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
>
<X size={14} />
</button>
</>
)}
{/* 原有的触发修复按钮 */} {/* 原有的触发修复按钮 */}
{!hasPR && ( {!hasPR && !isPendingReview && (
<button <button
className="trigger-repair-btn" className="trigger-repair-btn"
onClick={handleTriggerRepair} onClick={handleTriggerRepair}
@ -300,6 +436,23 @@ export default function BugDetail() {
</button> </button>
)} )}
{/* 重新尝试按钮 - 仅在修复失败时显示 */}
{canRetry && (
<button
className="trigger-repair-btn"
onClick={handleRetry}
disabled={retrying}
style={{ background: 'var(--warning)', borderColor: 'var(--warning)' }}
>
{retrying ? (
<Loader2 size={14} className="spinner" />
) : (
<Play size={14} />
)}
{retrying ? '重置中...' : '重新尝试'}
</button>
)}
{/* 消息显示 */} {/* 消息显示 */}
{prMessage && ( {prMessage && (
<span style={{ <span style={{
@ -319,7 +472,25 @@ export default function BugDetail() {
</span> </span>
)} )}
{!canTriggerRepair && !repairing && !hasPR && ( {retryMessage && (
<span style={{
fontSize: '13px',
color: retryMessage.includes('✅') ? 'var(--success)' : 'var(--error)'
}}>
{retryMessage}
</span>
)}
{reviewMessage && (
<span style={{
fontSize: '13px',
color: reviewMessage.includes('确认') || reviewMessage.includes('驳回') ? 'var(--success)' : 'var(--error)'
}}>
{reviewMessage}
</span>
)}
{!canTriggerRepair && !repairing && !hasPR && !canRetry && !canManualReview && (
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}> <span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
{!isRuntime {!isRuntime
? 'CI/CD 和部署错误暂不支持自动修复' ? 'CI/CD 和部署错误暂不支持自动修复'
@ -452,9 +623,9 @@ export default function BugDetail() {
width: '90%', width: '90%',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)' boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
}}> }}>
<h3 style={{ marginBottom: '16px' }}></h3> <h3 style={{ marginBottom: '16px' }}>{hasPR ? '拒绝修复' : '驳回修复'}</h3>
<p style={{ marginBottom: '12px', fontSize: '14px', color: 'var(--text-secondary)' }}> <p style={{ marginBottom: '12px', fontSize: '14px', color: 'var(--text-secondary)' }}>
Agent {hasPR ? '拒绝' : '驳回'}Agent
</p> </p>
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
@ -515,17 +686,17 @@ export default function BugDetail() {
</button> </button>
<button <button
onClick={handleClosePR} onClick={hasPR ? handleClosePR : handleRejectFix}
disabled={closingPR || !rejectReason.trim()} disabled={(hasPR ? closingPR : rejecting) || !rejectReason.trim()}
className="trigger-repair-btn" className="trigger-repair-btn"
style={{ background: 'var(--error)', borderColor: 'var(--error)' }} style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
> >
{closingPR ? ( {(hasPR ? closingPR : rejecting) ? (
<Loader2 size={14} className="spinner" /> <Loader2 size={14} className="spinner" />
) : ( ) : (
<X size={14} /> <X size={14} />
)} )}
{closingPR ? '提交中...' : '确认拒绝'} {(hasPR ? closingPR : rejecting) ? '提交中...' : '确认驳回'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { getBugs, getProjects, type ErrorLog, type Project } from '../api'; import { getBugs, getProjects, type ErrorLog, type Project } from '../api';
const STATUSES = [ const STATUSES = [
'NEW', 'CANNOT_REPRODUCE', 'FIXING', 'FIXED', 'FIX_FAILED' 'NEW', 'PENDING_FIX', 'CANNOT_REPRODUCE', 'FIXING', 'FIXED', 'FIX_FAILED'
]; ];
const SOURCE_LABELS: Record<string, string> = { const SOURCE_LABELS: Record<string, string> = {
@ -16,7 +16,7 @@ const STATUS_LABELS: Record<string, string> = {
NEW: '新发现', NEW: '新发现',
VERIFYING: '验证中', VERIFYING: '验证中',
CANNOT_REPRODUCE: '无法复现', CANNOT_REPRODUCE: '无法复现',
PENDING_FIX: '待修复', PENDING_FIX: '等待审核',
FIXING: '修复中', FIXING: '修复中',
FIXED: '已修复', FIXED: '已修复',
VERIFIED: '已验证', VERIFIED: '已验证',

View File

@ -12,7 +12,7 @@ const STATUS_LABELS: Record<string, string> = {
NEW: '新发现', NEW: '新发现',
VERIFYING: '验证中', VERIFYING: '验证中',
CANNOT_REPRODUCE: '无法复现', CANNOT_REPRODUCE: '无法复现',
PENDING_FIX: '待修复', PENDING_FIX: '等待审核',
FIXING: '修复中', FIXING: '修复中',
FIXED: '已修复', FIXED: '已修复',
VERIFIED: '已验证', VERIFIED: '已验证',

View File

@ -1,13 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle } from 'lucide-react'; import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle, Check, X, Loader2, ExternalLink } from 'lucide-react';
import { getRepairReportDetail, type RepairReport } from '../api'; import { getRepairReportDetail, approveReport, rejectReport, type RepairReport } from '../api';
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
NEW: '新发现', NEW: '新发现',
VERIFYING: '验证中', VERIFYING: '验证中',
CANNOT_REPRODUCE: '无法复现', CANNOT_REPRODUCE: '无法复现',
PENDING_FIX: '待修复', PENDING_FIX: '等待审核',
FIXING: '修复中', FIXING: '修复中',
FIXED: '已修复', FIXED: '已修复',
VERIFIED: '已验证', VERIFIED: '已验证',
@ -15,11 +15,25 @@ const STATUS_LABELS: Record<string, string> = {
FIX_FAILED: '修复失败', FIX_FAILED: '修复失败',
}; };
const REJECT_REASON_TEMPLATES = [
'测试覆盖不足,缺少边界条件测试',
'业务逻辑需要调整',
'代码质量不符合规范',
'需要补充异常处理',
];
export default function RepairDetail() { export default function RepairDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [report, setReport] = useState<RepairReport | null>(null); const [report, setReport] = useState<RepairReport | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 审核操作状态
const [approving, setApproving] = useState(false);
const [rejecting, setRejecting] = useState(false);
const [actionMessage, setActionMessage] = useState('');
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectReason, setRejectReason] = useState('');
useEffect(() => { useEffect(() => {
if (id) { if (id) {
const fetchDetail = async () => { const fetchDetail = async () => {
@ -36,6 +50,43 @@ export default function RepairDetail() {
} }
}, [id]); }, [id]);
const handleApprove = async () => {
if (!report) return;
setApproving(true);
setActionMessage('');
try {
await approveReport(report.id);
setReport({ ...report, status: 'FIXED' });
setActionMessage('修复已批准,所有关联缺陷已标记为已修复');
} catch (error: any) {
console.error('Failed to approve report:', error);
setActionMessage(`批准失败: ${error.response?.data?.detail || error.message}`);
} finally {
setApproving(false);
}
};
const handleReject = async () => {
if (!report || !rejectReason.trim()) {
setActionMessage('请输入驳回原因');
return;
}
setRejecting(true);
setActionMessage('');
try {
await rejectReport(report.id, rejectReason);
setReport({ ...report, status: 'NEW' });
setActionMessage('修复已驳回,所有关联缺陷将重新修复');
setShowRejectModal(false);
setRejectReason('');
} catch (error: any) {
console.error('Failed to reject report:', error);
setActionMessage(`驳回失败: ${error.response?.data?.detail || error.message}`);
} finally {
setRejecting(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="loading"> <div className="loading">
@ -48,6 +99,9 @@ export default function RepairDetail() {
return <div className="loading"></div>; return <div className="loading"></div>;
} }
const isPendingReview = report.status === 'PENDING_FIX';
const hasPR = !!report.pr_url;
return ( return (
<div> <div>
<Link to="/repairs" className="back-link"> <Link to="/repairs" className="back-link">
@ -71,8 +125,15 @@ export default function RepairDetail() {
<strong>{report.project_id}</strong> <strong>{report.project_id}</strong>
</div> </div>
<div className="info-row"> <div className="info-row">
<span></span> <span></span>
<Link to={`/bugs/${report.error_log_id}`}>#{report.error_log_id}</Link> <span>
{report.error_log_ids?.map((bugId, idx) => (
<span key={bugId}>
{idx > 0 && ', '}
<Link to={`/bugs/${bugId}`}>#{bugId}</Link>
</span>
))}
</span>
</div> </div>
<div className="info-row"> <div className="info-row">
<span></span> <span></span>
@ -90,6 +151,67 @@ export default function RepairDetail() {
</div> </div>
</div> </div>
{/* PR 信息 */}
{hasPR && (
<div className="card" style={{ borderLeft: '3px solid var(--accent)' }}>
<h2>Pull Request</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '14px' }}>
PR #{report.pr_number} | {report.branch_name || 'fix branch'}
</span>
<a href={report.pr_url!} target="_blank" rel="noopener noreferrer" className="btn-link" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
PR <ExternalLink size={12} />
</a>
</div>
</div>
)}
{/* 审核操作区 */}
{isPendingReview && (
<div className="card" style={{ borderLeft: '3px solid var(--warning)' }}>
<h2></h2>
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginBottom: '12px' }}>
{hasPR
? '批准将合并 PR 并标记所有关联缺陷为已修复'
: '确认修复将标记所有关联缺陷为已修复'}
</p>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
<button
className="trigger-repair-btn"
onClick={handleApprove}
disabled={approving}
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
>
{approving ? (
<Loader2 size={14} className="spinner" />
) : (
<Check size={14} />
)}
{approving ? '处理中...' : (hasPR ? '批准并合并' : '确认修复')}
</button>
<button
className="trigger-repair-btn"
onClick={() => setShowRejectModal(true)}
disabled={rejecting}
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
>
<X size={14} />
{hasPR ? '拒绝修复' : '驳回修复'}
</button>
{actionMessage && (
<span style={{
fontSize: '13px',
color: actionMessage.includes('批准') || actionMessage.includes('驳回') ? 'var(--success)' : 'var(--error)'
}}>
{actionMessage}
</span>
)}
</div>
</div>
)}
{report.failure_reason && ( {report.failure_reason && (
<div className="card" style={{ borderLeft: '3px solid var(--error)' }}> <div className="card" style={{ borderLeft: '3px solid var(--error)' }}>
<h2><AlertTriangle size={16} /> </h2> <h2><AlertTriangle size={16} /> </h2>
@ -111,6 +233,108 @@ export default function RepairDetail() {
<h2><FlaskConical size={16} /> </h2> <h2><FlaskConical size={16} /> </h2>
<pre className="code-block neutral">{report.test_output}</pre> <pre className="code-block neutral">{report.test_output}</pre>
</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' }}>{hasPR ? '拒绝修复' : '驳回修复'}</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={handleReject}
disabled={rejecting || !rejectReason.trim()}
className="trigger-repair-btn"
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
>
{rejecting ? (
<Loader2 size={14} className="spinner" />
) : (
<X size={14} />
)}
{rejecting ? '提交中...' : '确认驳回'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -6,7 +6,7 @@ const STATUS_LABELS: Record<string, string> = {
NEW: '新发现', NEW: '新发现',
VERIFYING: '验证中', VERIFYING: '验证中',
CANNOT_REPRODUCE: '无法复现', CANNOT_REPRODUCE: '无法复现',
PENDING_FIX: '待修复', PENDING_FIX: '等待审核',
FIXING: '修复中', FIXING: '修复中',
FIXED: '已修复', FIXED: '已修复',
VERIFIED: '已验证', VERIFIED: '已验证',
@ -116,9 +116,12 @@ export default function RepairList() {
<td>#{report.id}</td> <td>#{report.id}</td>
<td>{report.project_id}</td> <td>{report.project_id}</td>
<td> <td>
<Link to={`/bugs/${report.error_log_id}`}> {report.error_log_ids?.map((bugId, idx) => (
#{report.error_log_id} <span key={bugId}>
</Link> {idx > 0 && ', '}
<Link to={`/bugs/${bugId}`}>#{bugId}</Link>
</span>
))}
</td> </td>
<td> {report.repair_round} </td> <td> {report.repair_round} </td>
<td>{report.modified_files.length} </td> <td>{report.modified_files.length} </td>
@ -166,7 +169,7 @@ export default function RepairList() {
</div> </div>
<div className="mobile-card-meta"> <div className="mobile-card-meta">
<span>{report.project_id}</span> <span>{report.project_id}</span>
<span> #{report.error_log_id}</span> <span> {report.error_log_ids?.map(id => `#${id}`).join(', ')}</span>
<span> {report.repair_round} </span> <span> {report.repair_round} </span>
</div> </div>
<div className="mobile-card-meta"> <div className="mobile-card-meta">