This commit is contained in:
parent
5611839fd8
commit
b178d24e73
@ -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()
|
||||
|
||||
332
app/main.py
332
app/main.py
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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
74
create_test_bugs.sql
Normal 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
131
insert_new_bugs.py
Normal 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
110
insert_test_bugs.py
Normal 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())
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)} 个待修复 Bug(runtime + 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:
|
||||
|
||||
@ -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 仓库地址")
|
||||
|
||||
@ -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
55
test_bugs.sql
Normal 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);
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: '已验证',
|
||||
|
||||
@ -12,7 +12,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
NEW: '新发现',
|
||||
VERIFYING: '验证中',
|
||||
CANNOT_REPRODUCE: '无法复现',
|
||||
PENDING_FIX: '待修复',
|
||||
PENDING_FIX: '等待审核',
|
||||
FIXING: '修复中',
|
||||
FIXED: '已修复',
|
||||
VERIFIED: '已验证',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user