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)",
# ErrorLog failure_reason
"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
"""INSERT INTO project (project_id, created_at, updated_at)
SELECT DISTINCT e.project_id, NOW(), NOW()

View File

@ -1,7 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
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 .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
from .gitea_client import GiteaClient
@ -135,24 +135,89 @@ async def update_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 ====================
@app.post("/api/v1/repair/reports", tags=["Repair"])
async def create_repair_report(report: RepairTaskCreate, session: AsyncSession = Depends(get_session)):
"""Upload a new repair report"""
# 1. Create repair task record
"""Upload a new repair report (one per batch, may cover multiple bugs)"""
repair_task = RepairTask.from_orm(report)
session.add(repair_task)
# 2. Update error log status and failure_reason
if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED]:
log_stmt = select(ErrorLog).where(ErrorLog.id == report.error_log_id)
results = await session.exec(log_stmt)
error_log = results.first()
if error_log:
error_log.status = report.status
if report.failure_reason and report.status == LogStatus.FIX_FAILED:
error_log.failure_reason = report.failure_reason
session.add(error_log)
# Update all related bugs' status and failure_reason
if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED, LogStatus.PENDING_FIX, LogStatus.FIXING]:
for bug_id in report.error_log_ids:
log_stmt = select(ErrorLog).where(ErrorLog.id == bug_id)
results = await session.exec(log_stmt)
error_log = results.first()
if error_log:
error_log.status = report.status
if report.failure_reason and report.status == LogStatus.FIX_FAILED:
error_log.failure_reason = report.failure_reason
session.add(error_log)
await session.commit()
await session.refresh(repair_task)
@ -172,7 +237,10 @@ async def get_repair_reports(
if project_id:
query = query.where(RepairTask.project_id == project_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
query = query.offset(offset).limit(page_size)
@ -185,7 +253,9 @@ async def get_repair_reports(
if project_id:
count_query = count_query.where(RepairTask.project_id == project_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)
total = count_result.one()
@ -210,6 +280,113 @@ async def get_repair_report_detail(report_id: int, session: AsyncSession = Depen
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 ====================
@app.get("/api/v1/dashboard/stats", tags=["Dashboard"])
@ -364,10 +541,6 @@ async def health_check():
# ==================== PR 操作 ====================
class PRRejectRequest(BaseModel):
"""拒绝 PR 请求"""
reason: str # 拒绝原因
@app.post("/api/v1/bugs/{bug_id}/merge-pr", tags=["PR Operations"])
async def merge_pr(
@ -487,3 +660,124 @@ async def close_pr(
"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)
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: LogStatus = Field(default=LogStatus.NEW)
retry_count: int = Field(default=0)
@ -101,16 +105,16 @@ class TaskStatusUpdate(SQLModel):
message: Optional[str] = None
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)
error_log_id: int = Field(foreign_key="errorlog.id")
error_log_ids: List[int] = Field(sa_column=Column(JSON)) # 关联的 Bug ID 列表
status: LogStatus
project_id: str
# Repair Details
ai_analysis: str = Field(sa_column=Column(Text)) # Analysis from LLM
fix_plan: str = Field(sa_column=Column(Text)) # Proposed fix plan
code_diff: str = Field(sa_column=Column(Text)) # Git diff
ai_analysis: str = Field(sa_column=Column(Text))
fix_plan: str = Field(sa_column=Column(Text))
code_diff: str = Field(sa_column=Column(Text))
modified_files: List[str] = Field(sa_column=Column(JSON))
# Test Results
@ -118,14 +122,19 @@ class RepairTask(SQLModel, table=True):
test_passed: bool
# 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))
# 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)
class RepairTaskCreate(SQLModel):
"""Schema for creating a repair report via API"""
error_log_id: int
error_log_ids: List[int]
status: LogStatus
project_id: str
ai_analysis: str
@ -136,4 +145,7 @@ class RepairTaskCreate(SQLModel):
test_passed: bool
repair_round: int = 1
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]}"
logger.error(f"{failure_reason} (round {round_num})")
self._upload_round_report(
bugs=bugs, project_id=project_id, round_num=round_num,
ai_analysis=output, diff="", modified_files=[],
test_output="", test_passed=False,
failure_reason=failure_reason,
status=BugStatus.FIX_FAILED,
)
for bug in bugs:
self._upload_round_report(
bug=bug, project_id=project_id, round_num=round_num,
ai_analysis=output, diff="", 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
@ -206,23 +206,9 @@ class RepairEngine:
logger.info(f"{round_num} 轮修复完成,修改了 {len(modified_files)} 个文件")
# Step 3: 安全检查(仅在 Git 启用时,不重试
# Step 3: 安全检查(软性警告,不阻断修复流程
if git_manager and not self._safety_check(modified_files, diff):
failure_reason = "安全检查未通过"
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
logger.warning(f"安全检查警告 (round {round_num}):修改涉及核心文件或超出限制,继续修复流程")
# Step 4: 运行 Claude 生成的测试文件
bug_ids_str = "_".join(str(b.id) for b in bugs)
@ -233,42 +219,115 @@ class RepairEngine:
if not test_output:
test_output = "Claude 未生成测试文件"
logger.warning(f"测试文件 {test_file} 不存在,跳过测试验证")
# 没有测试文件时视为通过(无法验证)
test_passed = True
# 清理临时测试文件
cleanup_repair_test_file(project_path, test_file)
for bug in bugs:
self.task_manager.update_status(bug.id, BugStatus.FIXED)
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_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,
))
# Step 5: 测试失败 → 记录本轮结果,进入下一轮重试
if not test_passed:
logger.warning(f"{round_num} 轮测试未通过,{'进入下一轮重试' if round_num < max_rounds else '已达最大轮次'}")
last_test_output = test_output
last_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:
bug_ids = ", ".join([f"#{b.id}" for b in bugs])
git_manager.commit(f"fix: auto repair bugs {bug_ids}")
logger.info("代码已提交")
git_manager.push()
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:
# 兜底:标记为 FIX_FAILED防止死循环可通过 retry 命令重新处理)
@ -357,7 +416,7 @@ class RepairEngine:
"AI 分诊判定:非代码缺陷或无法复现",
)
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=[],
test_output="", test_passed=False,
failure_reason="AI 分诊:无法复现",
@ -399,7 +458,7 @@ class RepairEngine:
def _upload_round_report(
self,
bug: Bug,
bugs: list[Bug],
project_id: str,
round_num: int,
ai_analysis: str,
@ -409,11 +468,14 @@ class RepairEngine:
test_passed: bool,
failure_reason: Optional[str],
status: BugStatus,
pr_url: Optional[str] = None,
pr_number: Optional[int] = None,
branch_name: Optional[str] = None,
):
"""上传某一轮的修复报告"""
"""上传某一轮的修复报告(一次批量修复 = 一条报告)"""
try:
report = RepairReport(
error_log_id=bug.id,
error_log_ids=[b.id for b in bugs],
status=status,
project_id=project_id,
ai_analysis=ai_analysis,
@ -424,6 +486,9 @@ class RepairEngine:
test_passed=test_passed,
repair_round=round_num,
failure_reason=failure_reason,
pr_url=pr_url,
pr_number=pr_number,
branch_name=branch_name,
)
self.task_manager.upload_report(report)
except Exception as e:
@ -515,7 +580,7 @@ class RepairEngine:
if not success:
failure_reason = f"Claude CLI 执行失败: {output[:500]}"
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=[],
test_output="", test_passed=False,
failure_reason=failure_reason, status=BugStatus.FIX_FAILED,
@ -540,7 +605,7 @@ class RepairEngine:
self.task_manager.update_status(bug_id, BugStatus.FIXED)
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,
test_output=test_output,
test_passed=test_passed, failure_reason=None, status=BugStatus.FIXED,

View File

@ -2,7 +2,8 @@
Git Manager - Git 操作管理
"""
import os
from typing import Optional
from typing import Optional, Tuple
import requests
from git import Repo, InvalidGitRepositoryError
from loguru import logger
@ -52,6 +53,37 @@ class GitManager:
except Exception as 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:
"""拉取最新代码(自动切回 main/master 分支)"""
if not self.repo:
@ -214,6 +246,62 @@ class GitManager:
logger.error(f"合并到 main 失败: {e}")
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:
"""重置所有更改"""
if not self.repo:

