From b178d24e737fa81a9afc5a43c3325f55b8850b88 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 25 Feb 2026 16:35:28 +0800 Subject: [PATCH] fix pr --- app/database.py | 3 + app/main.py | 342 +++++++++++++++++++++++++++-- app/models.py | 26 ++- create_test_bugs.sql | 74 +++++++ insert_new_bugs.py | 131 +++++++++++ insert_test_bugs.py | 110 ++++++++++ repair_agent/agent/core.py | 171 ++++++++++----- repair_agent/agent/git_manager.py | 90 +++++++- repair_agent/agent/task_manager.py | 59 ++++- repair_agent/config/settings.py | 5 +- repair_agent/models/bug.py | 8 +- test_bugs.sql | 55 +++++ web/src/api.ts | 25 ++- web/src/pages/BugDetail.tsx | 203 +++++++++++++++-- web/src/pages/BugList.tsx | 4 +- web/src/pages/Dashboard.tsx | 2 +- web/src/pages/RepairDetail.tsx | 234 +++++++++++++++++++- web/src/pages/RepairList.tsx | 13 +- 18 files changed, 1429 insertions(+), 126 deletions(-) create mode 100644 create_test_bugs.sql create mode 100644 insert_new_bugs.py create mode 100644 insert_test_bugs.py create mode 100644 test_bugs.sql diff --git a/app/database.py b/app/database.py index 2158138..4dc70dc 100644 --- a/app/database.py +++ b/app/database.py @@ -34,6 +34,9 @@ async def init_db(): "CREATE INDEX IF NOT EXISTS ix_errorlog_source ON errorlog (source)", # ErrorLog failure_reason "ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS failure_reason TEXT", + # Bug severity (1-10 AI评估等级) + "ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity INTEGER", + "ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity_reason TEXT", # Seed Project table from existing ErrorLog data """INSERT INTO project (project_id, created_at, updated_at) SELECT DISTINCT e.project_id, NOW(), NOW() diff --git a/app/main.py b/app/main.py index 55cad24..df528d6 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI, Depends, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from sqlmodel.ext.asyncio.session import AsyncSession -from sqlmodel import select, func +from sqlmodel import select, func, text from .database import init_db, get_session from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate from .gitea_client import GiteaClient @@ -135,25 +135,90 @@ async def update_task_status( return {"message": "Status updated", "id": task.id, "status": task.status} +class PRInfoUpdate(BaseModel): + pr_number: int + pr_url: str + branch_name: str + + +class SeverityUpdate(BaseModel): + severity: int + severity_reason: Optional[str] = None + + +@app.put("/api/v1/bugs/{bug_id}/pr-info", tags=["Tasks"]) +async def update_bug_pr_info( + bug_id: int, + pr_info: PRInfoUpdate, + session: AsyncSession = Depends(get_session), +): + """repair agent 创建 PR 后回写 PR 信息""" + statement = select(ErrorLog).where(ErrorLog.id == bug_id) + results = await session.exec(statement) + bug = results.first() + + if not bug: + raise HTTPException(status_code=404, detail="Bug not found") + + bug.pr_number = pr_info.pr_number + bug.pr_url = pr_info.pr_url + bug.branch_name = pr_info.branch_name + + session.add(bug) + await session.commit() + await session.refresh(bug) + + return {"message": "PR info updated", "bug_id": bug.id} + + +@app.put("/api/v1/bugs/{bug_id}/severity", tags=["Tasks"]) +async def update_bug_severity( + bug_id: int, + data: SeverityUpdate, + session: AsyncSession = Depends(get_session), +): + """repair agent 评估后回写 Bug 严重等级""" + statement = select(ErrorLog).where(ErrorLog.id == bug_id) + results = await session.exec(statement) + bug = results.first() + + if not bug: + raise HTTPException(status_code=404, detail="Bug not found") + + bug.severity = data.severity + bug.severity_reason = data.severity_reason + + session.add(bug) + await session.commit() + await session.refresh(bug) + + return {"message": "Severity updated", "bug_id": bug.id, "severity": bug.severity} + + +class PRRejectRequest(BaseModel): + """拒绝/驳回请求""" + reason: str + + # ==================== Repair Reports ==================== @app.post("/api/v1/repair/reports", tags=["Repair"]) async def create_repair_report(report: RepairTaskCreate, session: AsyncSession = Depends(get_session)): - """Upload a new repair report""" - # 1. Create repair task record + """Upload a new repair report (one per batch, may cover multiple bugs)""" repair_task = RepairTask.from_orm(report) session.add(repair_task) - - # 2. Update error log status and failure_reason - if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED]: - log_stmt = select(ErrorLog).where(ErrorLog.id == report.error_log_id) - results = await session.exec(log_stmt) - error_log = results.first() - if error_log: - error_log.status = report.status - if report.failure_reason and report.status == LogStatus.FIX_FAILED: - error_log.failure_reason = report.failure_reason - session.add(error_log) - + + # Update all related bugs' status and failure_reason + if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED, LogStatus.PENDING_FIX, LogStatus.FIXING]: + for bug_id in report.error_log_ids: + log_stmt = select(ErrorLog).where(ErrorLog.id == bug_id) + results = await session.exec(log_stmt) + error_log = results.first() + if error_log: + error_log.status = report.status + if report.failure_reason and report.status == LogStatus.FIX_FAILED: + error_log.failure_reason = report.failure_reason + session.add(error_log) + await session.commit() await session.refresh(repair_task) return {"message": "Report uploaded", "id": repair_task.id} @@ -172,7 +237,10 @@ async def get_repair_reports( if project_id: query = query.where(RepairTask.project_id == project_id) if error_log_id: - query = query.where(RepairTask.error_log_id == error_log_id) + # PostgreSQL JSONB contains: error_log_ids @> '[28]' + query = query.where( + text(f"error_log_ids @> '{json.dumps([error_log_id])}'::jsonb") + ) offset = (page - 1) * page_size query = query.offset(offset).limit(page_size) @@ -185,10 +253,12 @@ async def get_repair_reports( if project_id: count_query = count_query.where(RepairTask.project_id == project_id) if error_log_id: - count_query = count_query.where(RepairTask.error_log_id == error_log_id) + count_query = count_query.where( + text(f"error_log_ids @> '{json.dumps([error_log_id])}'::jsonb") + ) count_result = await session.exec(count_query) total = count_result.one() - + return { "items": tasks, "total": total, @@ -203,13 +273,120 @@ async def get_repair_report_detail(report_id: int, session: AsyncSession = Depen statement = select(RepairTask).where(RepairTask.id == report_id) results = await session.exec(statement) task = results.first() - + if not task: raise HTTPException(status_code=404, detail="Report not found") - + return task +@app.post("/api/v1/repair/reports/{report_id}/approve", tags=["Repair"]) +async def approve_report(report_id: int, session: AsyncSession = Depends(get_session)): + """ + 批准修复报告:合并 PR(如有)并将所有关联 Bug 标记为 FIXED + """ + statement = select(RepairTask).where(RepairTask.id == report_id) + results = await session.exec(statement) + report = results.first() + + if not report: + raise HTTPException(status_code=404, detail="Report not found") + + if report.status != LogStatus.PENDING_FIX: + raise HTTPException(status_code=400, detail=f"报告状态不是等待审核: {report.status}") + + # 如有 PR,调用 Gitea API 合并 + if report.pr_url: + gitea_client = GiteaClient() + success, message = gitea_client.merge_pr_by_url(report.pr_url) + if not success: + raise HTTPException(status_code=500, detail=f"合并 PR 失败: {message}") + + # 更新报告状态 + report.status = LogStatus.FIXED + session.add(report) + + # 更新所有关联 Bug 状态 + updated_ids = [] + for bug_id in (report.error_log_ids or []): + bug_stmt = select(ErrorLog).where(ErrorLog.id == bug_id) + bug_results = await session.exec(bug_stmt) + bug = bug_results.first() + if bug: + bug.status = LogStatus.FIXED + bug.merged_at = datetime.utcnow() + session.add(bug) + updated_ids.append(bug_id) + + await session.commit() + + return { + "message": f"修复已批准,{len(updated_ids)} 个缺陷已更新", + "report_id": report_id, + "updated_bug_ids": updated_ids, + } + + +@app.post("/api/v1/repair/reports/{report_id}/reject", tags=["Repair"]) +async def reject_report( + report_id: int, + request: PRRejectRequest, + session: AsyncSession = Depends(get_session), +): + """ + 驳回修复报告:关闭 PR(如有)并将所有关联 Bug 重置为 NEW + """ + statement = select(RepairTask).where(RepairTask.id == report_id) + results = await session.exec(statement) + report = results.first() + + if not report: + raise HTTPException(status_code=404, detail="Report not found") + + if report.status != LogStatus.PENDING_FIX: + raise HTTPException(status_code=400, detail=f"报告状态不是等待审核: {report.status}") + + # 如有 PR,关闭 + if report.pr_url: + gitea_client = GiteaClient() + success, message = gitea_client.close_pr_by_url(report.pr_url, request.reason) + if not success: + raise HTTPException(status_code=500, detail=f"关闭 PR 失败: {message}") + + # 更新报告状态 + report.status = LogStatus.FIX_FAILED + report.failure_reason = f"人工驳回: {request.reason}" + session.add(report) + + # 重置所有关联 Bug 为 NEW + updated_ids = [] + rejection_info = json.dumps({ + "rejected_at": datetime.utcnow().isoformat(), + "reason": request.reason, + "report_id": report_id, + }, ensure_ascii=False) + + for bug_id in (report.error_log_ids or []): + bug_stmt = select(ErrorLog).where(ErrorLog.id == bug_id) + bug_results = await session.exec(bug_stmt) + bug = bug_results.first() + if bug: + bug.status = LogStatus.NEW + bug.rejection_count = (bug.rejection_count or 0) + 1 + bug.last_rejected_at = datetime.utcnow() + bug.rejection_reason = rejection_info + session.add(bug) + updated_ids.append(bug_id) + + await session.commit() + + return { + "message": f"修复已驳回,{len(updated_ids)} 个缺陷将重新修复", + "report_id": report_id, + "updated_bug_ids": updated_ids, + } + + # ==================== Dashboard APIs ==================== @app.get("/api/v1/dashboard/stats", tags=["Dashboard"]) @@ -364,10 +541,6 @@ async def health_check(): # ==================== PR 操作 ==================== -class PRRejectRequest(BaseModel): - """拒绝 PR 请求""" - reason: str # 拒绝原因 - @app.post("/api/v1/bugs/{bug_id}/merge-pr", tags=["PR Operations"]) async def merge_pr( @@ -487,3 +660,124 @@ async def close_pr( "new_status": bug.status, } + +@app.post("/api/v1/bugs/{bug_id}/retry", tags=["PR Operations"]) +async def retry_fix( + bug_id: int, + session: AsyncSession = Depends(get_session), +): + """ + 重新尝试修复失败的 Bug + + 将 FIX_FAILED 状态的 Bug 重置为 NEW,让 repair agent 重新扫描 + """ + statement = select(ErrorLog).where(ErrorLog.id == bug_id) + results = await session.exec(statement) + bug = results.first() + + if not bug: + raise HTTPException(status_code=404, detail="Bug not found") + + if bug.status != LogStatus.FIX_FAILED: + raise HTTPException( + status_code=400, + detail=f"只能重试修复失败的 Bug,当前状态: {bug.status}" + ) + + # 重置状态为 NEW + bug.status = LogStatus.NEW + # 清除失败原因,让 agent 重新分析 + bug.failure_reason = None + + session.add(bug) + await session.commit() + await session.refresh(bug) + + return { + "message": "Bug 已重置为新发现状态,repair agent 将重新扫描修复", + "bug_id": bug.id, + "new_status": bug.status, + } + + +@app.post("/api/v1/bugs/{bug_id}/approve-fix", tags=["PR Operations"]) +async def approve_fix( + bug_id: int, + session: AsyncSession = Depends(get_session), +): + """ + 人工确认修复(不依赖 PR) + + 用于 PENDING_FIX 状态但没有关联 PR 的 Bug,人工审核后直接标记为 FIXED。 + """ + statement = select(ErrorLog).where(ErrorLog.id == bug_id) + results = await session.exec(statement) + bug = results.first() + + if not bug: + raise HTTPException(status_code=404, detail="Bug not found") + + if bug.status != LogStatus.PENDING_FIX: + raise HTTPException( + status_code=400, + detail=f"只能确认等待审核的 Bug,当前状态: {bug.status}" + ) + + bug.status = LogStatus.FIXED + bug.merged_at = datetime.utcnow() + session.add(bug) + await session.commit() + await session.refresh(bug) + + return { + "message": "修复已确认", + "bug_id": bug.id, + "new_status": bug.status, + } + + +@app.post("/api/v1/bugs/{bug_id}/reject-fix", tags=["PR Operations"]) +async def reject_fix( + bug_id: int, + request: PRRejectRequest, + session: AsyncSession = Depends(get_session), +): + """ + 人工驳回修复(不依赖 PR) + + 用于 PENDING_FIX 状态但没有关联 PR 的 Bug,驳回后重置为 NEW 让 agent 重新修复。 + """ + statement = select(ErrorLog).where(ErrorLog.id == bug_id) + results = await session.exec(statement) + bug = results.first() + + if not bug: + raise HTTPException(status_code=404, detail="Bug not found") + + if bug.status != LogStatus.PENDING_FIX: + raise HTTPException( + status_code=400, + detail=f"只能驳回等待审核的 Bug,当前状态: {bug.status}" + ) + + bug.status = LogStatus.NEW + bug.rejection_count = (bug.rejection_count or 0) + 1 + bug.last_rejected_at = datetime.utcnow() + + rejection_info = { + "rejected_at": datetime.utcnow().isoformat(), + "reason": request.reason, + } + bug.rejection_reason = json.dumps(rejection_info, ensure_ascii=False) + + session.add(bug) + await session.commit() + await session.refresh(bug) + + return { + "message": "修复已驳回,Bug 将重新修复", + "rejection_count": bug.rejection_count, + "bug_id": bug.id, + "new_status": bug.status, + } + diff --git a/app/models.py b/app/models.py index ffe8d2b..261832f 100644 --- a/app/models.py +++ b/app/models.py @@ -64,6 +64,10 @@ class ErrorLog(SQLModel, table=True): timestamp: datetime = Field(default_factory=datetime.utcnow) fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number + # Severity (1-10, AI 评估的严重等级,8+ 需人工审核) + severity: Optional[int] = Field(default=None) + severity_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) + # Status Tracking status: LogStatus = Field(default=LogStatus.NEW) retry_count: int = Field(default=0) @@ -101,16 +105,16 @@ class TaskStatusUpdate(SQLModel): message: Optional[str] = None class RepairTask(SQLModel, table=True): - """Record of a repair attempt""" + """Record of a repair attempt (one per batch, may cover multiple bugs)""" id: Optional[int] = Field(default=None, primary_key=True) - error_log_id: int = Field(foreign_key="errorlog.id") + error_log_ids: List[int] = Field(sa_column=Column(JSON)) # 关联的 Bug ID 列表 status: LogStatus project_id: str # Repair Details - ai_analysis: str = Field(sa_column=Column(Text)) # Analysis from LLM - fix_plan: str = Field(sa_column=Column(Text)) # Proposed fix plan - code_diff: str = Field(sa_column=Column(Text)) # Git diff + ai_analysis: str = Field(sa_column=Column(Text)) + fix_plan: str = Field(sa_column=Column(Text)) + code_diff: str = Field(sa_column=Column(Text)) modified_files: List[str] = Field(sa_column=Column(JSON)) # Test Results @@ -118,14 +122,19 @@ class RepairTask(SQLModel, table=True): test_passed: bool # Repair Round Tracking - repair_round: int = Field(default=1) # Which round (1, 2, 3...) + repair_round: int = Field(default=1) failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) + # PR Info + pr_url: Optional[str] = Field(default=None) + pr_number: Optional[int] = Field(default=None) + branch_name: Optional[str] = Field(default=None) + created_at: datetime = Field(default_factory=datetime.utcnow) class RepairTaskCreate(SQLModel): """Schema for creating a repair report via API""" - error_log_id: int + error_log_ids: List[int] status: LogStatus project_id: str ai_analysis: str @@ -136,4 +145,7 @@ class RepairTaskCreate(SQLModel): test_passed: bool repair_round: int = 1 failure_reason: Optional[str] = None + pr_url: Optional[str] = None + pr_number: Optional[int] = None + branch_name: Optional[str] = None diff --git a/create_test_bugs.sql b/create_test_bugs.sql new file mode 100644 index 0000000..307ec15 --- /dev/null +++ b/create_test_bugs.sql @@ -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; diff --git a/insert_new_bugs.py b/insert_new_bugs.py new file mode 100644 index 0000000..bc30f04 --- /dev/null +++ b/insert_new_bugs.py @@ -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()) diff --git a/insert_test_bugs.py b/insert_test_bugs.py new file mode 100644 index 0000000..877f1ef --- /dev/null +++ b/insert_test_bugs.py @@ -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()) diff --git a/repair_agent/agent/core.py b/repair_agent/agent/core.py index 97c78e3..6d6d32c 100644 --- a/repair_agent/agent/core.py +++ b/repair_agent/agent/core.py @@ -185,14 +185,14 @@ class RepairEngine: failure_reason = f"Claude CLI 执行失败: {output[:500]}" logger.error(f"{failure_reason} (round {round_num})") + self._upload_round_report( + bugs=bugs, project_id=project_id, round_num=round_num, + ai_analysis=output, diff="", modified_files=[], + test_output="", test_passed=False, + failure_reason=failure_reason, + status=BugStatus.FIX_FAILED, + ) for bug in bugs: - self._upload_round_report( - bug=bug, project_id=project_id, round_num=round_num, - ai_analysis=output, diff="", modified_files=[], - test_output="", test_passed=False, - failure_reason=failure_reason, - status=BugStatus.FIX_FAILED, - ) self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason) results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason)) break @@ -206,23 +206,9 @@ class RepairEngine: logger.info(f"第 {round_num} 轮修复完成,修改了 {len(modified_files)} 个文件") - # Step 3: 安全检查(仅在 Git 启用时,不重试) + # Step 3: 安全检查(软性警告,不阻断修复流程) if git_manager and not self._safety_check(modified_files, diff): - failure_reason = "安全检查未通过" - logger.warning(f"{failure_reason} (round {round_num})") - git_manager.reset_hard() - - for bug in bugs: - self._upload_round_report( - bug=bug, project_id=project_id, round_num=round_num, - ai_analysis=output, diff=diff, modified_files=modified_files, - test_output="", test_passed=False, - failure_reason=failure_reason, - status=BugStatus.FIX_FAILED, - ) - self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason) - results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason)) - break + logger.warning(f"安全检查警告 (round {round_num}):修改涉及核心文件或超出限制,继续修复流程") # Step 4: 运行 Claude 生成的测试文件 bug_ids_str = "_".join(str(b.id) for b in bugs) @@ -233,42 +219,115 @@ class RepairEngine: if not test_output: test_output = "Claude 未生成测试文件" logger.warning(f"测试文件 {test_file} 不存在,跳过测试验证") + # 没有测试文件时视为通过(无法验证) + test_passed = True # 清理临时测试文件 cleanup_repair_test_file(project_path, test_file) - for bug in bugs: - self.task_manager.update_status(bug.id, BugStatus.FIXED) - self._upload_round_report( - bug=bug, project_id=project_id, round_num=round_num, - ai_analysis=output, diff=diff, modified_files=modified_files, - test_output=test_output, - test_passed=test_passed, - failure_reason=None, - status=BugStatus.FIXED, - ) - results.append(FixResult( - bug_id=bug.id, success=True, - message=f"修复成功 (第 {round_num} 轮)", - modified_files=modified_files, diff=diff, - )) + # Step 5: 测试失败 → 记录本轮结果,进入下一轮重试 + if not test_passed: + logger.warning(f"第 {round_num} 轮测试未通过,{'进入下一轮重试' if round_num < max_rounds else '已达最大轮次'}") + last_test_output = test_output + last_diff = diff - # 自动提交、合并到 main 并推送(仅在 Git 启用时) + self._upload_round_report( + bugs=bugs, project_id=project_id, round_num=round_num, + ai_analysis=output, diff=diff, modified_files=modified_files, + test_output=test_output, test_passed=False, + failure_reason=f"第 {round_num} 轮测试失败", + status=BugStatus.FIXING, + ) + + # 重置代码变更,让下一轮从干净状态开始 + if git_manager: + git_manager.reset_hard() + + # 最后一轮还失败 → 标记为 FIX_FAILED + if round_num == max_rounds: + failure_reason = f"经过 {max_rounds} 轮修复,测试仍未通过" + for bug in bugs: + self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason) + results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason)) + + continue # 进入下一轮 + + # Step 6: 判断是否需要强制 PR 审核(severity >= 8 的 bug 必须走 PR) + max_severity = max((b.severity or 0) for b in bugs) + + needs_pr_review = max_severity >= 8 + if needs_pr_review: + logger.info(f"批次中存在高严重等级 bug (max severity={max_severity}),强制走 PR 审核流程") + + # Step 7: 测试通过 → 提交代码并创建 PR + pr_info = None if git_enabled and auto_commit and modified_files and git_manager: bug_ids = ", ".join([f"#{b.id}" for b in bugs]) git_manager.commit(f"fix: auto repair bugs {bug_ids}") logger.info("代码已提交") git_manager.push() logger.info("fix 分支已推送") - # 合并 fix 分支到 main 并推送,触发 CI/CD - if git_manager.merge_to_main_and_push(): - logger.info("已合并到 main 并推送") - else: - logger.warning("合并到 main 失败,请手动合并") - elif not git_enabled and auto_commit: - logger.info("未配置 GitHub 仓库,跳过自动提交") - break # 成功,退出循环 + # 创建 PR + bug_summary = "\n".join([ + f"- #{b.id}: {b.error.type} - {b.error.message[:80]}" + for b in bugs + ]) + severity_note = f"\n\n⚠️ **包含高严重等级 Bug (max={max_severity}/10),需人工审核**" if needs_pr_review else "" + pr_title = f"fix: auto repair bugs {bug_ids}" + pr_body = f"## 自动修复报告\n\n修复的 Bug:\n{bug_summary}{severity_note}\n\n修改文件:\n" + \ + "\n".join([f"- `{f}`" for f in modified_files]) + + success, pr_info = git_manager.create_pr(pr_title, pr_body) + if success and pr_info: + logger.info(f"PR 创建成功: {pr_info['pr_url']}") + else: + logger.warning("创建 PR 失败,修复已提交到 fix 分支") + elif not git_enabled and auto_commit: + logger.info("未配置仓库地址,跳过自动提交") + + # 根据是否有 PR 或是否需要强制审核来决定 Bug 状态 + # 高严重等级 bug:即使 PR 创建失败也不能直接标记 FIXED,保持 PENDING_FIX 等待人工处理 + if pr_info: + final_status = BugStatus.PENDING_FIX + elif needs_pr_review: + final_status = BugStatus.PENDING_FIX + logger.warning("高严重等级 bug PR 创建失败,状态设为 PENDING_FIX 等待人工处理") + else: + final_status = BugStatus.FIXED + + # 上传一条批量修复报告(含 PR 信息) + self._upload_round_report( + bugs=bugs, project_id=project_id, round_num=round_num, + ai_analysis=output, diff=diff, modified_files=modified_files, + test_output=test_output, + test_passed=True, + failure_reason=None, + status=final_status, + pr_url=pr_info["pr_url"] if pr_info else None, + pr_number=pr_info["pr_number"] if pr_info else None, + branch_name=pr_info["branch_name"] if pr_info else None, + ) + + for bug in bugs: + self.task_manager.update_status(bug.id, final_status) + + # 回写 PR 信息到 Bug + if pr_info: + self.task_manager.update_pr_info( + bug.id, + pr_info["pr_number"], + pr_info["pr_url"], + pr_info["branch_name"], + ) + + results.append(FixResult( + bug_id=bug.id, success=True, + message=f"修复成功 (第 {round_num} 轮)" + (f", PR #{pr_info['pr_number']}" if pr_info else ""), + modified_files=modified_files, diff=diff, + )) + + break # 测试通过,退出循环 except Exception as e: # 兜底:标记为 FIX_FAILED,防止死循环(可通过 retry 命令重新处理) @@ -357,7 +416,7 @@ class RepairEngine: "AI 分诊判定:非代码缺陷或无法复现", ) self._upload_round_report( - bug=bug, project_id=bug.project_id, round_num=0, + bugs=[bug], project_id=bug.project_id, round_num=0, ai_analysis=output, diff="", modified_files=[], test_output="", test_passed=False, failure_reason="AI 分诊:无法复现", @@ -399,7 +458,7 @@ class RepairEngine: def _upload_round_report( self, - bug: Bug, + bugs: list[Bug], project_id: str, round_num: int, ai_analysis: str, @@ -409,11 +468,14 @@ class RepairEngine: test_passed: bool, failure_reason: Optional[str], status: BugStatus, + pr_url: Optional[str] = None, + pr_number: Optional[int] = None, + branch_name: Optional[str] = None, ): - """上传某一轮的修复报告""" + """上传某一轮的修复报告(一次批量修复 = 一条报告)""" try: report = RepairReport( - error_log_id=bug.id, + error_log_ids=[b.id for b in bugs], status=status, project_id=project_id, ai_analysis=ai_analysis, @@ -424,6 +486,9 @@ class RepairEngine: test_passed=test_passed, repair_round=round_num, failure_reason=failure_reason, + pr_url=pr_url, + pr_number=pr_number, + branch_name=branch_name, ) self.task_manager.upload_report(report) except Exception as e: @@ -515,7 +580,7 @@ class RepairEngine: if not success: failure_reason = f"Claude CLI 执行失败: {output[:500]}" self._upload_round_report( - bug=bug, project_id=bug.project_id, round_num=round_num, + bugs=[bug], project_id=bug.project_id, round_num=round_num, ai_analysis=output, diff="", modified_files=[], test_output="", test_passed=False, failure_reason=failure_reason, status=BugStatus.FIX_FAILED, @@ -540,7 +605,7 @@ class RepairEngine: self.task_manager.update_status(bug_id, BugStatus.FIXED) self._upload_round_report( - bug=bug, project_id=bug.project_id, round_num=round_num, + bugs=[bug], project_id=bug.project_id, round_num=round_num, ai_analysis=output, diff=diff, modified_files=modified_files, test_output=test_output, test_passed=test_passed, failure_reason=None, status=BugStatus.FIXED, diff --git a/repair_agent/agent/git_manager.py b/repair_agent/agent/git_manager.py index 6b6a8da..51024f5 100644 --- a/repair_agent/agent/git_manager.py +++ b/repair_agent/agent/git_manager.py @@ -2,7 +2,8 @@ Git Manager - Git 操作管理 """ import os -from typing import Optional +from typing import Optional, Tuple +import requests from git import Repo, InvalidGitRepositoryError from loguru import logger @@ -52,6 +53,37 @@ class GitManager: except Exception as e: logger.error(f"配置 remote 失败: {e}") + def _parse_owner_repo(self) -> Tuple[str, str]: + """ + 从 github_repo 解析出 owner 和 repo 名称。 + 支持格式: + - "owner/repo" + - "https://gitea.airlabs.art/owner/repo.git" + - "https://gitea.airlabs.art/owner/repo" + - "git@gitea.airlabs.art:owner/repo.git" + """ + raw = self.github_repo.strip() + # 去掉 .git 后缀 + if raw.endswith(".git"): + raw = raw[:-4] + # HTTP(S) URL + if raw.startswith("http://") or raw.startswith("https://"): + parts = raw.rstrip("/").split("/") + if len(parts) >= 2: + return parts[-2], parts[-1] + # SSH 格式 git@host:owner/repo + elif ":" in raw and "@" in raw: + path = raw.split(":")[-1] + segments = path.split("/") + if len(segments) == 2: + return segments[0], segments[1] + # 简单 owner/repo 格式 + else: + segments = raw.split("/") + if len(segments) == 2: + return segments[0], segments[1] + return "", "" + def pull(self) -> bool: """拉取最新代码(自动切回 main/master 分支)""" if not self.repo: @@ -214,6 +246,62 @@ class GitManager: logger.error(f"合并到 main 失败: {e}") return False + def create_pr(self, title: str, body: str, base: str = "main") -> Tuple[bool, Optional[dict]]: + """ + 通过 Gitea API 创建 Pull Request + + Returns: + (success, pr_info) - pr_info 包含 pr_number, pr_url, branch_name + """ + if not self.repo or not self.github_repo: + logger.error("未配置仓库地址,无法创建 PR") + return False, None + + head_branch = self.repo.active_branch.name + + gitea_url = settings.gitea_url.rstrip("/") + token = settings.gitea_token + if not token: + logger.error("未配置 Gitea Token,无法创建 PR") + return False, None + + # 从 github_repo 提取 owner/repo,支持完整 URL 和 owner/repo 格式 + owner, repo = self._parse_owner_repo() + if not owner or not repo: + logger.error(f"无法解析仓库 owner/repo: {self.github_repo}") + return False, None + api_url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/pulls" + + try: + resp = requests.post( + api_url, + json={ + "title": title, + "body": body, + "head": head_branch, + "base": base, + }, + headers={"Authorization": f"token {token}"}, + timeout=30, + ) + + if resp.status_code in (200, 201): + data = resp.json() + pr_info = { + "pr_number": data["number"], + "pr_url": data["html_url"], + "branch_name": head_branch, + } + logger.info(f"PR 创建成功: #{data['number']} - {data['html_url']}") + return True, pr_info + else: + logger.error(f"创建 PR 失败 ({resp.status_code}): {resp.text}") + return False, None + + except Exception as e: + logger.error(f"创建 PR 请求异常: {e}") + return False, None + def reset_hard(self) -> bool: """重置所有更改""" if not self.repo: diff --git a/repair_agent/agent/task_manager.py b/repair_agent/agent/task_manager.py index 1fae198..1739863 100644 --- a/repair_agent/agent/task_manager.py +++ b/repair_agent/agent/task_manager.py @@ -18,7 +18,9 @@ class TaskManager: def fetch_pending_bugs(self, project_id: Optional[str] = None) -> list[Bug]: """ - 获取待修复的 Bug 列表(包括 NEW 和 PENDING_FIX 状态) + 获取待修复的 Bug 列表(仅 NEW 状态) + + PENDING_FIX 表示已修复、等待 PR 审核,不应被重新拉取修复。 Args: project_id: 可选,筛选特定项目 @@ -28,8 +30,8 @@ class TaskManager: """ all_bugs: dict[int, Bug] = {} - for source in ("runtime", "cicd", "deployment"): - for status in ("NEW", "PENDING_FIX"): + for source in ("runtime", "cicd", "deployment", "code_review"): + for status in ("NEW",): try: params: dict[str, str] = {"status": status, "source": source} if project_id: @@ -50,6 +52,8 @@ class TaskManager: stack_trace = item.get("stack_trace") if isinstance(stack_trace, str): stack_trace = stack_trace.split("\n") + elif isinstance(stack_trace, dict): + stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()] all_bugs[bug_id] = Bug( id=bug_id, @@ -66,12 +70,13 @@ class TaskManager: context=item.get("context"), status=BugStatus(item.get("status", "NEW")), retry_count=item.get("retry_count", 0), + severity=item.get("severity"), ) except httpx.HTTPError as e: logger.error(f"获取 {source}/{status} 状态 Bug 列表失败: {e}") bugs = list(all_bugs.values()) - logger.info(f"获取到 {len(bugs)} 个待修复 Bug(runtime + cicd + deployment)") + logger.info(f"获取到 {len(bugs)} 个待修复 Bug") return bugs def fetch_failed_bugs(self, project_id: Optional[str] = None) -> list[Bug]: @@ -79,7 +84,7 @@ class TaskManager: 获取修复失败的 Bug 列表(FIX_FAILED 状态,所有来源) """ all_bugs: dict[int, Bug] = {} - for source in ("runtime", "cicd", "deployment"): + for source in ("runtime", "cicd", "deployment", "code_review"): try: params: dict[str, str] = {"status": "FIX_FAILED", "source": source} if project_id: @@ -100,6 +105,8 @@ class TaskManager: stack_trace = item.get("stack_trace") if isinstance(stack_trace, str): stack_trace = stack_trace.split("\n") + elif isinstance(stack_trace, dict): + stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()] all_bugs[bug_id] = Bug( id=item["id"], @@ -116,6 +123,7 @@ class TaskManager: context=item.get("context"), status=BugStatus.FIX_FAILED, retry_count=item.get("retry_count", 0), + severity=item.get("severity"), ) except httpx.HTTPError as e: logger.error(f"获取 {source}/FIX_FAILED Bug 列表失败: {e}") @@ -160,10 +168,12 @@ class TaskManager: item = response.json() - # stack_trace 可能是列表或字符串 + # stack_trace 可能是列表、字符串或字典 stack_trace = item.get("stack_trace") if isinstance(stack_trace, str): stack_trace = stack_trace.split("\n") + elif isinstance(stack_trace, dict): + stack_trace = [f"{k}: {v}" for k, v in stack_trace.items()] return Bug( id=item["id"], @@ -180,8 +190,9 @@ class TaskManager: context=item.get("context"), status=BugStatus(item.get("status", "NEW")), retry_count=item.get("retry_count", 0), + severity=item.get("severity"), ) - + except httpx.HTTPError as e: logger.error(f"获取 Bug 详情失败: {e}") return None @@ -194,12 +205,44 @@ class TaskManager: json=report.model_dump() ) response.raise_for_status() - logger.info(f"Bug #{report.error_log_id} 修复报告已上传") + logger.info(f"修复报告已上传 (bugs: {report.error_log_ids})") return True except httpx.HTTPError as e: logger.error(f"上传修复报告失败: {e}") return False + def update_pr_info(self, bug_id: int, pr_number: int, pr_url: str, branch_name: str) -> bool: + """回写 PR 信息到 Bug""" + try: + response = self.client.put( + f"{self.base_url}/api/v1/bugs/{bug_id}/pr-info", + json={ + "pr_number": pr_number, + "pr_url": pr_url, + "branch_name": branch_name, + }, + ) + response.raise_for_status() + logger.info(f"Bug #{bug_id} PR 信息已更新: #{pr_number}") + return True + except httpx.HTTPError as e: + logger.error(f"更新 PR 信息失败: {e}") + return False + + def update_severity(self, bug_id: int, severity: int, severity_reason: str = "") -> bool: + """回写 Bug 严重等级""" + try: + response = self.client.put( + f"{self.base_url}/api/v1/bugs/{bug_id}/severity", + json={"severity": severity, "severity_reason": severity_reason}, + ) + response.raise_for_status() + logger.info(f"Bug #{bug_id} 严重等级已更新: {severity}/10") + return True + except httpx.HTTPError as e: + logger.error(f"更新严重等级失败: {e}") + return False + def get_project_info(self, project_id: str) -> Optional[dict]: """从 Log Center API 获取项目配置(repo_url + local_path)""" try: diff --git a/repair_agent/config/settings.py b/repair_agent/config/settings.py index d27397a..9336052 100644 --- a/repair_agent/config/settings.py +++ b/repair_agent/config/settings.py @@ -23,9 +23,12 @@ class Settings(BaseSettings): # Git git_user_name: str = Field(default="repair-agent", description="Git 用户名") git_user_email: str = Field(default="agent@airlabs.art", description="Git 邮箱") + + # Gitea + gitea_url: str = Field(default="https://gitea.airlabs.art", description="Gitea 地址") gitea_token: Optional[str] = Field(default=None, description="Gitea Token") - # GitHub 仓库地址(为空则不执行 git 操作) + # 仓库地址(owner/repo 格式,为空则不执行 git 操作) github_repo_rtc_backend: str = Field(default="", description="rtc_backend GitHub 仓库地址") github_repo_rtc_web: str = Field(default="", description="rtc_web GitHub 仓库地址") github_repo_airhub_app: str = Field(default="", description="airhub_app GitHub 仓库地址") diff --git a/repair_agent/models/bug.py b/repair_agent/models/bug.py index 252bd20..8037b2a 100644 --- a/repair_agent/models/bug.py +++ b/repair_agent/models/bug.py @@ -39,6 +39,7 @@ class Bug(BaseModel): context: Optional[dict[str, Any]] = Field(default=None, description="上下文") status: BugStatus = Field(default=BugStatus.NEW, description="状态") retry_count: int = Field(default=0, description="重试次数") + severity: Optional[int] = Field(default=None, description="严重等级 1-10") created_at: Optional[datetime] = Field(default=None, description="创建时间") def format_for_prompt(self) -> str: @@ -81,8 +82,8 @@ class BatchFixResult(BaseModel): class RepairReport(BaseModel): - """修复报告""" - error_log_id: int + """修复报告(一次批量修复 = 一条报告)""" + error_log_ids: list[int] status: BugStatus project_id: str ai_analysis: str @@ -93,4 +94,7 @@ class RepairReport(BaseModel): test_passed: bool repair_round: int = 1 failure_reason: Optional[str] = None + pr_url: Optional[str] = None + pr_number: Optional[int] = None + branch_name: Optional[str] = None diff --git a/test_bugs.sql b/test_bugs.sql new file mode 100644 index 0000000..81355a6 --- /dev/null +++ b/test_bugs.sql @@ -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); diff --git a/web/src/api.ts b/web/src/api.ts index e3d743e..7be77b0 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -27,6 +27,9 @@ export interface ErrorLog { status: string; retry_count: number; failure_reason: string | null; + // Severity (1-10) + severity?: number | null; + severity_reason?: string | null; // PR Tracking pr_number?: number | null; pr_url?: string | null; @@ -67,7 +70,7 @@ export interface PaginatedResponse { export interface RepairReport { id: number; - error_log_id: number; + error_log_ids: number[]; project_id: string; status: string; ai_analysis: string; @@ -79,6 +82,9 @@ export interface RepairReport { created_at: string; repair_round: number; failure_reason: string | null; + pr_url?: string | null; + pr_number?: number | null; + branch_name?: string | null; } // API Functions @@ -128,4 +134,21 @@ export const mergePR = (bugId: number) => export const closePR = (bugId: number, reason: string) => api.post(`/api/v1/bugs/${bugId}/close-pr`, { reason }); +export const retryFix = (bugId: number) => + api.post(`/api/v1/bugs/${bugId}/retry`); + +// Manual review operations (without PR) +export const approveFix = (bugId: number) => + api.post(`/api/v1/bugs/${bugId}/approve-fix`); + +export const rejectFix = (bugId: number, reason: string) => + api.post(`/api/v1/bugs/${bugId}/reject-fix`, { reason }); + +// Report-level approve/reject (batch operation) +export const approveReport = (reportId: number) => + api.post(`/api/v1/repair/reports/${reportId}/approve`); + +export const rejectReport = (reportId: number, reason: string) => + api.post(`/api/v1/repair/reports/${reportId}/reject`, { reason }); + export default api; diff --git a/web/src/pages/BugDetail.tsx b/web/src/pages/BugDetail.tsx index da89878..3a961fc 100644 --- a/web/src/pages/BugDetail.tsx +++ b/web/src/pages/BugDetail.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useParams, Link, useLocation } from 'react-router-dom'; import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle, Check, X, ExternalLink } from 'lucide-react'; -import { getBugDetail, triggerRepair, getRepairReportsByBug, mergePR, closePR, type ErrorLog, type RepairReport } from '../api'; +import { getBugDetail, triggerRepair, getRepairReportsByBug, mergePR, closePR, retryFix, approveFix, rejectFix, type ErrorLog, type RepairReport } from '../api'; const SOURCE_LABELS: Record = { runtime: '运行时', @@ -13,7 +13,7 @@ const STATUS_LABELS: Record = { NEW: '新发现', VERIFYING: '验证中', CANNOT_REPRODUCE: '无法复现', - PENDING_FIX: '待修复', + PENDING_FIX: '等待审核', FIXING: '修复中', FIXED: '已修复', VERIFIED: '已验证', @@ -44,6 +44,15 @@ export default function BugDetail() { const [showRejectModal, setShowRejectModal] = useState(false); const [rejectReason, setRejectReason] = useState(''); + // 人工审核操作状态(无 PR 时使用) + const [approving, setApproving] = useState(false); + const [rejecting, setRejecting] = useState(false); + const [reviewMessage, setReviewMessage] = useState(''); + + // 重试操作状态 + const [retrying, setRetrying] = useState(false); + const [retryMessage, setRetryMessage] = useState(''); + const backSearch = location.state?.fromSearch || ''; useEffect(() => { @@ -127,6 +136,64 @@ export default function BugDetail() { } }; + const handleRetry = async () => { + if (!bug) return; + setRetrying(true); + setRetryMessage(''); + try { + await retryFix(bug.id); + setBug({ ...bug, status: 'NEW', failure_reason: null }); + setRetryMessage('✅ Bug 已重置,repair agent 将重新扫描'); + } catch (error: any) { + console.error('Failed to retry:', error); + setRetryMessage(`❌ 重试失败: ${error.response?.data?.detail || error.message}`); + } finally { + setRetrying(false); + } + }; + + const handleApproveFix = async () => { + if (!bug) return; + setApproving(true); + setReviewMessage(''); + try { + await approveFix(bug.id); + setBug({ ...bug, status: 'FIXED', merged_at: new Date().toISOString() }); + setReviewMessage('修复已确认'); + } catch (error: any) { + console.error('Failed to approve fix:', error); + setReviewMessage(`确认失败: ${error.response?.data?.detail || error.message}`); + } finally { + setApproving(false); + } + }; + + const handleRejectFix = async () => { + if (!bug || !rejectReason.trim()) { + setReviewMessage('请输入驳回原因'); + return; + } + setRejecting(true); + setReviewMessage(''); + try { + await rejectFix(bug.id, rejectReason); + setBug({ + ...bug, + status: 'NEW', + rejection_count: (bug.rejection_count || 0) + 1, + last_rejected_at: new Date().toISOString() + }); + setReviewMessage('修复已驳回,Bug 将重新修复'); + setShowRejectModal(false); + setRejectReason(''); + } catch (error: any) { + console.error('Failed to reject fix:', error); + setReviewMessage(`驳回失败: ${error.response?.data?.detail || error.message}`); + } finally { + setRejecting(false); + } + }; + if (loading) { return (
@@ -142,7 +209,12 @@ export default function BugDetail() { const isRuntime = !bug.source || bug.source === 'runtime'; const canTriggerRepair = ['NEW', 'FIX_FAILED'].includes(bug.status) && isRuntime; const hasPR = !!bug.pr_url; - const canOperatePR = hasPR && bug.status === 'PENDING_FIX'; + const isPendingReview = bug.status === 'PENDING_FIX'; + const canOperatePR = hasPR && isPendingReview; + const canManualReview = !hasPR && isPendingReview; + // PR 信息只在 PENDING_FIX 或 FIXED 状态时显示 + const shouldShowPR = hasPR && (isPendingReview || bug.status === 'FIXED'); + const canRetry = bug.status === 'FIX_FAILED'; return (
@@ -164,13 +236,48 @@ export default function BugDetail() { 级别:{bug.level}
- - {STATUS_LABELS[bug.status] || bug.status} - +
+ {bug.severity != null && bug.status !== 'NEW' && ( + = 8 ? 'var(--error)' : bug.severity >= 5 ? 'var(--warning)' : 'var(--success)', + }}> + 等级 {bug.severity}/10 + + )} + + {STATUS_LABELS[bug.status] || bug.status} + +
- {/* PR 信息显示 */} - {hasPR && ( + {/* 严重等级说明 */} + {bug.severity != null && bug.severity_reason && bug.status !== 'NEW' && ( +
= 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)'}`, + }}> +
+ 严重等级:{bug.severity}/10 {bug.severity >= 8 ? '(需人工审核)' : ''} +
+
+ {bug.severity_reason} +
+
+ )} + + {/* PR 信息显示 - 仅在 PENDING_FIX 或 FIXED 状态时显示 */} + {shouldShowPR && (
Pull Request
@@ -284,8 +391,37 @@ export default function BugDetail() { )} + {/* 人工审核按钮(无 PR 时) */} + {canManualReview && ( + <> + + + + + )} + {/* 原有的触发修复按钮 */} - {!hasPR && ( + {!hasPR && !isPendingReview && ( + )} + {/* 消息显示 */} {prMessage && ( )} - {!canTriggerRepair && !repairing && !hasPR && ( + {retryMessage && ( + + {retryMessage} + + )} + + {reviewMessage && ( + + {reviewMessage} + + )} + + {!canTriggerRepair && !repairing && !hasPR && !canRetry && !canManualReview && ( {!isRuntime ? 'CI/CD 和部署错误暂不支持自动修复' @@ -452,9 +623,9 @@ export default function BugDetail() { width: '90%', boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}> -

拒绝修复

+

{hasPR ? '拒绝修复' : '驳回修复'}

- 请说明拒绝原因,Agent 将根据您的反馈重新修复: + 请说明{hasPR ? '拒绝' : '驳回'}原因,Agent 将根据您的反馈重新修复:

@@ -515,17 +686,17 @@ export default function BugDetail() { 取消
diff --git a/web/src/pages/BugList.tsx b/web/src/pages/BugList.tsx index 0458e82..42bd38f 100644 --- a/web/src/pages/BugList.tsx +++ b/web/src/pages/BugList.tsx @@ -3,7 +3,7 @@ import { Link, useSearchParams } from 'react-router-dom'; import { getBugs, getProjects, type ErrorLog, type Project } from '../api'; const STATUSES = [ - 'NEW', 'CANNOT_REPRODUCE', 'FIXING', 'FIXED', 'FIX_FAILED' + 'NEW', 'PENDING_FIX', 'CANNOT_REPRODUCE', 'FIXING', 'FIXED', 'FIX_FAILED' ]; const SOURCE_LABELS: Record = { @@ -16,7 +16,7 @@ const STATUS_LABELS: Record = { NEW: '新发现', VERIFYING: '验证中', CANNOT_REPRODUCE: '无法复现', - PENDING_FIX: '待修复', + PENDING_FIX: '等待审核', FIXING: '修复中', FIXED: '已修复', VERIFIED: '已验证', diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 0514283..59d3727 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -12,7 +12,7 @@ const STATUS_LABELS: Record = { NEW: '新发现', VERIFYING: '验证中', CANNOT_REPRODUCE: '无法复现', - PENDING_FIX: '待修复', + PENDING_FIX: '等待审核', FIXING: '修复中', FIXED: '已修复', VERIFIED: '已验证', diff --git a/web/src/pages/RepairDetail.tsx b/web/src/pages/RepairDetail.tsx index d196113..5255906 100644 --- a/web/src/pages/RepairDetail.tsx +++ b/web/src/pages/RepairDetail.tsx @@ -1,13 +1,13 @@ import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle } from 'lucide-react'; -import { getRepairReportDetail, type RepairReport } from '../api'; +import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle, Check, X, Loader2, ExternalLink } from 'lucide-react'; +import { getRepairReportDetail, approveReport, rejectReport, type RepairReport } from '../api'; const STATUS_LABELS: Record = { NEW: '新发现', VERIFYING: '验证中', CANNOT_REPRODUCE: '无法复现', - PENDING_FIX: '待修复', + PENDING_FIX: '等待审核', FIXING: '修复中', FIXED: '已修复', VERIFIED: '已验证', @@ -15,11 +15,25 @@ const STATUS_LABELS: Record = { FIX_FAILED: '修复失败', }; +const REJECT_REASON_TEMPLATES = [ + '测试覆盖不足,缺少边界条件测试', + '业务逻辑需要调整', + '代码质量不符合规范', + '需要补充异常处理', +]; + export default function RepairDetail() { const { id } = useParams<{ id: string }>(); const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); + // 审核操作状态 + const [approving, setApproving] = useState(false); + const [rejecting, setRejecting] = useState(false); + const [actionMessage, setActionMessage] = useState(''); + const [showRejectModal, setShowRejectModal] = useState(false); + const [rejectReason, setRejectReason] = useState(''); + useEffect(() => { if (id) { const fetchDetail = async () => { @@ -36,6 +50,43 @@ export default function RepairDetail() { } }, [id]); + const handleApprove = async () => { + if (!report) return; + setApproving(true); + setActionMessage(''); + try { + await approveReport(report.id); + setReport({ ...report, status: 'FIXED' }); + setActionMessage('修复已批准,所有关联缺陷已标记为已修复'); + } catch (error: any) { + console.error('Failed to approve report:', error); + setActionMessage(`批准失败: ${error.response?.data?.detail || error.message}`); + } finally { + setApproving(false); + } + }; + + const handleReject = async () => { + if (!report || !rejectReason.trim()) { + setActionMessage('请输入驳回原因'); + return; + } + setRejecting(true); + setActionMessage(''); + try { + await rejectReport(report.id, rejectReason); + setReport({ ...report, status: 'NEW' }); + setActionMessage('修复已驳回,所有关联缺陷将重新修复'); + setShowRejectModal(false); + setRejectReason(''); + } catch (error: any) { + console.error('Failed to reject report:', error); + setActionMessage(`驳回失败: ${error.response?.data?.detail || error.message}`); + } finally { + setRejecting(false); + } + }; + if (loading) { return (
@@ -48,6 +99,9 @@ export default function RepairDetail() { return
未找到该修复报告
; } + const isPendingReview = report.status === 'PENDING_FIX'; + const hasPR = !!report.pr_url; + return (
@@ -71,8 +125,15 @@ export default function RepairDetail() { {report.project_id}
- 缺陷编号 - #{report.error_log_id} + 关联缺陷 + + {report.error_log_ids?.map((bugId, idx) => ( + + {idx > 0 && ', '} + #{bugId} + + ))} +
创建时间 @@ -90,6 +151,67 @@ export default function RepairDetail() {
+ {/* PR 信息 */} + {hasPR && ( +
+

Pull Request

+
+ + PR #{report.pr_number} | {report.branch_name || 'fix branch'} + + + 查看 PR + +
+
+ )} + + {/* 审核操作区 */} + {isPendingReview && ( +
+

审核操作

+

+ {hasPR + ? '批准将合并 PR 并标记所有关联缺陷为已修复' + : '确认修复将标记所有关联缺陷为已修复'} +

+
+ + + + + {actionMessage && ( + + {actionMessage} + + )} +
+
+ )} + {report.failure_reason && (

失败原因

@@ -111,6 +233,108 @@ export default function RepairDetail() {

测试输出

{report.test_output}
+ + {/* 驳回原因模态框 */} + {showRejectModal && ( +
+
+

{hasPR ? '拒绝修复' : '驳回修复'}

+

+ 请说明原因,Agent 将根据您的反馈重新修复所有关联缺陷: +

+ +
+ +
+ {REJECT_REASON_TEMPLATES.map((template, idx) => ( + + ))} +
+
+ +