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)",
|
"CREATE INDEX IF NOT EXISTS ix_errorlog_source ON errorlog (source)",
|
||||||
# ErrorLog failure_reason
|
# ErrorLog failure_reason
|
||||||
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS failure_reason TEXT",
|
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS failure_reason TEXT",
|
||||||
|
# Bug severity (1-10 AI评估等级)
|
||||||
|
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity INTEGER",
|
||||||
|
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity_reason TEXT",
|
||||||
# Seed Project table from existing ErrorLog data
|
# Seed Project table from existing ErrorLog data
|
||||||
"""INSERT INTO project (project_id, created_at, updated_at)
|
"""INSERT INTO project (project_id, created_at, updated_at)
|
||||||
SELECT DISTINCT e.project_id, NOW(), NOW()
|
SELECT DISTINCT e.project_id, NOW(), NOW()
|
||||||
|
|||||||
332
app/main.py
332
app/main.py
@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException, Query
|
from fastapi import FastAPI, Depends, HTTPException, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sqlmodel import select, func
|
from sqlmodel import select, func, text
|
||||||
from .database import init_db, get_session
|
from .database import init_db, get_session
|
||||||
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
|
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
|
||||||
from .gitea_client import GiteaClient
|
from .gitea_client import GiteaClient
|
||||||
@ -135,24 +135,89 @@ async def update_task_status(
|
|||||||
return {"message": "Status updated", "id": task.id, "status": task.status}
|
return {"message": "Status updated", "id": task.id, "status": task.status}
|
||||||
|
|
||||||
|
|
||||||
|
class PRInfoUpdate(BaseModel):
|
||||||
|
pr_number: int
|
||||||
|
pr_url: str
|
||||||
|
branch_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SeverityUpdate(BaseModel):
|
||||||
|
severity: int
|
||||||
|
severity_reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/bugs/{bug_id}/pr-info", tags=["Tasks"])
|
||||||
|
async def update_bug_pr_info(
|
||||||
|
bug_id: int,
|
||||||
|
pr_info: PRInfoUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""repair agent 创建 PR 后回写 PR 信息"""
|
||||||
|
statement = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
bug = results.first()
|
||||||
|
|
||||||
|
if not bug:
|
||||||
|
raise HTTPException(status_code=404, detail="Bug not found")
|
||||||
|
|
||||||
|
bug.pr_number = pr_info.pr_number
|
||||||
|
bug.pr_url = pr_info.pr_url
|
||||||
|
bug.branch_name = pr_info.branch_name
|
||||||
|
|
||||||
|
session.add(bug)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(bug)
|
||||||
|
|
||||||
|
return {"message": "PR info updated", "bug_id": bug.id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/bugs/{bug_id}/severity", tags=["Tasks"])
|
||||||
|
async def update_bug_severity(
|
||||||
|
bug_id: int,
|
||||||
|
data: SeverityUpdate,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""repair agent 评估后回写 Bug 严重等级"""
|
||||||
|
statement = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
bug = results.first()
|
||||||
|
|
||||||
|
if not bug:
|
||||||
|
raise HTTPException(status_code=404, detail="Bug not found")
|
||||||
|
|
||||||
|
bug.severity = data.severity
|
||||||
|
bug.severity_reason = data.severity_reason
|
||||||
|
|
||||||
|
session.add(bug)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(bug)
|
||||||
|
|
||||||
|
return {"message": "Severity updated", "bug_id": bug.id, "severity": bug.severity}
|
||||||
|
|
||||||
|
|
||||||
|
class PRRejectRequest(BaseModel):
|
||||||
|
"""拒绝/驳回请求"""
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
# ==================== Repair Reports ====================
|
# ==================== Repair Reports ====================
|
||||||
@app.post("/api/v1/repair/reports", tags=["Repair"])
|
@app.post("/api/v1/repair/reports", tags=["Repair"])
|
||||||
async def create_repair_report(report: RepairTaskCreate, session: AsyncSession = Depends(get_session)):
|
async def create_repair_report(report: RepairTaskCreate, session: AsyncSession = Depends(get_session)):
|
||||||
"""Upload a new repair report"""
|
"""Upload a new repair report (one per batch, may cover multiple bugs)"""
|
||||||
# 1. Create repair task record
|
|
||||||
repair_task = RepairTask.from_orm(report)
|
repair_task = RepairTask.from_orm(report)
|
||||||
session.add(repair_task)
|
session.add(repair_task)
|
||||||
|
|
||||||
# 2. Update error log status and failure_reason
|
# Update all related bugs' status and failure_reason
|
||||||
if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED]:
|
if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED, LogStatus.PENDING_FIX, LogStatus.FIXING]:
|
||||||
log_stmt = select(ErrorLog).where(ErrorLog.id == report.error_log_id)
|
for bug_id in report.error_log_ids:
|
||||||
results = await session.exec(log_stmt)
|
log_stmt = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||||
error_log = results.first()
|
results = await session.exec(log_stmt)
|
||||||
if error_log:
|
error_log = results.first()
|
||||||
error_log.status = report.status
|
if error_log:
|
||||||
if report.failure_reason and report.status == LogStatus.FIX_FAILED:
|
error_log.status = report.status
|
||||||
error_log.failure_reason = report.failure_reason
|
if report.failure_reason and report.status == LogStatus.FIX_FAILED:
|
||||||
session.add(error_log)
|
error_log.failure_reason = report.failure_reason
|
||||||
|
session.add(error_log)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(repair_task)
|
await session.refresh(repair_task)
|
||||||
@ -172,7 +237,10 @@ async def get_repair_reports(
|
|||||||
if project_id:
|
if project_id:
|
||||||
query = query.where(RepairTask.project_id == project_id)
|
query = query.where(RepairTask.project_id == project_id)
|
||||||
if error_log_id:
|
if error_log_id:
|
||||||
query = query.where(RepairTask.error_log_id == error_log_id)
|
# PostgreSQL JSONB contains: error_log_ids @> '[28]'
|
||||||
|
query = query.where(
|
||||||
|
text(f"error_log_ids @> '{json.dumps([error_log_id])}'::jsonb")
|
||||||
|
)
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
query = query.offset(offset).limit(page_size)
|
query = query.offset(offset).limit(page_size)
|
||||||
@ -185,7 +253,9 @@ async def get_repair_reports(
|
|||||||
if project_id:
|
if project_id:
|
||||||
count_query = count_query.where(RepairTask.project_id == project_id)
|
count_query = count_query.where(RepairTask.project_id == project_id)
|
||||||
if error_log_id:
|
if error_log_id:
|
||||||
count_query = count_query.where(RepairTask.error_log_id == error_log_id)
|
count_query = count_query.where(
|
||||||
|
text(f"error_log_ids @> '{json.dumps([error_log_id])}'::jsonb")
|
||||||
|
)
|
||||||
count_result = await session.exec(count_query)
|
count_result = await session.exec(count_query)
|
||||||
total = count_result.one()
|
total = count_result.one()
|
||||||
|
|
||||||
@ -210,6 +280,113 @@ async def get_repair_report_detail(report_id: int, session: AsyncSession = Depen
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/repair/reports/{report_id}/approve", tags=["Repair"])
|
||||||
|
async def approve_report(report_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
"""
|
||||||
|
批准修复报告:合并 PR(如有)并将所有关联 Bug 标记为 FIXED
|
||||||
|
"""
|
||||||
|
statement = select(RepairTask).where(RepairTask.id == report_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
report = results.first()
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(status_code=404, detail="Report not found")
|
||||||
|
|
||||||
|
if report.status != LogStatus.PENDING_FIX:
|
||||||
|
raise HTTPException(status_code=400, detail=f"报告状态不是等待审核: {report.status}")
|
||||||
|
|
||||||
|
# 如有 PR,调用 Gitea API 合并
|
||||||
|
if report.pr_url:
|
||||||
|
gitea_client = GiteaClient()
|
||||||
|
success, message = gitea_client.merge_pr_by_url(report.pr_url)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail=f"合并 PR 失败: {message}")
|
||||||
|
|
||||||
|
# 更新报告状态
|
||||||
|
report.status = LogStatus.FIXED
|
||||||
|
session.add(report)
|
||||||
|
|
||||||
|
# 更新所有关联 Bug 状态
|
||||||
|
updated_ids = []
|
||||||
|
for bug_id in (report.error_log_ids or []):
|
||||||
|
bug_stmt = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||||
|
bug_results = await session.exec(bug_stmt)
|
||||||
|
bug = bug_results.first()
|
||||||
|
if bug:
|
||||||
|
bug.status = LogStatus.FIXED
|
||||||
|
bug.merged_at = datetime.utcnow()
|
||||||
|
session.add(bug)
|
||||||
|
updated_ids.append(bug_id)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"修复已批准,{len(updated_ids)} 个缺陷已更新",
|
||||||
|
"report_id": report_id,
|
||||||
|
"updated_bug_ids": updated_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/repair/reports/{report_id}/reject", tags=["Repair"])
|
||||||
|
async def reject_report(
|
||||||
|
report_id: int,
|
||||||
|
request: PRRejectRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
驳回修复报告:关闭 PR(如有)并将所有关联 Bug 重置为 NEW
|
||||||
|
"""
|
||||||
|
statement = select(RepairTask).where(RepairTask.id == report_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
report = results.first()
|
||||||
|
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(status_code=404, detail="Report not found")
|
||||||
|
|
||||||
|
if report.status != LogStatus.PENDING_FIX:
|
||||||
|
raise HTTPException(status_code=400, detail=f"报告状态不是等待审核: {report.status}")
|
||||||
|
|
||||||
|
# 如有 PR,关闭
|
||||||
|
if report.pr_url:
|
||||||
|
gitea_client = GiteaClient()
|
||||||
|
success, message = gitea_client.close_pr_by_url(report.pr_url, request.reason)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail=f"关闭 PR 失败: {message}")
|
||||||
|
|
||||||
|
# 更新报告状态
|
||||||
|
report.status = LogStatus.FIX_FAILED
|
||||||
|
report.failure_reason = f"人工驳回: {request.reason}"
|
||||||
|
session.add(report)
|
||||||
|
|
||||||
|
# 重置所有关联 Bug 为 NEW
|
||||||
|
updated_ids = []
|
||||||
|
rejection_info = json.dumps({
|
||||||
|
"rejected_at": datetime.utcnow().isoformat(),
|
||||||
|
"reason": request.reason,
|
||||||
|
"report_id": report_id,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
for bug_id in (report.error_log_ids or []):
|
||||||
|
bug_stmt = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||||
|
bug_results = await session.exec(bug_stmt)
|
||||||
|
bug = bug_results.first()
|
||||||
|
if bug:
|
||||||
|
bug.status = LogStatus.NEW
|
||||||
|
bug.rejection_count = (bug.rejection_count or 0) + 1
|
||||||
|
bug.last_rejected_at = datetime.utcnow()
|
||||||
|
bug.rejection_reason = rejection_info
|
||||||
|
session.add(bug)
|
||||||
|
updated_ids.append(bug_id)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"修复已驳回,{len(updated_ids)} 个缺陷将重新修复",
|
||||||
|
"report_id": report_id,
|
||||||
|
"updated_bug_ids": updated_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Dashboard APIs ====================
|
# ==================== Dashboard APIs ====================
|
||||||
@app.get("/api/v1/dashboard/stats", tags=["Dashboard"])
|
@app.get("/api/v1/dashboard/stats", tags=["Dashboard"])
|
||||||
@ -364,10 +541,6 @@ async def health_check():
|
|||||||
|
|
||||||
# ==================== PR 操作 ====================
|
# ==================== PR 操作 ====================
|
||||||
|
|
||||||
class PRRejectRequest(BaseModel):
|
|
||||||
"""拒绝 PR 请求"""
|
|
||||||
reason: str # 拒绝原因
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/bugs/{bug_id}/merge-pr", tags=["PR Operations"])
|
@app.post("/api/v1/bugs/{bug_id}/merge-pr", tags=["PR Operations"])
|
||||||
async def merge_pr(
|
async def merge_pr(
|
||||||
@ -487,3 +660,124 @@ async def close_pr(
|
|||||||
"new_status": bug.status,
|
"new_status": bug.status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/bugs/{bug_id}/retry", tags=["PR Operations"])
|
||||||
|
async def retry_fix(
|
||||||
|
bug_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
重新尝试修复失败的 Bug
|
||||||
|
|
||||||
|
将 FIX_FAILED 状态的 Bug 重置为 NEW,让 repair agent 重新扫描
|
||||||
|
"""
|
||||||
|
statement = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
bug = results.first()
|
||||||
|
|
||||||
|
if not bug:
|
||||||
|
raise HTTPException(status_code=404, detail="Bug not found")
|
||||||
|
|
||||||
|
if bug.status != LogStatus.FIX_FAILED:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"只能重试修复失败的 Bug,当前状态: {bug.status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 重置状态为 NEW
|
||||||
|
bug.status = LogStatus.NEW
|
||||||
|
# 清除失败原因,让 agent 重新分析
|
||||||
|
bug.failure_reason = None
|
||||||
|
|
||||||
|
session.add(bug)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(bug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Bug 已重置为新发现状态,repair agent 将重新扫描修复",
|
||||||
|
"bug_id": bug.id,
|
||||||
|
"new_status": bug.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/bugs/{bug_id}/approve-fix", tags=["PR Operations"])
|
||||||
|
async def approve_fix(
|
||||||
|
bug_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
人工确认修复(不依赖 PR)
|
||||||
|
|
||||||
|
用于 PENDING_FIX 状态但没有关联 PR 的 Bug,人工审核后直接标记为 FIXED。
|
||||||
|
"""
|
||||||
|
statement = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
bug = results.first()
|
||||||
|
|
||||||
|
if not bug:
|
||||||
|
raise HTTPException(status_code=404, detail="Bug not found")
|
||||||
|
|
||||||
|
if bug.status != LogStatus.PENDING_FIX:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"只能确认等待审核的 Bug,当前状态: {bug.status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
bug.status = LogStatus.FIXED
|
||||||
|
bug.merged_at = datetime.utcnow()
|
||||||
|
session.add(bug)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(bug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "修复已确认",
|
||||||
|
"bug_id": bug.id,
|
||||||
|
"new_status": bug.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/bugs/{bug_id}/reject-fix", tags=["PR Operations"])
|
||||||
|
async def reject_fix(
|
||||||
|
bug_id: int,
|
||||||
|
request: PRRejectRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
人工驳回修复(不依赖 PR)
|
||||||
|
|
||||||
|
用于 PENDING_FIX 状态但没有关联 PR 的 Bug,驳回后重置为 NEW 让 agent 重新修复。
|
||||||
|
"""
|
||||||
|
statement = select(ErrorLog).where(ErrorLog.id == bug_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
bug = results.first()
|
||||||
|
|
||||||
|
if not bug:
|
||||||
|
raise HTTPException(status_code=404, detail="Bug not found")
|
||||||
|
|
||||||
|
if bug.status != LogStatus.PENDING_FIX:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"只能驳回等待审核的 Bug,当前状态: {bug.status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
bug.status = LogStatus.NEW
|
||||||
|
bug.rejection_count = (bug.rejection_count or 0) + 1
|
||||||
|
bug.last_rejected_at = datetime.utcnow()
|
||||||
|
|
||||||
|
rejection_info = {
|
||||||
|
"rejected_at": datetime.utcnow().isoformat(),
|
||||||
|
"reason": request.reason,
|
||||||
|
}
|
||||||
|
bug.rejection_reason = json.dumps(rejection_info, ensure_ascii=False)
|
||||||
|
|
||||||
|
session.add(bug)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(bug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "修复已驳回,Bug 将重新修复",
|
||||||
|
"rejection_count": bug.rejection_count,
|
||||||
|
"bug_id": bug.id,
|
||||||
|
"new_status": bug.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,10 @@ class ErrorLog(SQLModel, table=True):
|
|||||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||||
fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number
|
fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number
|
||||||
|
|
||||||
|
# Severity (1-10, AI 评估的严重等级,8+ 需人工审核)
|
||||||
|
severity: Optional[int] = Field(default=None)
|
||||||
|
severity_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
|
||||||
# Status Tracking
|
# Status Tracking
|
||||||
status: LogStatus = Field(default=LogStatus.NEW)
|
status: LogStatus = Field(default=LogStatus.NEW)
|
||||||
retry_count: int = Field(default=0)
|
retry_count: int = Field(default=0)
|
||||||
@ -101,16 +105,16 @@ class TaskStatusUpdate(SQLModel):
|
|||||||
message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
|
|
||||||
class RepairTask(SQLModel, table=True):
|
class RepairTask(SQLModel, table=True):
|
||||||
"""Record of a repair attempt"""
|
"""Record of a repair attempt (one per batch, may cover multiple bugs)"""
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
error_log_id: int = Field(foreign_key="errorlog.id")
|
error_log_ids: List[int] = Field(sa_column=Column(JSON)) # 关联的 Bug ID 列表
|
||||||
status: LogStatus
|
status: LogStatus
|
||||||
project_id: str
|
project_id: str
|
||||||
|
|
||||||
# Repair Details
|
# Repair Details
|
||||||
ai_analysis: str = Field(sa_column=Column(Text)) # Analysis from LLM
|
ai_analysis: str = Field(sa_column=Column(Text))
|
||||||
fix_plan: str = Field(sa_column=Column(Text)) # Proposed fix plan
|
fix_plan: str = Field(sa_column=Column(Text))
|
||||||
code_diff: str = Field(sa_column=Column(Text)) # Git diff
|
code_diff: str = Field(sa_column=Column(Text))
|
||||||
modified_files: List[str] = Field(sa_column=Column(JSON))
|
modified_files: List[str] = Field(sa_column=Column(JSON))
|
||||||
|
|
||||||
# Test Results
|
# Test Results
|
||||||
@ -118,14 +122,19 @@ class RepairTask(SQLModel, table=True):
|
|||||||
test_passed: bool
|
test_passed: bool
|
||||||
|
|
||||||
# Repair Round Tracking
|
# Repair Round Tracking
|
||||||
repair_round: int = Field(default=1) # Which round (1, 2, 3...)
|
repair_round: int = Field(default=1)
|
||||||
failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||||
|
|
||||||
|
# PR Info
|
||||||
|
pr_url: Optional[str] = Field(default=None)
|
||||||
|
pr_number: Optional[int] = Field(default=None)
|
||||||
|
branch_name: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
class RepairTaskCreate(SQLModel):
|
class RepairTaskCreate(SQLModel):
|
||||||
"""Schema for creating a repair report via API"""
|
"""Schema for creating a repair report via API"""
|
||||||
error_log_id: int
|
error_log_ids: List[int]
|
||||||
status: LogStatus
|
status: LogStatus
|
||||||
project_id: str
|
project_id: str
|
||||||
ai_analysis: str
|
ai_analysis: str
|
||||||
@ -136,4 +145,7 @@ class RepairTaskCreate(SQLModel):
|
|||||||
test_passed: bool
|
test_passed: bool
|
||||||
repair_round: int = 1
|
repair_round: int = 1
|
||||||
failure_reason: Optional[str] = None
|
failure_reason: Optional[str] = None
|
||||||
|
pr_url: Optional[str] = None
|
||||||
|
pr_number: Optional[int] = None
|
||||||
|
branch_name: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
74
create_test_bugs.sql
Normal file
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]}"
|
failure_reason = f"Claude CLI 执行失败: {output[:500]}"
|
||||||
logger.error(f"{failure_reason} (round {round_num})")
|
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:
|
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)
|
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason)
|
||||||
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
|
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
|
||||||
break
|
break
|
||||||
@ -206,23 +206,9 @@ class RepairEngine:
|
|||||||
|
|
||||||
logger.info(f"第 {round_num} 轮修复完成,修改了 {len(modified_files)} 个文件")
|
logger.info(f"第 {round_num} 轮修复完成,修改了 {len(modified_files)} 个文件")
|
||||||
|
|
||||||
# Step 3: 安全检查(仅在 Git 启用时,不重试)
|
# Step 3: 安全检查(软性警告,不阻断修复流程)
|
||||||
if git_manager and not self._safety_check(modified_files, diff):
|
if git_manager and not self._safety_check(modified_files, diff):
|
||||||
failure_reason = "安全检查未通过"
|
logger.warning(f"安全检查警告 (round {round_num}):修改涉及核心文件或超出限制,继续修复流程")
|
||||||
logger.warning(f"{failure_reason} (round {round_num})")
|
|
||||||
git_manager.reset_hard()
|
|
||||||
|
|
||||||
for bug in bugs:
|
|
||||||
self._upload_round_report(
|
|
||||||
bug=bug, project_id=project_id, round_num=round_num,
|
|
||||||
ai_analysis=output, diff=diff, modified_files=modified_files,
|
|
||||||
test_output="", test_passed=False,
|
|
||||||
failure_reason=failure_reason,
|
|
||||||
status=BugStatus.FIX_FAILED,
|
|
||||||
)
|
|
||||||
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason)
|
|
||||||
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
|
|
||||||
break
|
|
||||||
|
|
||||||
# Step 4: 运行 Claude 生成的测试文件
|
# Step 4: 运行 Claude 生成的测试文件
|
||||||
bug_ids_str = "_".join(str(b.id) for b in bugs)
|
bug_ids_str = "_".join(str(b.id) for b in bugs)
|
||||||
@ -233,42 +219,115 @@ class RepairEngine:
|
|||||||
if not test_output:
|
if not test_output:
|
||||||
test_output = "Claude 未生成测试文件"
|
test_output = "Claude 未生成测试文件"
|
||||||
logger.warning(f"测试文件 {test_file} 不存在,跳过测试验证")
|
logger.warning(f"测试文件 {test_file} 不存在,跳过测试验证")
|
||||||
|
# 没有测试文件时视为通过(无法验证)
|
||||||
|
test_passed = True
|
||||||
|
|
||||||
# 清理临时测试文件
|
# 清理临时测试文件
|
||||||
cleanup_repair_test_file(project_path, test_file)
|
cleanup_repair_test_file(project_path, test_file)
|
||||||
|
|
||||||
for bug in bugs:
|
# Step 5: 测试失败 → 记录本轮结果,进入下一轮重试
|
||||||
self.task_manager.update_status(bug.id, BugStatus.FIXED)
|
if not test_passed:
|
||||||
self._upload_round_report(
|
logger.warning(f"第 {round_num} 轮测试未通过,{'进入下一轮重试' if round_num < max_rounds else '已达最大轮次'}")
|
||||||
bug=bug, project_id=project_id, round_num=round_num,
|
last_test_output = test_output
|
||||||
ai_analysis=output, diff=diff, modified_files=modified_files,
|
last_diff = diff
|
||||||
test_output=test_output,
|
|
||||||
test_passed=test_passed,
|
|
||||||
failure_reason=None,
|
|
||||||
status=BugStatus.FIXED,
|
|
||||||
)
|
|
||||||
results.append(FixResult(
|
|
||||||
bug_id=bug.id, success=True,
|
|
||||||
message=f"修复成功 (第 {round_num} 轮)",
|
|
||||||
modified_files=modified_files, diff=diff,
|
|
||||||
))
|
|
||||||
|
|
||||||
# 自动提交、合并到 main 并推送(仅在 Git 启用时)
|
self._upload_round_report(
|
||||||
|
bugs=bugs, project_id=project_id, round_num=round_num,
|
||||||
|
ai_analysis=output, diff=diff, modified_files=modified_files,
|
||||||
|
test_output=test_output, test_passed=False,
|
||||||
|
failure_reason=f"第 {round_num} 轮测试失败",
|
||||||
|
status=BugStatus.FIXING,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 重置代码变更,让下一轮从干净状态开始
|
||||||
|
if git_manager:
|
||||||
|
git_manager.reset_hard()
|
||||||
|
|
||||||
|
# 最后一轮还失败 → 标记为 FIX_FAILED
|
||||||
|
if round_num == max_rounds:
|
||||||
|
failure_reason = f"经过 {max_rounds} 轮修复,测试仍未通过"
|
||||||
|
for bug in bugs:
|
||||||
|
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason)
|
||||||
|
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
|
||||||
|
|
||||||
|
continue # 进入下一轮
|
||||||
|
|
||||||
|
# Step 6: 判断是否需要强制 PR 审核(severity >= 8 的 bug 必须走 PR)
|
||||||
|
max_severity = max((b.severity or 0) for b in bugs)
|
||||||
|
|
||||||
|
needs_pr_review = max_severity >= 8
|
||||||
|
if needs_pr_review:
|
||||||
|
logger.info(f"批次中存在高严重等级 bug (max severity={max_severity}),强制走 PR 审核流程")
|
||||||
|
|
||||||
|
# Step 7: 测试通过 → 提交代码并创建 PR
|
||||||
|
pr_info = None
|
||||||
if git_enabled and auto_commit and modified_files and git_manager:
|
if git_enabled and auto_commit and modified_files and git_manager:
|
||||||
bug_ids = ", ".join([f"#{b.id}" for b in bugs])
|
bug_ids = ", ".join([f"#{b.id}" for b in bugs])
|
||||||
git_manager.commit(f"fix: auto repair bugs {bug_ids}")
|
git_manager.commit(f"fix: auto repair bugs {bug_ids}")
|
||||||
logger.info("代码已提交")
|
logger.info("代码已提交")
|
||||||
git_manager.push()
|
git_manager.push()
|
||||||
logger.info("fix 分支已推送")
|
logger.info("fix 分支已推送")
|
||||||
# 合并 fix 分支到 main 并推送,触发 CI/CD
|
|
||||||
if git_manager.merge_to_main_and_push():
|
|
||||||
logger.info("已合并到 main 并推送")
|
|
||||||
else:
|
|
||||||
logger.warning("合并到 main 失败,请手动合并")
|
|
||||||
elif not git_enabled and auto_commit:
|
|
||||||
logger.info("未配置 GitHub 仓库,跳过自动提交")
|
|
||||||
|
|
||||||
break # 成功,退出循环
|
# 创建 PR
|
||||||
|
bug_summary = "\n".join([
|
||||||
|
f"- #{b.id}: {b.error.type} - {b.error.message[:80]}"
|
||||||
|
for b in bugs
|
||||||
|
])
|
||||||
|
severity_note = f"\n\n⚠️ **包含高严重等级 Bug (max={max_severity}/10),需人工审核**" if needs_pr_review else ""
|
||||||
|
pr_title = f"fix: auto repair bugs {bug_ids}"
|
||||||
|
pr_body = f"## 自动修复报告\n\n修复的 Bug:\n{bug_summary}{severity_note}\n\n修改文件:\n" + \
|
||||||
|
"\n".join([f"- `{f}`" for f in modified_files])
|
||||||
|
|
||||||
|
success, pr_info = git_manager.create_pr(pr_title, pr_body)
|
||||||
|
if success and pr_info:
|
||||||
|
logger.info(f"PR 创建成功: {pr_info['pr_url']}")
|
||||||
|
else:
|
||||||
|
logger.warning("创建 PR 失败,修复已提交到 fix 分支")
|
||||||
|
elif not git_enabled and auto_commit:
|
||||||
|
logger.info("未配置仓库地址,跳过自动提交")
|
||||||
|
|
||||||
|
# 根据是否有 PR 或是否需要强制审核来决定 Bug 状态
|
||||||
|
# 高严重等级 bug:即使 PR 创建失败也不能直接标记 FIXED,保持 PENDING_FIX 等待人工处理
|
||||||
|
if pr_info:
|
||||||
|
final_status = BugStatus.PENDING_FIX
|
||||||
|
elif needs_pr_review:
|
||||||
|
final_status = BugStatus.PENDING_FIX
|
||||||
|
logger.warning("高严重等级 bug PR 创建失败,状态设为 PENDING_FIX 等待人工处理")
|
||||||
|
else:
|
||||||
|
final_status = BugStatus.FIXED
|
||||||
|
|
||||||
|
# 上传一条批量修复报告(含 PR 信息)
|
||||||
|
self._upload_round_report(
|
||||||
|
bugs=bugs, project_id=project_id, round_num=round_num,
|
||||||
|
ai_analysis=output, diff=diff, modified_files=modified_files,
|
||||||
|
test_output=test_output,
|
||||||
|
test_passed=True,
|
||||||
|
failure_reason=None,
|
||||||
|
status=final_status,
|
||||||
|
pr_url=pr_info["pr_url"] if pr_info else None,
|
||||||
|
pr_number=pr_info["pr_number"] if pr_info else None,
|
||||||
|
branch_name=pr_info["branch_name"] if pr_info else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
for bug in bugs:
|
||||||
|
self.task_manager.update_status(bug.id, final_status)
|
||||||
|
|
||||||
|
# 回写 PR 信息到 Bug
|
||||||
|
if pr_info:
|
||||||
|
self.task_manager.update_pr_info(
|
||||||
|
bug.id,
|
||||||
|
pr_info["pr_number"],
|
||||||
|
pr_info["pr_url"],
|
||||||
|
pr_info["branch_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(FixResult(
|
||||||
|
bug_id=bug.id, success=True,
|
||||||
|
message=f"修复成功 (第 {round_num} 轮)" + (f", PR #{pr_info['pr_number']}" if pr_info else ""),
|
||||||
|
modified_files=modified_files, diff=diff,
|
||||||
|
))
|
||||||
|
|
||||||
|
break # 测试通过,退出循环
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 兜底:标记为 FIX_FAILED,防止死循环(可通过 retry 命令重新处理)
|
# 兜底:标记为 FIX_FAILED,防止死循环(可通过 retry 命令重新处理)
|
||||||
@ -357,7 +416,7 @@ class RepairEngine:
|
|||||||
"AI 分诊判定:非代码缺陷或无法复现",
|
"AI 分诊判定:非代码缺陷或无法复现",
|
||||||
)
|
)
|
||||||
self._upload_round_report(
|
self._upload_round_report(
|
||||||
bug=bug, project_id=bug.project_id, round_num=0,
|
bugs=[bug], project_id=bug.project_id, round_num=0,
|
||||||
ai_analysis=output, diff="", modified_files=[],
|
ai_analysis=output, diff="", modified_files=[],
|
||||||
test_output="", test_passed=False,
|
test_output="", test_passed=False,
|
||||||
failure_reason="AI 分诊:无法复现",
|
failure_reason="AI 分诊:无法复现",
|
||||||
@ -399,7 +458,7 @@ class RepairEngine:
|
|||||||
|
|
||||||
def _upload_round_report(
|
def _upload_round_report(
|
||||||
self,
|
self,
|
||||||
bug: Bug,
|
bugs: list[Bug],
|
||||||
project_id: str,
|
project_id: str,
|
||||||
round_num: int,
|
round_num: int,
|
||||||
ai_analysis: str,
|
ai_analysis: str,
|
||||||
@ -409,11 +468,14 @@ class RepairEngine:
|
|||||||
test_passed: bool,
|
test_passed: bool,
|
||||||
failure_reason: Optional[str],
|
failure_reason: Optional[str],
|
||||||
status: BugStatus,
|
status: BugStatus,
|
||||||
|
pr_url: Optional[str] = None,
|
||||||
|
pr_number: Optional[int] = None,
|
||||||
|
branch_name: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""上传某一轮的修复报告"""
|
"""上传某一轮的修复报告(一次批量修复 = 一条报告)"""
|
||||||
try:
|
try:
|
||||||
report = RepairReport(
|
report = RepairReport(
|
||||||
error_log_id=bug.id,
|
error_log_ids=[b.id for b in bugs],
|
||||||
status=status,
|
status=status,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
ai_analysis=ai_analysis,
|
ai_analysis=ai_analysis,
|
||||||
@ -424,6 +486,9 @@ class RepairEngine:
|
|||||||
test_passed=test_passed,
|
test_passed=test_passed,
|
||||||
repair_round=round_num,
|
repair_round=round_num,
|
||||||
failure_reason=failure_reason,
|
failure_reason=failure_reason,
|
||||||
|
pr_url=pr_url,
|
||||||
|
pr_number=pr_number,
|
||||||
|
branch_name=branch_name,
|
||||||
)
|
)
|
||||||
self.task_manager.upload_report(report)
|
self.task_manager.upload_report(report)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -515,7 +580,7 @@ class RepairEngine:
|
|||||||
if not success:
|
if not success:
|
||||||
failure_reason = f"Claude CLI 执行失败: {output[:500]}"
|
failure_reason = f"Claude CLI 执行失败: {output[:500]}"
|
||||||
self._upload_round_report(
|
self._upload_round_report(
|
||||||
bug=bug, project_id=bug.project_id, round_num=round_num,
|
bugs=[bug], project_id=bug.project_id, round_num=round_num,
|
||||||
ai_analysis=output, diff="", modified_files=[],
|
ai_analysis=output, diff="", modified_files=[],
|
||||||
test_output="", test_passed=False,
|
test_output="", test_passed=False,
|
||||||
failure_reason=failure_reason, status=BugStatus.FIX_FAILED,
|
failure_reason=failure_reason, status=BugStatus.FIX_FAILED,
|
||||||
@ -540,7 +605,7 @@ class RepairEngine:
|
|||||||
|
|
||||||
self.task_manager.update_status(bug_id, BugStatus.FIXED)
|
self.task_manager.update_status(bug_id, BugStatus.FIXED)
|
||||||
self._upload_round_report(
|
self._upload_round_report(
|
||||||
bug=bug, project_id=bug.project_id, round_num=round_num,
|
bugs=[bug], project_id=bug.project_id, round_num=round_num,
|
||||||
ai_analysis=output, diff=diff, modified_files=modified_files,
|
ai_analysis=output, diff=diff, modified_files=modified_files,
|
||||||
test_output=test_output,
|
test_output=test_output,
|
||||||
test_passed=test_passed, failure_reason=None, status=BugStatus.FIXED,
|
test_passed=test_passed, failure_reason=None, status=BugStatus.FIXED,
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
Git Manager - Git 操作管理
|
Git Manager - Git 操作管理
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
|
import requests
|
||||||
from git import Repo, InvalidGitRepositoryError
|
from git import Repo, InvalidGitRepositoryError
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@ -52,6 +53,37 @@ class GitManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"配置 remote 失败: {e}")
|
logger.error(f"配置 remote 失败: {e}")
|
||||||
|
|
||||||
|
def _parse_owner_repo(self) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
从 github_repo 解析出 owner 和 repo 名称。
|
||||||
|
支持格式:
|
||||||
|
- "owner/repo"
|
||||||
|
- "https://gitea.airlabs.art/owner/repo.git"
|
||||||
|
- "https://gitea.airlabs.art/owner/repo"
|
||||||
|
- "git@gitea.airlabs.art:owner/repo.git"
|
||||||
|
"""
|
||||||
|
raw = self.github_repo.strip()
|
||||||
|
# 去掉 .git 后缀
|
||||||
|
if raw.endswith(".git"):
|
||||||
|
raw = raw[:-4]
|
||||||
|
# HTTP(S) URL
|
||||||
|
if raw.startswith("http://") or raw.startswith("https://"):
|
||||||
|
parts = raw.rstrip("/").split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return parts[-2], parts[-1]
|
||||||
|
# SSH 格式 git@host:owner/repo
|
||||||
|
elif ":" in raw and "@" in raw:
|
||||||
|
path = raw.split(":")[-1]
|
||||||
|
segments = path.split("/")
|
||||||
|
if len(segments) == 2:
|
||||||
|
return segments[0], segments[1]
|
||||||
|
# 简单 owner/repo 格式
|
||||||
|
else:
|
||||||
|
segments = raw.split("/")
|
||||||
|
if len(segments) == 2:
|
||||||
|
return segments[0], segments[1]
|
||||||
|
return "", ""
|
||||||
|
|
||||||
def pull(self) -> bool:
|
def pull(self) -> bool:
|
||||||
"""拉取最新代码(自动切回 main/master 分支)"""
|
"""拉取最新代码(自动切回 main/master 分支)"""
|
||||||
if not self.repo:
|
if not self.repo:
|
||||||
@ -214,6 +246,62 @@ class GitManager:
|
|||||||
logger.error(f"合并到 main 失败: {e}")
|
logger.error(f"合并到 main 失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def create_pr(self, title: str, body: str, base: str = "main") -> Tuple[bool, Optional[dict]]:
|
||||||
|
"""
|
||||||
|
通过 Gitea API 创建 Pull Request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, pr_info) - pr_info 包含 pr_number, pr_url, branch_name
|
||||||
|
"""
|
||||||
|
if not self.repo or not self.github_repo:
|
||||||
|
logger.error("未配置仓库地址,无法创建 PR")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
head_branch = self.repo.active_branch.name
|
||||||
|
|
||||||
|
gitea_url = settings.gitea_url.rstrip("/")
|
||||||
|
token = settings.gitea_token
|
||||||
|
if not token:
|
||||||
|
logger.error("未配置 Gitea Token,无法创建 PR")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
# 从 github_repo 提取 owner/repo,支持完整 URL 和 owner/repo 格式
|
||||||
|
owner, repo = self._parse_owner_repo()
|
||||||
|
if not owner or not repo:
|
||||||
|
logger.error(f"无法解析仓库 owner/repo: {self.github_repo}")
|
||||||
|
return False, None
|
||||||
|
api_url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/pulls"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
api_url,
|
||||||
|
json={
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"head": head_branch,
|
||||||
|
"base": base,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"token {token}"},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
data = resp.json()
|
||||||
|
pr_info = {
|
||||||
|
"pr_number": data["number"],
|
||||||
|
"pr_url": data["html_url"],
|
||||||
|
"branch_name": head_branch,
|
||||||
|
}
|
||||||
|
logger.info(f"PR 创建成功: #{data['number']} - {data['html_url']}")
|
||||||
|
return True, pr_info
|
||||||
|
else:
|
||||||
|
logger.error(f"创建 PR 失败 ({resp.status_code}): {resp.text}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建 PR 请求异常: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
def reset_hard(self) -> bool:
|
def reset_hard(self) -> bool:
|
||||||
"""重置所有更改"""
|
"""重置所有更改"""
|
||||||
if not self.repo:
|
if not self.repo:
|
||||||
|
|||||||
@ -18,7 +18,9 @@ class TaskManager:
|
|||||||
|
|
||||||
def fetch_pending_bugs(self, project_id: Optional[str] = None) -> list[Bug]:
|
def fetch_pending_bugs(self, project_id: Optional[str] = None) -> list[Bug]:
|
||||||
"""
|
"""
|
||||||
获取待修复的 Bug 列表(包括 NEW 和 PENDING_FIX 状态)
|
获取待修复的 Bug 列表(仅 NEW 状态)
|
||||||
|
|
||||||
|
PENDING_FIX 表示已修复、等待 PR 审核,不应被重新拉取修复。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: 可选,筛选特定项目
|
project_id: 可选,筛选特定项目
|
||||||
@ -28,8 +30,8 @@ class TaskManager:
|
|||||||
"""
|
"""
|
||||||
all_bugs: dict[int, Bug] = {}
|
all_bugs: dict[int, Bug] = {}
|
||||||
|
|
||||||
for source in ("runtime", "cicd", "deployment"):
|
for source in ("runtime", "cicd", "deployment", "code_review"):
|
||||||
for status in ("NEW", "PENDING_FIX"):
|
for status in ("NEW",):
|
||||||
try:
|
try:
|
||||||
params: dict[str, str] = {"status": status, "source": source}
|
params: dict[str, str] = {"status": status, "source": source}
|
||||||
if project_id:
|
if project_id:
|
||||||
@ -50,6 +52,8 @@ class TaskManager:
|
|||||||
stack_trace = item.get("stack_trace")
|
stack_trace = item.get("stack_trace")
|
||||||
if isinstance(stack_trace, str):
|
if isinstance(stack_trace, str):
|
||||||
stack_trace = stack_trace.split("\n")
|
stack_trace = stack_trace.split("\n")
|
||||||
|
elif isinstance(stack_trace, dict):
|
||||||
|
stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()]
|
||||||
|
|
||||||
all_bugs[bug_id] = Bug(
|
all_bugs[bug_id] = Bug(
|
||||||
id=bug_id,
|
id=bug_id,
|
||||||
@ -66,12 +70,13 @@ class TaskManager:
|
|||||||
context=item.get("context"),
|
context=item.get("context"),
|
||||||
status=BugStatus(item.get("status", "NEW")),
|
status=BugStatus(item.get("status", "NEW")),
|
||||||
retry_count=item.get("retry_count", 0),
|
retry_count=item.get("retry_count", 0),
|
||||||
|
severity=item.get("severity"),
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"获取 {source}/{status} 状态 Bug 列表失败: {e}")
|
logger.error(f"获取 {source}/{status} 状态 Bug 列表失败: {e}")
|
||||||
|
|
||||||
bugs = list(all_bugs.values())
|
bugs = list(all_bugs.values())
|
||||||
logger.info(f"获取到 {len(bugs)} 个待修复 Bug(runtime + cicd + deployment)")
|
logger.info(f"获取到 {len(bugs)} 个待修复 Bug")
|
||||||
return bugs
|
return bugs
|
||||||
|
|
||||||
def fetch_failed_bugs(self, project_id: Optional[str] = None) -> list[Bug]:
|
def fetch_failed_bugs(self, project_id: Optional[str] = None) -> list[Bug]:
|
||||||
@ -79,7 +84,7 @@ class TaskManager:
|
|||||||
获取修复失败的 Bug 列表(FIX_FAILED 状态,所有来源)
|
获取修复失败的 Bug 列表(FIX_FAILED 状态,所有来源)
|
||||||
"""
|
"""
|
||||||
all_bugs: dict[int, Bug] = {}
|
all_bugs: dict[int, Bug] = {}
|
||||||
for source in ("runtime", "cicd", "deployment"):
|
for source in ("runtime", "cicd", "deployment", "code_review"):
|
||||||
try:
|
try:
|
||||||
params: dict[str, str] = {"status": "FIX_FAILED", "source": source}
|
params: dict[str, str] = {"status": "FIX_FAILED", "source": source}
|
||||||
if project_id:
|
if project_id:
|
||||||
@ -100,6 +105,8 @@ class TaskManager:
|
|||||||
stack_trace = item.get("stack_trace")
|
stack_trace = item.get("stack_trace")
|
||||||
if isinstance(stack_trace, str):
|
if isinstance(stack_trace, str):
|
||||||
stack_trace = stack_trace.split("\n")
|
stack_trace = stack_trace.split("\n")
|
||||||
|
elif isinstance(stack_trace, dict):
|
||||||
|
stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()]
|
||||||
|
|
||||||
all_bugs[bug_id] = Bug(
|
all_bugs[bug_id] = Bug(
|
||||||
id=item["id"],
|
id=item["id"],
|
||||||
@ -116,6 +123,7 @@ class TaskManager:
|
|||||||
context=item.get("context"),
|
context=item.get("context"),
|
||||||
status=BugStatus.FIX_FAILED,
|
status=BugStatus.FIX_FAILED,
|
||||||
retry_count=item.get("retry_count", 0),
|
retry_count=item.get("retry_count", 0),
|
||||||
|
severity=item.get("severity"),
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"获取 {source}/FIX_FAILED Bug 列表失败: {e}")
|
logger.error(f"获取 {source}/FIX_FAILED Bug 列表失败: {e}")
|
||||||
@ -160,10 +168,12 @@ class TaskManager:
|
|||||||
|
|
||||||
item = response.json()
|
item = response.json()
|
||||||
|
|
||||||
# stack_trace 可能是列表或字符串
|
# stack_trace 可能是列表、字符串或字典
|
||||||
stack_trace = item.get("stack_trace")
|
stack_trace = item.get("stack_trace")
|
||||||
if isinstance(stack_trace, str):
|
if isinstance(stack_trace, str):
|
||||||
stack_trace = stack_trace.split("\n")
|
stack_trace = stack_trace.split("\n")
|
||||||
|
elif isinstance(stack_trace, dict):
|
||||||
|
stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()]
|
||||||
|
|
||||||
return Bug(
|
return Bug(
|
||||||
id=item["id"],
|
id=item["id"],
|
||||||
@ -180,6 +190,7 @@ class TaskManager:
|
|||||||
context=item.get("context"),
|
context=item.get("context"),
|
||||||
status=BugStatus(item.get("status", "NEW")),
|
status=BugStatus(item.get("status", "NEW")),
|
||||||
retry_count=item.get("retry_count", 0),
|
retry_count=item.get("retry_count", 0),
|
||||||
|
severity=item.get("severity"),
|
||||||
)
|
)
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
@ -194,12 +205,44 @@ class TaskManager:
|
|||||||
json=report.model_dump()
|
json=report.model_dump()
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.info(f"Bug #{report.error_log_id} 修复报告已上传")
|
logger.info(f"修复报告已上传 (bugs: {report.error_log_ids})")
|
||||||
return True
|
return True
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"上传修复报告失败: {e}")
|
logger.error(f"上传修复报告失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def update_pr_info(self, bug_id: int, pr_number: int, pr_url: str, branch_name: str) -> bool:
|
||||||
|
"""回写 PR 信息到 Bug"""
|
||||||
|
try:
|
||||||
|
response = self.client.put(
|
||||||
|
f"{self.base_url}/api/v1/bugs/{bug_id}/pr-info",
|
||||||
|
json={
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"pr_url": pr_url,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info(f"Bug #{bug_id} PR 信息已更新: #{pr_number}")
|
||||||
|
return True
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"更新 PR 信息失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_severity(self, bug_id: int, severity: int, severity_reason: str = "") -> bool:
|
||||||
|
"""回写 Bug 严重等级"""
|
||||||
|
try:
|
||||||
|
response = self.client.put(
|
||||||
|
f"{self.base_url}/api/v1/bugs/{bug_id}/severity",
|
||||||
|
json={"severity": severity, "severity_reason": severity_reason},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info(f"Bug #{bug_id} 严重等级已更新: {severity}/10")
|
||||||
|
return True
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"更新严重等级失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def get_project_info(self, project_id: str) -> Optional[dict]:
|
def get_project_info(self, project_id: str) -> Optional[dict]:
|
||||||
"""从 Log Center API 获取项目配置(repo_url + local_path)"""
|
"""从 Log Center API 获取项目配置(repo_url + local_path)"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -23,9 +23,12 @@ class Settings(BaseSettings):
|
|||||||
# Git
|
# Git
|
||||||
git_user_name: str = Field(default="repair-agent", description="Git 用户名")
|
git_user_name: str = Field(default="repair-agent", description="Git 用户名")
|
||||||
git_user_email: str = Field(default="agent@airlabs.art", description="Git 邮箱")
|
git_user_email: str = Field(default="agent@airlabs.art", description="Git 邮箱")
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
gitea_url: str = Field(default="https://gitea.airlabs.art", description="Gitea 地址")
|
||||||
gitea_token: Optional[str] = Field(default=None, description="Gitea Token")
|
gitea_token: Optional[str] = Field(default=None, description="Gitea Token")
|
||||||
|
|
||||||
# GitHub 仓库地址(为空则不执行 git 操作)
|
# 仓库地址(owner/repo 格式,为空则不执行 git 操作)
|
||||||
github_repo_rtc_backend: str = Field(default="", description="rtc_backend GitHub 仓库地址")
|
github_repo_rtc_backend: str = Field(default="", description="rtc_backend GitHub 仓库地址")
|
||||||
github_repo_rtc_web: str = Field(default="", description="rtc_web GitHub 仓库地址")
|
github_repo_rtc_web: str = Field(default="", description="rtc_web GitHub 仓库地址")
|
||||||
github_repo_airhub_app: str = Field(default="", description="airhub_app GitHub 仓库地址")
|
github_repo_airhub_app: str = Field(default="", description="airhub_app GitHub 仓库地址")
|
||||||
|
|||||||
@ -39,6 +39,7 @@ class Bug(BaseModel):
|
|||||||
context: Optional[dict[str, Any]] = Field(default=None, description="上下文")
|
context: Optional[dict[str, Any]] = Field(default=None, description="上下文")
|
||||||
status: BugStatus = Field(default=BugStatus.NEW, description="状态")
|
status: BugStatus = Field(default=BugStatus.NEW, description="状态")
|
||||||
retry_count: int = Field(default=0, description="重试次数")
|
retry_count: int = Field(default=0, description="重试次数")
|
||||||
|
severity: Optional[int] = Field(default=None, description="严重等级 1-10")
|
||||||
created_at: Optional[datetime] = Field(default=None, description="创建时间")
|
created_at: Optional[datetime] = Field(default=None, description="创建时间")
|
||||||
|
|
||||||
def format_for_prompt(self) -> str:
|
def format_for_prompt(self) -> str:
|
||||||
@ -81,8 +82,8 @@ class BatchFixResult(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RepairReport(BaseModel):
|
class RepairReport(BaseModel):
|
||||||
"""修复报告"""
|
"""修复报告(一次批量修复 = 一条报告)"""
|
||||||
error_log_id: int
|
error_log_ids: list[int]
|
||||||
status: BugStatus
|
status: BugStatus
|
||||||
project_id: str
|
project_id: str
|
||||||
ai_analysis: str
|
ai_analysis: str
|
||||||
@ -93,4 +94,7 @@ class RepairReport(BaseModel):
|
|||||||
test_passed: bool
|
test_passed: bool
|
||||||
repair_round: int = 1
|
repair_round: int = 1
|
||||||
failure_reason: Optional[str] = None
|
failure_reason: Optional[str] = None
|
||||||
|
pr_url: Optional[str] = None
|
||||||
|
pr_number: Optional[int] = None
|
||||||
|
branch_name: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
55
test_bugs.sql
Normal file
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;
|
status: string;
|
||||||
retry_count: number;
|
retry_count: number;
|
||||||
failure_reason: string | null;
|
failure_reason: string | null;
|
||||||
|
// Severity (1-10)
|
||||||
|
severity?: number | null;
|
||||||
|
severity_reason?: string | null;
|
||||||
// PR Tracking
|
// PR Tracking
|
||||||
pr_number?: number | null;
|
pr_number?: number | null;
|
||||||
pr_url?: string | null;
|
pr_url?: string | null;
|
||||||
@ -67,7 +70,7 @@ export interface PaginatedResponse<T> {
|
|||||||
|
|
||||||
export interface RepairReport {
|
export interface RepairReport {
|
||||||
id: number;
|
id: number;
|
||||||
error_log_id: number;
|
error_log_ids: number[];
|
||||||
project_id: string;
|
project_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
ai_analysis: string;
|
ai_analysis: string;
|
||||||
@ -79,6 +82,9 @@ export interface RepairReport {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
repair_round: number;
|
repair_round: number;
|
||||||
failure_reason: string | null;
|
failure_reason: string | null;
|
||||||
|
pr_url?: string | null;
|
||||||
|
pr_number?: number | null;
|
||||||
|
branch_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Functions
|
// API Functions
|
||||||
@ -128,4 +134,21 @@ export const mergePR = (bugId: number) =>
|
|||||||
export const closePR = (bugId: number, reason: string) =>
|
export const closePR = (bugId: number, reason: string) =>
|
||||||
api.post(`/api/v1/bugs/${bugId}/close-pr`, { reason });
|
api.post(`/api/v1/bugs/${bugId}/close-pr`, { reason });
|
||||||
|
|
||||||
|
export const retryFix = (bugId: number) =>
|
||||||
|
api.post(`/api/v1/bugs/${bugId}/retry`);
|
||||||
|
|
||||||
|
// Manual review operations (without PR)
|
||||||
|
export const approveFix = (bugId: number) =>
|
||||||
|
api.post(`/api/v1/bugs/${bugId}/approve-fix`);
|
||||||
|
|
||||||
|
export const rejectFix = (bugId: number, reason: string) =>
|
||||||
|
api.post(`/api/v1/bugs/${bugId}/reject-fix`, { reason });
|
||||||
|
|
||||||
|
// Report-level approve/reject (batch operation)
|
||||||
|
export const approveReport = (reportId: number) =>
|
||||||
|
api.post(`/api/v1/repair/reports/${reportId}/approve`);
|
||||||
|
|
||||||
|
export const rejectReport = (reportId: number, reason: string) =>
|
||||||
|
api.post(`/api/v1/repair/reports/${reportId}/reject`, { reason });
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useLocation } from 'react-router-dom';
|
import { useParams, Link, useLocation } from 'react-router-dom';
|
||||||
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle, Check, X, ExternalLink } from 'lucide-react';
|
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle, Check, X, ExternalLink } from 'lucide-react';
|
||||||
import { getBugDetail, triggerRepair, getRepairReportsByBug, mergePR, closePR, type ErrorLog, type RepairReport } from '../api';
|
import { getBugDetail, triggerRepair, getRepairReportsByBug, mergePR, closePR, retryFix, approveFix, rejectFix, type ErrorLog, type RepairReport } from '../api';
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
runtime: '运行时',
|
runtime: '运行时',
|
||||||
@ -13,7 +13,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
NEW: '新发现',
|
NEW: '新发现',
|
||||||
VERIFYING: '验证中',
|
VERIFYING: '验证中',
|
||||||
CANNOT_REPRODUCE: '无法复现',
|
CANNOT_REPRODUCE: '无法复现',
|
||||||
PENDING_FIX: '待修复',
|
PENDING_FIX: '等待审核',
|
||||||
FIXING: '修复中',
|
FIXING: '修复中',
|
||||||
FIXED: '已修复',
|
FIXED: '已修复',
|
||||||
VERIFIED: '已验证',
|
VERIFIED: '已验证',
|
||||||
@ -44,6 +44,15 @@ export default function BugDetail() {
|
|||||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
|
// 人工审核操作状态(无 PR 时使用)
|
||||||
|
const [approving, setApproving] = useState(false);
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
const [reviewMessage, setReviewMessage] = useState('');
|
||||||
|
|
||||||
|
// 重试操作状态
|
||||||
|
const [retrying, setRetrying] = useState(false);
|
||||||
|
const [retryMessage, setRetryMessage] = useState('');
|
||||||
|
|
||||||
const backSearch = location.state?.fromSearch || '';
|
const backSearch = location.state?.fromSearch || '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -127,6 +136,64 @@ export default function BugDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetry = async () => {
|
||||||
|
if (!bug) return;
|
||||||
|
setRetrying(true);
|
||||||
|
setRetryMessage('');
|
||||||
|
try {
|
||||||
|
await retryFix(bug.id);
|
||||||
|
setBug({ ...bug, status: 'NEW', failure_reason: null });
|
||||||
|
setRetryMessage('✅ Bug 已重置,repair agent 将重新扫描');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to retry:', error);
|
||||||
|
setRetryMessage(`❌ 重试失败: ${error.response?.data?.detail || error.message}`);
|
||||||
|
} finally {
|
||||||
|
setRetrying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveFix = async () => {
|
||||||
|
if (!bug) return;
|
||||||
|
setApproving(true);
|
||||||
|
setReviewMessage('');
|
||||||
|
try {
|
||||||
|
await approveFix(bug.id);
|
||||||
|
setBug({ ...bug, status: 'FIXED', merged_at: new Date().toISOString() });
|
||||||
|
setReviewMessage('修复已确认');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to approve fix:', error);
|
||||||
|
setReviewMessage(`确认失败: ${error.response?.data?.detail || error.message}`);
|
||||||
|
} finally {
|
||||||
|
setApproving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectFix = async () => {
|
||||||
|
if (!bug || !rejectReason.trim()) {
|
||||||
|
setReviewMessage('请输入驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRejecting(true);
|
||||||
|
setReviewMessage('');
|
||||||
|
try {
|
||||||
|
await rejectFix(bug.id, rejectReason);
|
||||||
|
setBug({
|
||||||
|
...bug,
|
||||||
|
status: 'NEW',
|
||||||
|
rejection_count: (bug.rejection_count || 0) + 1,
|
||||||
|
last_rejected_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
setReviewMessage('修复已驳回,Bug 将重新修复');
|
||||||
|
setShowRejectModal(false);
|
||||||
|
setRejectReason('');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to reject fix:', error);
|
||||||
|
setReviewMessage(`驳回失败: ${error.response?.data?.detail || error.message}`);
|
||||||
|
} finally {
|
||||||
|
setRejecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="loading">
|
<div className="loading">
|
||||||
@ -142,7 +209,12 @@ export default function BugDetail() {
|
|||||||
const isRuntime = !bug.source || bug.source === 'runtime';
|
const isRuntime = !bug.source || bug.source === 'runtime';
|
||||||
const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime;
|
const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime;
|
||||||
const hasPR = !!bug.pr_url;
|
const hasPR = !!bug.pr_url;
|
||||||
const canOperatePR = hasPR && bug.status === 'PENDING_FIX';
|
const isPendingReview = bug.status === 'PENDING_FIX';
|
||||||
|
const canOperatePR = hasPR && isPendingReview;
|
||||||
|
const canManualReview = !hasPR && isPendingReview;
|
||||||
|
// PR 信息只在 PENDING_FIX 或 FIXED 状态时显示
|
||||||
|
const shouldShowPR = hasPR && (isPendingReview || bug.status === 'FIXED');
|
||||||
|
const canRetry = bug.status === 'FIX_FAILED';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -164,13 +236,48 @@ export default function BugDetail() {
|
|||||||
<span>级别:{bug.level}</span>
|
<span>级别:{bug.level}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`status-badge status-${bug.status}`}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{STATUS_LABELS[bug.status] || bug.status}
|
{bug.severity != null && bug.status !== 'NEW' && (
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* PR 信息显示 */}
|
{/* 严重等级说明 */}
|
||||||
{hasPR && (
|
{bug.severity != null && bug.severity_reason && bug.status !== 'NEW' && (
|
||||||
|
<div className="detail-section" style={{
|
||||||
|
background: bug.severity >= 8 ? 'rgba(239,68,68,0.08)' : 'var(--bg-secondary)',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginTop: '12px',
|
||||||
|
borderLeft: `3px solid ${bug.severity >= 8 ? 'var(--error)' : bug.severity >= 5 ? 'var(--warning)' : 'var(--success)'}`,
|
||||||
|
}}>
|
||||||
|
<div className="detail-section-title" style={{ marginBottom: '4px' }}>
|
||||||
|
严重等级:{bug.severity}/10 {bug.severity >= 8 ? '(需人工审核)' : ''}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||||
|
{bug.severity_reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PR 信息显示 - 仅在 PENDING_FIX 或 FIXED 状态时显示 */}
|
||||||
|
{shouldShowPR && (
|
||||||
<div className="detail-section" style={{ background: 'var(--bg-secondary)', padding: '12px', borderRadius: '6px', marginTop: '16px' }}>
|
<div className="detail-section" style={{ background: 'var(--bg-secondary)', padding: '12px', borderRadius: '6px', marginTop: '16px' }}>
|
||||||
<div className="detail-section-title" style={{ marginBottom: '8px' }}>Pull Request</div>
|
<div className="detail-section-title" style={{ marginBottom: '8px' }}>Pull Request</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
@ -284,8 +391,37 @@ export default function BugDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 人工审核按钮(无 PR 时) */}
|
||||||
|
{canManualReview && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="trigger-repair-btn"
|
||||||
|
onClick={handleApproveFix}
|
||||||
|
disabled={approving}
|
||||||
|
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
|
||||||
|
>
|
||||||
|
{approving ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} />
|
||||||
|
)}
|
||||||
|
{approving ? '确认中...' : '确认修复'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="trigger-repair-btn"
|
||||||
|
onClick={() => setShowRejectModal(true)}
|
||||||
|
disabled={rejecting}
|
||||||
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
驳回修复
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 原有的触发修复按钮 */}
|
{/* 原有的触发修复按钮 */}
|
||||||
{!hasPR && (
|
{!hasPR && !isPendingReview && (
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn"
|
className="trigger-repair-btn"
|
||||||
onClick={handleTriggerRepair}
|
onClick={handleTriggerRepair}
|
||||||
@ -300,6 +436,23 @@ export default function BugDetail() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 重新尝试按钮 - 仅在修复失败时显示 */}
|
||||||
|
{canRetry && (
|
||||||
|
<button
|
||||||
|
className="trigger-repair-btn"
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
style={{ background: 'var(--warning)', borderColor: 'var(--warning)' }}
|
||||||
|
>
|
||||||
|
{retrying ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<Play size={14} />
|
||||||
|
)}
|
||||||
|
{retrying ? '重置中...' : '重新尝试'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 消息显示 */}
|
{/* 消息显示 */}
|
||||||
{prMessage && (
|
{prMessage && (
|
||||||
<span style={{
|
<span style={{
|
||||||
@ -319,7 +472,25 @@ export default function BugDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!canTriggerRepair && !repairing && !hasPR && (
|
{retryMessage && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: retryMessage.includes('✅') ? 'var(--success)' : 'var(--error)'
|
||||||
|
}}>
|
||||||
|
{retryMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reviewMessage && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: reviewMessage.includes('确认') || reviewMessage.includes('驳回') ? 'var(--success)' : 'var(--error)'
|
||||||
|
}}>
|
||||||
|
{reviewMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!canTriggerRepair && !repairing && !hasPR && !canRetry && !canManualReview && (
|
||||||
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
|
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
|
||||||
{!isRuntime
|
{!isRuntime
|
||||||
? 'CI/CD 和部署错误暂不支持自动修复'
|
? 'CI/CD 和部署错误暂不支持自动修复'
|
||||||
@ -452,9 +623,9 @@ export default function BugDetail() {
|
|||||||
width: '90%',
|
width: '90%',
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
}}>
|
}}>
|
||||||
<h3 style={{ marginBottom: '16px' }}>拒绝修复</h3>
|
<h3 style={{ marginBottom: '16px' }}>{hasPR ? '拒绝修复' : '驳回修复'}</h3>
|
||||||
<p style={{ marginBottom: '12px', fontSize: '14px', color: 'var(--text-secondary)' }}>
|
<p style={{ marginBottom: '12px', fontSize: '14px', color: 'var(--text-secondary)' }}>
|
||||||
请说明拒绝原因,Agent 将根据您的反馈重新修复:
|
请说明{hasPR ? '拒绝' : '驳回'}原因,Agent 将根据您的反馈重新修复:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
@ -515,17 +686,17 @@ export default function BugDetail() {
|
|||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleClosePR}
|
onClick={hasPR ? handleClosePR : handleRejectFix}
|
||||||
disabled={closingPR || !rejectReason.trim()}
|
disabled={(hasPR ? closingPR : rejecting) || !rejectReason.trim()}
|
||||||
className="trigger-repair-btn"
|
className="trigger-repair-btn"
|
||||||
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
>
|
>
|
||||||
{closingPR ? (
|
{(hasPR ? closingPR : rejecting) ? (
|
||||||
<Loader2 size={14} className="spinner" />
|
<Loader2 size={14} className="spinner" />
|
||||||
) : (
|
) : (
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
)}
|
)}
|
||||||
{closingPR ? '提交中...' : '确认拒绝'}
|
{(hasPR ? closingPR : rejecting) ? '提交中...' : '确认驳回'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Link, useSearchParams } from 'react-router-dom';
|
|||||||
import { getBugs, getProjects, type ErrorLog, type Project } from '../api';
|
import { getBugs, getProjects, type ErrorLog, type Project } from '../api';
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
'NEW', 'CANNOT_REPRODUCE', 'FIXING', 'FIXED', 'FIX_FAILED'
|
'NEW', 'PENDING_FIX', 'CANNOT_REPRODUCE', 'FIXING', 'FIXED', 'FIX_FAILED'
|
||||||
];
|
];
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
@ -16,7 +16,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
NEW: '新发现',
|
NEW: '新发现',
|
||||||
VERIFYING: '验证中',
|
VERIFYING: '验证中',
|
||||||
CANNOT_REPRODUCE: '无法复现',
|
CANNOT_REPRODUCE: '无法复现',
|
||||||
PENDING_FIX: '待修复',
|
PENDING_FIX: '等待审核',
|
||||||
FIXING: '修复中',
|
FIXING: '修复中',
|
||||||
FIXED: '已修复',
|
FIXED: '已修复',
|
||||||
VERIFIED: '已验证',
|
VERIFIED: '已验证',
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
NEW: '新发现',
|
NEW: '新发现',
|
||||||
VERIFYING: '验证中',
|
VERIFYING: '验证中',
|
||||||
CANNOT_REPRODUCE: '无法复现',
|
CANNOT_REPRODUCE: '无法复现',
|
||||||
PENDING_FIX: '待修复',
|
PENDING_FIX: '等待审核',
|
||||||
FIXING: '修复中',
|
FIXING: '修复中',
|
||||||
FIXED: '已修复',
|
FIXED: '已修复',
|
||||||
VERIFIED: '已验证',
|
VERIFIED: '已验证',
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle } from 'lucide-react';
|
import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle, Check, X, Loader2, ExternalLink } from 'lucide-react';
|
||||||
import { getRepairReportDetail, type RepairReport } from '../api';
|
import { getRepairReportDetail, approveReport, rejectReport, type RepairReport } from '../api';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
NEW: '新发现',
|
NEW: '新发现',
|
||||||
VERIFYING: '验证中',
|
VERIFYING: '验证中',
|
||||||
CANNOT_REPRODUCE: '无法复现',
|
CANNOT_REPRODUCE: '无法复现',
|
||||||
PENDING_FIX: '待修复',
|
PENDING_FIX: '等待审核',
|
||||||
FIXING: '修复中',
|
FIXING: '修复中',
|
||||||
FIXED: '已修复',
|
FIXED: '已修复',
|
||||||
VERIFIED: '已验证',
|
VERIFIED: '已验证',
|
||||||
@ -15,11 +15,25 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
FIX_FAILED: '修复失败',
|
FIX_FAILED: '修复失败',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REJECT_REASON_TEMPLATES = [
|
||||||
|
'测试覆盖不足,缺少边界条件测试',
|
||||||
|
'业务逻辑需要调整',
|
||||||
|
'代码质量不符合规范',
|
||||||
|
'需要补充异常处理',
|
||||||
|
];
|
||||||
|
|
||||||
export default function RepairDetail() {
|
export default function RepairDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [report, setReport] = useState<RepairReport | null>(null);
|
const [report, setReport] = useState<RepairReport | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 审核操作状态
|
||||||
|
const [approving, setApproving] = useState(false);
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
const [actionMessage, setActionMessage] = useState('');
|
||||||
|
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
const fetchDetail = async () => {
|
const fetchDetail = async () => {
|
||||||
@ -36,6 +50,43 @@ export default function RepairDetail() {
|
|||||||
}
|
}
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (!report) return;
|
||||||
|
setApproving(true);
|
||||||
|
setActionMessage('');
|
||||||
|
try {
|
||||||
|
await approveReport(report.id);
|
||||||
|
setReport({ ...report, status: 'FIXED' });
|
||||||
|
setActionMessage('修复已批准,所有关联缺陷已标记为已修复');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to approve report:', error);
|
||||||
|
setActionMessage(`批准失败: ${error.response?.data?.detail || error.message}`);
|
||||||
|
} finally {
|
||||||
|
setApproving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!report || !rejectReason.trim()) {
|
||||||
|
setActionMessage('请输入驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRejecting(true);
|
||||||
|
setActionMessage('');
|
||||||
|
try {
|
||||||
|
await rejectReport(report.id, rejectReason);
|
||||||
|
setReport({ ...report, status: 'NEW' });
|
||||||
|
setActionMessage('修复已驳回,所有关联缺陷将重新修复');
|
||||||
|
setShowRejectModal(false);
|
||||||
|
setRejectReason('');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to reject report:', error);
|
||||||
|
setActionMessage(`驳回失败: ${error.response?.data?.detail || error.message}`);
|
||||||
|
} finally {
|
||||||
|
setRejecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="loading">
|
<div className="loading">
|
||||||
@ -48,6 +99,9 @@ export default function RepairDetail() {
|
|||||||
return <div className="loading">未找到该修复报告</div>;
|
return <div className="loading">未找到该修复报告</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPendingReview = report.status === 'PENDING_FIX';
|
||||||
|
const hasPR = !!report.pr_url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link to="/repairs" className="back-link">
|
<Link to="/repairs" className="back-link">
|
||||||
@ -71,8 +125,15 @@ export default function RepairDetail() {
|
|||||||
<strong>{report.project_id}</strong>
|
<strong>{report.project_id}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span>缺陷编号</span>
|
<span>关联缺陷</span>
|
||||||
<Link to={`/bugs/${report.error_log_id}`}>#{report.error_log_id}</Link>
|
<span>
|
||||||
|
{report.error_log_ids?.map((bugId, idx) => (
|
||||||
|
<span key={bugId}>
|
||||||
|
{idx > 0 && ', '}
|
||||||
|
<Link to={`/bugs/${bugId}`}>#{bugId}</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span>创建时间</span>
|
<span>创建时间</span>
|
||||||
@ -90,6 +151,67 @@ export default function RepairDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* PR 信息 */}
|
||||||
|
{hasPR && (
|
||||||
|
<div className="card" style={{ borderLeft: '3px solid var(--accent)' }}>
|
||||||
|
<h2>Pull Request</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: '14px' }}>
|
||||||
|
PR #{report.pr_number} | {report.branch_name || 'fix branch'}
|
||||||
|
</span>
|
||||||
|
<a href={report.pr_url!} target="_blank" rel="noopener noreferrer" className="btn-link" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
查看 PR <ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 审核操作区 */}
|
||||||
|
{isPendingReview && (
|
||||||
|
<div className="card" style={{ borderLeft: '3px solid var(--warning)' }}>
|
||||||
|
<h2>审核操作</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginBottom: '12px' }}>
|
||||||
|
{hasPR
|
||||||
|
? '批准将合并 PR 并标记所有关联缺陷为已修复'
|
||||||
|
: '确认修复将标记所有关联缺陷为已修复'}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
className="trigger-repair-btn"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={approving}
|
||||||
|
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
|
||||||
|
>
|
||||||
|
{approving ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} />
|
||||||
|
)}
|
||||||
|
{approving ? '处理中...' : (hasPR ? '批准并合并' : '确认修复')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="trigger-repair-btn"
|
||||||
|
onClick={() => setShowRejectModal(true)}
|
||||||
|
disabled={rejecting}
|
||||||
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
{hasPR ? '拒绝修复' : '驳回修复'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{actionMessage && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: actionMessage.includes('批准') || actionMessage.includes('驳回') ? 'var(--success)' : 'var(--error)'
|
||||||
|
}}>
|
||||||
|
{actionMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{report.failure_reason && (
|
{report.failure_reason && (
|
||||||
<div className="card" style={{ borderLeft: '3px solid var(--error)' }}>
|
<div className="card" style={{ borderLeft: '3px solid var(--error)' }}>
|
||||||
<h2><AlertTriangle size={16} /> 失败原因</h2>
|
<h2><AlertTriangle size={16} /> 失败原因</h2>
|
||||||
@ -111,6 +233,108 @@ export default function RepairDetail() {
|
|||||||
<h2><FlaskConical size={16} /> 测试输出</h2>
|
<h2><FlaskConical size={16} /> 测试输出</h2>
|
||||||
<pre className="code-block neutral">{report.test_output}</pre>
|
<pre className="code-block neutral">{report.test_output}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 驳回原因模态框 */}
|
||||||
|
{showRejectModal && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ marginBottom: '16px' }}>{hasPR ? '拒绝修复' : '驳回修复'}</h3>
|
||||||
|
<p style={{ marginBottom: '12px', fontSize: '14px', color: 'var(--text-secondary)' }}>
|
||||||
|
请说明原因,Agent 将根据您的反馈重新修复所有关联缺陷:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<label style={{ fontSize: '13px', color: 'var(--text-tertiary)', marginBottom: '6px', display: 'block' }}>
|
||||||
|
常用模板(点击填充):
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
|
{REJECT_REASON_TEMPLATES.map((template, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setRejectReason(template)}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{template}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
placeholder="请输入详细原因..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '120px',
|
||||||
|
padding: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginTop: '16px', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowRejectModal(false);
|
||||||
|
setRejectReason('');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={rejecting || !rejectReason.trim()}
|
||||||
|
className="trigger-repair-btn"
|
||||||
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
{rejecting ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<X size={14} />
|
||||||
|
)}
|
||||||
|
{rejecting ? '提交中...' : '确认驳回'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
NEW: '新发现',
|
NEW: '新发现',
|
||||||
VERIFYING: '验证中',
|
VERIFYING: '验证中',
|
||||||
CANNOT_REPRODUCE: '无法复现',
|
CANNOT_REPRODUCE: '无法复现',
|
||||||
PENDING_FIX: '待修复',
|
PENDING_FIX: '等待审核',
|
||||||
FIXING: '修复中',
|
FIXING: '修复中',
|
||||||
FIXED: '已修复',
|
FIXED: '已修复',
|
||||||
VERIFIED: '已验证',
|
VERIFIED: '已验证',
|
||||||
@ -116,9 +116,12 @@ export default function RepairList() {
|
|||||||
<td>#{report.id}</td>
|
<td>#{report.id}</td>
|
||||||
<td>{report.project_id}</td>
|
<td>{report.project_id}</td>
|
||||||
<td>
|
<td>
|
||||||
<Link to={`/bugs/${report.error_log_id}`}>
|
{report.error_log_ids?.map((bugId, idx) => (
|
||||||
#{report.error_log_id}
|
<span key={bugId}>
|
||||||
</Link>
|
{idx > 0 && ', '}
|
||||||
|
<Link to={`/bugs/${bugId}`}>#{bugId}</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</td>
|
</td>
|
||||||
<td>第 {report.repair_round} 轮</td>
|
<td>第 {report.repair_round} 轮</td>
|
||||||
<td>{report.modified_files.length} 个文件</td>
|
<td>{report.modified_files.length} 个文件</td>
|
||||||
@ -166,7 +169,7 @@ export default function RepairList() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mobile-card-meta">
|
<div className="mobile-card-meta">
|
||||||
<span>{report.project_id}</span>
|
<span>{report.project_id}</span>
|
||||||
<span>缺陷 #{report.error_log_id}</span>
|
<span>缺陷 {report.error_log_ids?.map(id => `#${id}`).join(', ')}</span>
|
||||||
<span>第 {report.repair_round} 轮</span>
|
<span>第 {report.repair_round} 轮</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-card-meta">
|
<div className="mobile-card-meta">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user