View File

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

View File

@ -23,9 +23,12 @@ class Settings(BaseSettings):
# Git
git_user_name: str = Field(default="repair-agent", 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")
# GitHub 仓库地址(为空则不执行 git 操作)
# 仓库地址(owner/repo 格式,为空则不执行 git 操作)
github_repo_rtc_backend: str = Field(default="", description="rtc_backend GitHub 仓库地址")
github_repo_rtc_web: str = Field(default="", description="rtc_web 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="上下文")
status: BugStatus = Field(default=BugStatus.NEW, 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="创建时间")
def format_for_prompt(self) -> str:
@ -81,8 +82,8 @@ class BatchFixResult(BaseModel):
class RepairReport(BaseModel):
"""修复报告"""
error_log_id: int
"""修复报告(一次批量修复 = 一条报告)"""
error_log_ids: list[int]
status: BugStatus
project_id: str
ai_analysis: str
@ -93,4 +94,7 @@ class RepairReport(BaseModel):
test_passed: bool
repair_round: int = 1
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;
retry_count: number;
failure_reason: string | null;
// Severity (1-10)
severity?: number | null;
severity_reason?: string | null;
// PR Tracking
pr_number?: number | null;
pr_url?: string | null;
@ -67,7 +70,7 @@ export interface PaginatedResponse<T> {
export interface RepairReport {
id: number;
error_log_id: number;
error_log_ids: number[];
project_id: string;
status: string;
ai_analysis: string;
@ -79,6 +82,9 @@ export interface RepairReport {
created_at: string;
repair_round: number;
failure_reason: string | null;
pr_url?: string | null;
pr_number?: number | null;
branch_name?: string | null;
}
// API Functions
@ -128,4 +134,21 @@ export const mergePR = (bugId: number) =>
export const closePR = (bugId: number, reason: string) =>
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;

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useLocation } from 'react-router-dom';
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle, 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> = {
runtime: '运行时',
@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, string> = {
NEW: '新发现',
VERIFYING: '验证中',
CANNOT_REPRODUCE: '无法复现',
PENDING_FIX: '待修复',
PENDING_FIX: '等待审核',
FIXING: '修复中',
FIXED: '已修复',
VERIFIED: '已验证',
@ -44,6 +44,15 @@ export default function BugDetail() {
const [showRejectModal, setShowRejectModal] = useState(false);
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 || '';
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) {
return (
<div className="loading">
@ -142,7 +209,12 @@ export default function BugDetail() {
const isRuntime = !bug.source || bug.source === 'runtime';
const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime;
const hasPR = !!bug.pr_url;
const canOperatePR = hasPR && bug.status === 'PENDING_FIX';
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 (
<div>
@ -164,13 +236,48 @@ export default function BugDetail() {
<span>{bug.level}</span>
</div>
</div>
<span className={`status-badge status-${bug.status}`}>
{STATUS_LABELS[bug.status] || bug.status}
</span>
<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}`}>
{STATUS_LABELS[bug.status] || bug.status}
</span>
</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-title" style={{ marginBottom: '8px' }}>Pull Request</div>
<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
className="trigger-repair-btn"
onClick={handleTriggerRepair}
@ -300,6 +436,23 @@ export default function BugDetail() {
</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 && (
<span style={{
@ -319,7 +472,25 @@ export default function BugDetail() {
</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)' }}>
{!isRuntime
? 'CI/CD 和部署错误暂不支持自动修复'
@ -452,9 +623,9 @@ export default function BugDetail() {
width: '90%',
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)' }}>
Agent
{hasPR ? '拒绝' : '驳回'}Agent
</p>
<div style={{ marginBottom: '12px' }}>
@ -515,17 +686,17 @@ export default function BugDetail() {
</button>
<button
onClick={handleClosePR}
disabled={closingPR || !rejectReason.trim()}
onClick={hasPR ? handleClosePR : handleRejectFix}
disabled={(hasPR ? closingPR : rejecting) || !rejectReason.trim()}
className="trigger-repair-btn"
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
>
{closingPR ? (
{(hasPR ? closingPR : rejecting) ? (
<Loader2 size={14} className="spinner" />
) : (
<X size={14} />
)}
{closingPR ? '提交中...' : '确认拒绝'}
{(hasPR ? closingPR : rejecting) ? '提交中...' : '确认驳回'}
</button>
</div>
</div>

View File

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

View File

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

View File

@ -1,13 +1,13 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle } from 'lucide-react';
import { getRepairReportDetail, type RepairReport } from '../api';
import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle, Check, X, Loader2, ExternalLink } from 'lucide-react';
import { getRepairReportDetail, approveReport, rejectReport, type RepairReport } from '../api';
const STATUS_LABELS: Record<string, string> = {
NEW: '新发现',
VERIFYING: '验证中',
CANNOT_REPRODUCE: '无法复现',
PENDING_FIX: '待修复',
PENDING_FIX: '等待审核',
FIXING: '修复中',
FIXED: '已修复',
VERIFIED: '已验证',
@ -15,11 +15,25 @@ const STATUS_LABELS: Record<string, string> = {
FIX_FAILED: '修复失败',
};
const REJECT_REASON_TEMPLATES = [
'测试覆盖不足,缺少边界条件测试',
'业务逻辑需要调整',
'代码质量不符合规范',
'需要补充异常处理',
];
export default function RepairDetail() {
const { id } = useParams<{ id: string }>();
const [report, setReport] = useState<RepairReport | null>(null);
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(() => {
if (id) {
const fetchDetail = async () => {
@ -36,6 +50,43 @@ export default function RepairDetail() {
}
}, [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) {
return (
<div className="loading">
@ -48,6 +99,9 @@ export default function RepairDetail() {
return <div className="loading"></div>;
}
const isPendingReview = report.status === 'PENDING_FIX';
const hasPR = !!report.pr_url;
return (
<div>
<Link to="/repairs" className="back-link">
@ -71,8 +125,15 @@ export default function RepairDetail() {
<strong>{report.project_id}</strong>
</div>
<div className="info-row">
<span></span>
<Link to={`/bugs/${report.error_log_id}`}>#{report.error_log_id}</Link>
<span></span>
<span>
{report.error_log_ids?.map((bugId, idx) => (
<span key={bugId}>
{idx > 0 && ', '}
<Link to={`/bugs/${bugId}`}>#{bugId}</Link>
</span>
))}
</span>
</div>
<div className="info-row">
<span></span>
@ -90,6 +151,67 @@ export default function RepairDetail() {
</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 && (
<div className="card" style={{ borderLeft: '3px solid var(--error)' }}>
<h2><AlertTriangle size={16} /> </h2>
@ -111,6 +233,108 @@ export default function RepairDetail() {
<h2><FlaskConical size={16} /> </h2>
<pre className="code-block neutral">{report.test_output}</pre>
</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>
);
}

View File

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