diff --git a/app/database.py b/app/database.py index 9b56094..5d5bccf 100644 --- a/app/database.py +++ b/app/database.py @@ -2,6 +2,7 @@ from sqlmodel import SQLModel, create_engine from sqlmodel.ext.asyncio.session import AsyncSession from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.orm import sessionmaker +from sqlalchemy import text import os from dotenv import load_dotenv @@ -22,6 +23,17 @@ async def init_db(): # await conn.run_sync(SQLModel.metadata.drop_all) await conn.run_sync(SQLModel.metadata.create_all) + # Migrate: add new columns to existing repairtask table + migrations = [ + "ALTER TABLE repairtask ADD COLUMN repair_round INTEGER DEFAULT 1", + "ALTER TABLE repairtask ADD COLUMN failure_reason TEXT", + ] + for sql in migrations: + try: + await conn.execute(text(sql)) + except Exception: + pass # Column already exists + async def get_session() -> AsyncSession: async_session = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False diff --git a/app/main.py b/app/main.py index eca3df6..ec484aa 100644 --- a/app/main.py +++ b/app/main.py @@ -135,24 +135,29 @@ async def get_repair_reports( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: Optional[str] = None, + error_log_id: Optional[int] = None, session: AsyncSession = Depends(get_session) ): - """Get request reports list""" + """Get repair reports list, optionally filtered by project or bug""" query = select(RepairTask).order_by(RepairTask.created_at.desc()) - + 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) + offset = (page - 1) * page_size query = query.offset(offset).limit(page_size) - + results = await session.exec(query) tasks = results.all() - + # Get total count_query = select(func.count(RepairTask.id)) 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_result = await session.exec(count_query) total = count_result.one() diff --git a/app/models.py b/app/models.py index ff43e62..a2e0c3e 100644 --- a/app/models.py +++ b/app/models.py @@ -67,17 +67,21 @@ class RepairTask(SQLModel, table=True): error_log_id: int = Field(foreign_key="errorlog.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 modified_files: List[str] = Field(sa_column=Column(JSON)) - + # Test Results test_output: str = Field(sa_column=Column(Text)) test_passed: bool - + + # Repair Round Tracking + repair_round: int = Field(default=1) # Which round (1, 2, 3...) + failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) + created_at: datetime = Field(default_factory=datetime.utcnow) class RepairTaskCreate(SQLModel): @@ -91,4 +95,6 @@ class RepairTaskCreate(SQLModel): modified_files: List[str] test_output: str test_passed: bool + repair_round: int = 1 + failure_reason: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6eb3df5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/repair_agent/agent/claude_service.py b/repair_agent/agent/claude_service.py index f1468a4..63a7bf7 100644 --- a/repair_agent/agent/claude_service.py +++ b/repair_agent/agent/claude_service.py @@ -123,6 +123,68 @@ class ClaudeService: logger.info(f"开始批量修复 {len(bugs)} 个 Bug...") return self.execute_prompt(prompt, project_path) + def retry_fix_bugs( + self, + bugs: list[Bug], + project_path: str, + previous_diff: str, + previous_test_output: str, + round_num: int, + ) -> tuple[bool, str]: + """ + 重试修复 Bug,带上次失败的上下文 + + Args: + bugs: Bug 列表 + project_path: 项目路径 + previous_diff: 上轮修复产生的 diff + previous_test_output: 上轮测试输出 + round_num: 当前轮次 + + Returns: + (成功与否, Claude 输出) + """ + prompt_parts = [ + f"你是一个自动化 Bug 修复代理。这是第 {round_num} 次修复尝试。", + "", + "## 上次修复尝试失败", + "", + "上次你进行了如下代码修改:", + "```diff", + previous_diff[:3000], + "```", + "", + "但是测试未通过,测试输出如下:", + "```", + previous_test_output[:3000], + "```", + "", + "请分析上次修复失败的原因,避免同样的错误,重新修复以下 Bug:", + "", + ] + + for bug in bugs: + prompt_parts.append(bug.format_for_prompt()) + prompt_parts.append("") + + prompt_parts.extend([ + "## 修复要求", + "1. 先分析上次测试失败的原因", + "2. 用 Grep/Glob 定位相关源代码文件", + "3. 用 Read 读取文件内容,理解上下文", + "4. 用 Edit 或 Write 直接修改代码来修复 Bug", + "5. 每个 Bug 只做最小必要的改动", + "6. 确保不破坏现有功能", + "7. 修复完成后简要说明每个 Bug 的修复方式和与上次的区别", + "", + "请立即开始修复,直接编辑文件。", + ]) + + prompt = "\n".join(prompt_parts) + + logger.info(f"开始第 {round_num} 轮修复 {len(bugs)} 个 Bug...") + return self.execute_prompt(prompt, project_path) + def analyze_bug(self, bug: Bug, project_path: str) -> tuple[bool, str]: """ 分析单个 Bug(不修复) diff --git a/repair_agent/agent/core.py b/repair_agent/agent/core.py index 7e7ba29..5b58639 100644 --- a/repair_agent/agent/core.py +++ b/repair_agent/agent/core.py @@ -83,104 +83,122 @@ class RepairEngine: # 更新所有 Bug 状态为 FIXING for bug in bugs: self.task_manager.update_status(bug.id, BugStatus.FIXING) - - # 批量修复 - success, output = self.claude_service.batch_fix_bugs(bugs, project_path) - + + # 多轮修复循环 + max_rounds = settings.max_retry_count # 默认 3 results = [] - if success: - # 获取修改的文件 + last_test_output = "" + last_diff = "" + + for round_num in range(1, max_rounds + 1): + logger.info(f"=== 第 {round_num}/{max_rounds} 轮修复 ===") + + # Step 1: 调用 Claude 修复 + if round_num == 1: + success, output = self.claude_service.batch_fix_bugs(bugs, project_path) + else: + success, output = self.claude_service.retry_fix_bugs( + bugs, project_path, + previous_diff=last_diff, + previous_test_output=last_test_output, + round_num=round_num, + ) + + if not success: + # Claude CLI 本身执行失败,不重试 + failure_reason = f"Claude CLI 执行失败: {output[:500]}" + logger.error(f"{failure_reason} (round {round_num})") + + 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 + + # Step 2: 获取变更 modified_files = [] diff = "" if git_manager: modified_files = git_manager.get_modified_files() diff = git_manager.get_diff() - logger.info(f"修复完成,修改了 {len(modified_files)} 个文件") + logger.info(f"第 {round_num} 轮修复完成,修改了 {len(modified_files)} 个文件") - # 安全检查(仅在 Git 启用时) + # Step 3: 安全检查(仅在 Git 启用时,不重试) if git_manager and not self._safety_check(modified_files, diff): - logger.warning("安全检查未通过,回滚更改") + failure_reason = "安全检查未通过" + logger.warning(f"{failure_reason} (round {round_num})") git_manager.reset_hard() for bug in bugs: - self.task_manager.update_status( - bug.id, - BugStatus.FIX_FAILED, - "安全检查未通过" + 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, ) - results.append(FixResult( - bug_id=bug.id, - success=False, - message="安全检查未通过", - )) + 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 - return BatchFixResult( - project_id=project_id, - total=len(bugs), - success_count=0, - failed_count=len(bugs), - results=results, - ) - - # 运行测试 + # Step 4: 运行测试 test_result = None if run_tests: test_runner = TestRunner(project_path, project_id) test_result = test_runner.run_full_suite() if not test_result.success: - logger.error("测试未通过,回滚更改") + last_test_output = test_result.output + last_diff = diff + is_last_round = (round_num == max_rounds) + failure_reason = f"测试未通过 (第 {round_num}/{max_rounds} 轮)" + + # 上传本轮报告 + 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_result.output, test_passed=False, + failure_reason=failure_reason, + status=BugStatus.FIX_FAILED if is_last_round else BugStatus.FIXING, + ) + + # 回滚准备下一轮或最终失败 if git_manager: git_manager.reset_hard() - for bug in bugs: - self.task_manager.update_status( - bug.id, - BugStatus.FIX_FAILED, - f"测试未通过: {test_result.output[:200]}" - ) - results.append(FixResult( - bug_id=bug.id, - success=False, - message="测试未通过", - )) + if is_last_round: + final_msg = f"经过 {max_rounds} 轮修复仍未通过测试" + for bug in bugs: + self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, final_msg) + results.append(FixResult(bug_id=bug.id, success=False, message=final_msg)) + else: + logger.info(f"第 {round_num} 轮测试未通过,准备第 {round_num + 1} 轮重试...") - return BatchFixResult( - project_id=project_id, - total=len(bugs), - success_count=0, - failed_count=len(bugs), - results=results, - ) + continue # 进入下一轮 - # 标记成功并上传报告 + # Step 5: 测试通过(或跳过测试)— 成功! for bug in bugs: self.task_manager.update_status(bug.id, BugStatus.FIXED) - - # 上传修复报告 - try: - report = RepairReport( - error_log_id=bug.id, - status=BugStatus.FIXED, - project_id=project_id, - ai_analysis=output, - fix_plan="See AI Analysis", - code_diff=diff, - modified_files=modified_files, - test_output=test_result.output if test_result else "Tests skipped", - test_passed=test_result.success if test_result else True - ) - self.task_manager.upload_report(report) - except Exception as e: - logger.error(f"上传报告失败: {e}") - + 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_result.output if test_result else "Tests skipped", + test_passed=test_result.success if test_result else True, + failure_reason=None, + status=BugStatus.FIXED, + ) results.append(FixResult( - bug_id=bug.id, - success=True, - message="修复成功", - modified_files=modified_files, - diff=diff, + bug_id=bug.id, success=True, + message=f"修复成功 (第 {round_num} 轮)", + modified_files=modified_files, diff=diff, )) # 自动提交(仅在 Git 启用时) @@ -190,22 +208,11 @@ class RepairEngine: logger.info("代码已提交") elif not git_enabled and auto_commit: logger.info("未配置 GitHub 仓库,跳过自动提交") - else: - logger.error(f"修复失败: {output}") - for bug in bugs: - self.task_manager.update_status( - bug.id, - BugStatus.FIX_FAILED, - output[:200] - ) - results.append(FixResult( - bug_id=bug.id, - success=False, - message=output[:200], - )) - + + break # 成功,退出循环 + success_count = sum(1 for r in results if r.success) - + return BatchFixResult( project_id=project_id, total=len(bugs), @@ -214,6 +221,38 @@ class RepairEngine: results=results, ) + def _upload_round_report( + self, + bug: Bug, + project_id: str, + round_num: int, + ai_analysis: str, + diff: str, + modified_files: list[str], + test_output: str, + test_passed: bool, + failure_reason: Optional[str], + status: BugStatus, + ): + """上传某一轮的修复报告""" + try: + report = RepairReport( + error_log_id=bug.id, + status=status, + project_id=project_id, + ai_analysis=ai_analysis, + fix_plan="See AI Analysis", + code_diff=diff, + modified_files=modified_files, + test_output=test_output, + test_passed=test_passed, + repair_round=round_num, + failure_reason=failure_reason, + ) + self.task_manager.upload_report(report) + except Exception as e: + logger.error(f"上传第 {round_num} 轮报告失败: {e}") + def _safety_check(self, modified_files: list[str], diff: str) -> bool: """ 安全检查 @@ -254,7 +293,7 @@ class RepairEngine: bug_id: int, run_tests: bool = True, ) -> FixResult: - """修复单个 Bug""" + """修复单个 Bug(带多轮重试)""" bug = self.task_manager.get_bug_detail(bug_id) if not bug: return FixResult( @@ -262,7 +301,7 @@ class RepairEngine: success=False, message="Bug 不存在", ) - + project_path = settings.get_project_path(bug.project_id) if not project_path: return FixResult( @@ -270,43 +309,88 @@ class RepairEngine: success=False, message=f"未找到项目路径: {bug.project_id}", ) - - # 单个 Bug 也使用批量修复接口 - success, output = self.claude_service.batch_fix_bugs([bug], project_path) - - if success: - git_manager = GitManager(project_path) + + self.task_manager.update_status(bug_id, BugStatus.FIXING) + + max_rounds = settings.max_retry_count + git_manager = GitManager(project_path) + last_test_output = "" + last_diff = "" + + for round_num in range(1, max_rounds + 1): + logger.info(f"=== Bug #{bug_id} 第 {round_num}/{max_rounds} 轮修复 ===") + + # 调用 Claude + if round_num == 1: + success, output = self.claude_service.batch_fix_bugs([bug], project_path) + else: + success, output = self.claude_service.retry_fix_bugs( + [bug], project_path, + previous_diff=last_diff, + previous_test_output=last_test_output, + round_num=round_num, + ) + + 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, + 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) + return FixResult(bug_id=bug_id, success=False, message=failure_reason) + modified_files = git_manager.get_modified_files() - + diff = git_manager.get_diff() + + # 运行测试 + test_result = None if run_tests: test_runner = TestRunner(project_path, bug.project_id) test_result = test_runner.run_full_suite() - + if not test_result.success: + last_test_output = test_result.output + last_diff = diff + is_last_round = (round_num == max_rounds) + failure_reason = f"测试未通过 (第 {round_num}/{max_rounds} 轮)" + + self._upload_round_report( + bug=bug, project_id=bug.project_id, round_num=round_num, + ai_analysis=output, diff=diff, modified_files=modified_files, + test_output=test_result.output, test_passed=False, + failure_reason=failure_reason, + status=BugStatus.FIX_FAILED if is_last_round else BugStatus.FIXING, + ) + git_manager.reset_hard() - self.task_manager.update_status( - bug_id, BugStatus.FIX_FAILED, "测试未通过" - ) - return FixResult( - bug_id=bug_id, - success=False, - message="测试未通过", - ) - + + if is_last_round: + final_msg = f"经过 {max_rounds} 轮修复仍未通过测试" + self.task_manager.update_status(bug_id, BugStatus.FIX_FAILED, final_msg) + return FixResult(bug_id=bug_id, success=False, message=final_msg) + + logger.info(f"第 {round_num} 轮测试未通过,准备第 {round_num + 1} 轮重试...") + continue + + # 测试通过 — 成功 self.task_manager.update_status(bug_id, BugStatus.FIXED) - return FixResult( - bug_id=bug_id, - success=True, - message="修复成功", - modified_files=modified_files, + self._upload_round_report( + bug=bug, project_id=bug.project_id, round_num=round_num, + ai_analysis=output, diff=diff, modified_files=modified_files, + test_output=test_result.output if test_result else "Tests skipped", + test_passed=True, failure_reason=None, status=BugStatus.FIXED, ) - else: - self.task_manager.update_status(bug_id, BugStatus.FIX_FAILED, output[:200]) return FixResult( - bug_id=bug_id, - success=False, - message=output[:200], + bug_id=bug_id, success=True, + message=f"修复成功 (第 {round_num} 轮)", + modified_files=modified_files, diff=diff, ) + + # 不应到达这里,但做安全兜底 + return FixResult(bug_id=bug_id, success=False, message="修复流程异常结束") def close(self): """关闭资源""" diff --git a/repair_agent/models/bug.py b/repair_agent/models/bug.py index ede3a09..252bd20 100644 --- a/repair_agent/models/bug.py +++ b/repair_agent/models/bug.py @@ -91,4 +91,6 @@ class RepairReport(BaseModel): modified_files: list[str] test_output: str test_passed: bool + repair_round: int = 1 + failure_reason: Optional[str] = None diff --git a/test.db b/test.db new file mode 100644 index 0000000..37d4273 Binary files /dev/null and b/test.db differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..62de0b9 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,224 @@ +""" +FastAPI API 测试 - 测试修复报告相关接口的新字段和过滤功能 +""" +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import sessionmaker + +from app.main import app +from app.database import get_session +from app.models import ErrorLog, LogStatus, RepairTask + +# 使用 SQLite 内存数据库 +TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db" +test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) + + +async def override_get_session(): + async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + yield session + + +app.dependency_overrides[get_session] = override_get_session + + +@pytest_asyncio.fixture(autouse=True) +async def setup_db(): + """每个测试前重建数据库""" + async with test_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + yield + async with test_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + + +@pytest_asyncio.fixture +async def seed_data(): + """插入测试数据""" + async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + # 创建两个 ErrorLog + log1 = ErrorLog( + id=1, project_id="rtc_backend", environment="production", + level="ERROR", error_type="ValueError", error_message="test error 1", + file_path="app/views.py", line_number=42, + stack_trace=["line1", "line2"], + fingerprint="fp001", status=LogStatus.FIX_FAILED, + ) + log2 = ErrorLog( + id=2, project_id="rtc_backend", environment="production", + level="ERROR", error_type="TypeError", error_message="test error 2", + file_path="app/models.py", line_number=10, + stack_trace=["line1"], + fingerprint="fp002", status=LogStatus.FIXED, + ) + session.add(log1) + session.add(log2) + await session.commit() + + # 创建 RepairTask 记录(含新字段) + task1 = RepairTask( + id=1, error_log_id=1, status=LogStatus.FIXING, + project_id="rtc_backend", + ai_analysis="round 1 analysis", fix_plan="plan", + code_diff="diff1", modified_files=["file1.py"], + test_output="FAILED test_foo", test_passed=False, + repair_round=1, failure_reason="测试未通过 (第 1/3 轮)", + ) + task2 = RepairTask( + id=2, error_log_id=1, status=LogStatus.FIX_FAILED, + project_id="rtc_backend", + ai_analysis="round 2 analysis", fix_plan="plan", + code_diff="diff2", modified_files=["file1.py"], + test_output="FAILED test_foo", test_passed=False, + repair_round=2, failure_reason="测试未通过 (第 2/3 轮)", + ) + task3 = RepairTask( + id=3, error_log_id=2, status=LogStatus.FIXED, + project_id="rtc_backend", + ai_analysis="fixed analysis", fix_plan="plan", + code_diff="diff3", modified_files=["file2.py"], + test_output="OK", test_passed=True, + repair_round=1, failure_reason=None, + ) + session.add_all([task1, task2, task3]) + await session.commit() + + +@pytest.mark.asyncio +async def test_create_repair_report_with_new_fields(): + """测试创建修复报告时包含 repair_round 和 failure_reason""" + async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + log = ErrorLog( + id=10, project_id="rtc_backend", environment="production", + level="ERROR", error_type="ValueError", error_message="test", + file_path="x.py", line_number=1, + stack_trace=[], fingerprint="fp_test_create", + status=LogStatus.FIXING, + ) + session.add(log) + await session.commit() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/v1/repair/reports", json={ + "error_log_id": 10, + "status": "FIX_FAILED", + "project_id": "rtc_backend", + "ai_analysis": "analysis content", + "fix_plan": "fix plan", + "code_diff": "some diff", + "modified_files": ["a.py"], + "test_output": "FAILED: test_something", + "test_passed": False, + "repair_round": 2, + "failure_reason": "测试未通过 (第 2/3 轮)", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["message"] == "Report uploaded" + report_id = data["id"] + + resp2 = await client.get(f"/api/v1/repair/reports/{report_id}") + assert resp2.status_code == 200 + report = resp2.json() + assert report["repair_round"] == 2 + assert report["failure_reason"] == "测试未通过 (第 2/3 轮)" + assert report["test_passed"] is False + + +@pytest.mark.asyncio +async def test_create_repair_report_success_no_failure_reason(): + """测试成功报告的 failure_reason 为 null""" + async_session = sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + log = ErrorLog( + id=11, project_id="rtc_backend", environment="production", + level="ERROR", error_type="ValueError", error_message="test", + file_path="x.py", line_number=1, + stack_trace=[], fingerprint="fp_test_success", + status=LogStatus.FIXING, + ) + session.add(log) + await session.commit() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/v1/repair/reports", json={ + "error_log_id": 11, + "status": "FIXED", + "project_id": "rtc_backend", + "ai_analysis": "fixed it", + "fix_plan": "plan", + "code_diff": "diff", + "modified_files": ["b.py"], + "test_output": "OK all passed", + "test_passed": True, + "repair_round": 1, + }) + assert resp.status_code == 200 + report_id = resp.json()["id"] + + resp2 = await client.get(f"/api/v1/repair/reports/{report_id}") + report = resp2.json() + assert report["repair_round"] == 1 + assert report["failure_reason"] is None + assert report["test_passed"] is True + + +@pytest.mark.asyncio +async def test_filter_repair_reports_by_error_log_id(seed_data): + """测试按 error_log_id 过滤修复报告""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/repair/reports", params={"error_log_id": 1}) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert len(data["items"]) == 2 + for item in data["items"]: + assert item["error_log_id"] == 1 + + resp2 = await client.get("/api/v1/repair/reports", params={"error_log_id": 2}) + data2 = resp2.json() + assert data2["total"] == 1 + assert data2["items"][0]["error_log_id"] == 2 + assert data2["items"][0]["repair_round"] == 1 + assert data2["items"][0]["failure_reason"] is None + + +@pytest.mark.asyncio +async def test_filter_repair_reports_no_results(seed_data): + """测试按不存在的 error_log_id 查询返回空""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/repair/reports", params={"error_log_id": 999}) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["items"] == [] + + +@pytest.mark.asyncio +async def test_repair_report_detail_has_new_fields(seed_data): + """测试修复报告详情包含新字段""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/repair/reports/1") + assert resp.status_code == 200 + report = resp.json() + assert report["repair_round"] == 1 + assert report["failure_reason"] == "测试未通过 (第 1/3 轮)" + + resp2 = await client.get("/api/v1/repair/reports/3") + report2 = resp2.json() + assert report2["repair_round"] == 1 + assert report2["failure_reason"] is None + assert report2["test_passed"] is True diff --git a/tests/test_repair_engine.py b/tests/test_repair_engine.py new file mode 100644 index 0000000..86139ef --- /dev/null +++ b/tests/test_repair_engine.py @@ -0,0 +1,317 @@ +""" +Repair Engine 核心逻辑测试 - 测试 3 轮修复重试机制和失败报告上传 +""" +import pytest +from unittest.mock import MagicMock, patch, call +from repair_agent.models import Bug, BugStatus, ErrorInfo, FixResult, BatchFixResult, RepairReport +from repair_agent.agent.core import RepairEngine +from repair_agent.agent.test_runner import TestResult + + +def make_bug(bug_id=1, project_id="rtc_backend"): + """创建测试用 Bug 对象""" + return Bug( + id=bug_id, + project_id=project_id, + environment="production", + level="ERROR", + error=ErrorInfo( + type="ValueError", + message="test error", + file_path="app/views.py", + line_number=42, + stack_trace=["traceback line 1"], + ), + status=BugStatus.NEW, + retry_count=0, + ) + + +class TestRepairEngineRetryLoop: + """测试修复引擎的多轮重试逻辑""" + + def setup_method(self): + """每个测试前初始化""" + self.engine = RepairEngine.__new__(RepairEngine) + self.engine.task_manager = MagicMock() + self.engine.claude_service = MagicMock() + + @patch("repair_agent.agent.core.settings") + @patch("repair_agent.agent.core.TestRunner") + @patch("repair_agent.agent.core.GitManager") + def test_fix_project_success_first_round(self, MockGitManager, MockTestRunner, mock_settings): + """测试第 1 轮就修复成功""" + mock_settings.get_project_path.return_value = "/tmp/project" + mock_settings.is_git_enabled.return_value = False + mock_settings.max_retry_count = 3 + + bug = make_bug() + self.engine.task_manager.fetch_pending_bugs.return_value = [bug] + self.engine.claude_service.batch_fix_bugs.return_value = (True, "fixed output") + + mock_runner = MagicMock() + mock_runner.run_full_suite.return_value = TestResult( + success=True, output="OK", failed_tests=[], passed_count=5, failed_count=0 + ) + MockTestRunner.return_value = mock_runner + + result = self.engine.fix_project("rtc_backend", run_tests=True) + + assert result.success_count == 1 + assert result.failed_count == 0 + assert "修复成功 (第 1 轮)" in result.results[0].message + + # Claude 只调用了一次 + self.engine.claude_service.batch_fix_bugs.assert_called_once() + self.engine.claude_service.retry_fix_bugs.assert_not_called() + + # 状态更新:FIXING → FIXED + self.engine.task_manager.update_status.assert_any_call(bug.id, BugStatus.FIXING) + self.engine.task_manager.update_status.assert_any_call(bug.id, BugStatus.FIXED) + + # 上传了成功报告 + self.engine.task_manager.upload_report.assert_called_once() + uploaded = self.engine.task_manager.upload_report.call_args[0][0] + assert uploaded.repair_round == 1 + assert uploaded.failure_reason is None + assert uploaded.status == BugStatus.FIXED + + @patch("repair_agent.agent.core.settings") + @patch("repair_agent.agent.core.TestRunner") + @patch("repair_agent.agent.core.GitManager") + def test_fix_project_success_second_round(self, MockGitManager, MockTestRunner, mock_settings): + """测试第 1 轮失败,第 2 轮修复成功""" + mock_settings.get_project_path.return_value = "/tmp/project" + mock_settings.is_git_enabled.return_value = False + mock_settings.max_retry_count = 3 + + bug = make_bug() + self.engine.task_manager.fetch_pending_bugs.return_value = [bug] + self.engine.claude_service.batch_fix_bugs.return_value = (True, "round 1 output") + self.engine.claude_service.retry_fix_bugs.return_value = (True, "round 2 output") + + fail_result = TestResult( + success=False, output="FAILED: test_foo", + failed_tests=["test_foo"], passed_count=4, failed_count=1, + ) + pass_result = TestResult( + success=True, output="OK", failed_tests=[], passed_count=5, failed_count=0, + ) + + mock_runner = MagicMock() + mock_runner.run_full_suite.side_effect = [fail_result, pass_result] + MockTestRunner.return_value = mock_runner + + result = self.engine.fix_project("rtc_backend", run_tests=True) + + assert result.success_count == 1 + assert result.failed_count == 0 + assert "修复成功 (第 2 轮)" in result.results[0].message + + # Claude: batch_fix_bugs 第1轮, retry_fix_bugs 第2轮 + self.engine.claude_service.batch_fix_bugs.assert_called_once() + self.engine.claude_service.retry_fix_bugs.assert_called_once() + retry_args = self.engine.claude_service.retry_fix_bugs.call_args + assert retry_args.kwargs["round_num"] == 2 + assert "FAILED: test_foo" in retry_args.kwargs["previous_test_output"] + + # 上传了 2 次报告:第1轮失败 + 第2轮成功 + assert self.engine.task_manager.upload_report.call_count == 2 + reports = [c[0][0] for c in self.engine.task_manager.upload_report.call_args_list] + assert reports[0].repair_round == 1 + assert reports[0].failure_reason is not None + assert reports[0].status == BugStatus.FIXING # 中间轮 + assert reports[1].repair_round == 2 + assert reports[1].failure_reason is None + assert reports[1].status == BugStatus.FIXED + + @patch("repair_agent.agent.core.settings") + @patch("repair_agent.agent.core.TestRunner") + @patch("repair_agent.agent.core.GitManager") + def test_fix_project_all_rounds_fail(self, MockGitManager, MockTestRunner, mock_settings): + """测试 3 轮都失败""" + mock_settings.get_project_path.return_value = "/tmp/project" + mock_settings.is_git_enabled.return_value = False + mock_settings.max_retry_count = 3 + + bug = make_bug() + self.engine.task_manager.fetch_pending_bugs.return_value = [bug] + self.engine.claude_service.batch_fix_bugs.return_value = (True, "round 1") + self.engine.claude_service.retry_fix_bugs.side_effect = [ + (True, "round 2"), + (True, "round 3"), + ] + + fail_result = TestResult( + success=False, output="FAILED", + failed_tests=["test_foo"], passed_count=0, failed_count=1, + ) + mock_runner = MagicMock() + mock_runner.run_full_suite.return_value = fail_result + MockTestRunner.return_value = mock_runner + + result = self.engine.fix_project("rtc_backend", run_tests=True) + + assert result.success_count == 0 + assert result.failed_count == 1 + assert "3 轮修复仍未通过测试" in result.results[0].message + + # 上传了 3 次报告 + assert self.engine.task_manager.upload_report.call_count == 3 + reports = [c[0][0] for c in self.engine.task_manager.upload_report.call_args_list] + + assert reports[0].repair_round == 1 + assert reports[0].status == BugStatus.FIXING + assert reports[1].repair_round == 2 + assert reports[1].status == BugStatus.FIXING + assert reports[2].repair_round == 3 + assert reports[2].status == BugStatus.FIX_FAILED + + # 最终状态为 FIX_FAILED + final_call = self.engine.task_manager.update_status.call_args_list[-1] + assert final_call[0][1] == BugStatus.FIX_FAILED + + @patch("repair_agent.agent.core.settings") + @patch("repair_agent.agent.core.GitManager") + def test_fix_project_claude_cli_failure(self, MockGitManager, mock_settings): + """测试 Claude CLI 执行失败不重试""" + mock_settings.get_project_path.return_value = "/tmp/project" + mock_settings.is_git_enabled.return_value = False + mock_settings.max_retry_count = 3 + + bug = make_bug() + self.engine.task_manager.fetch_pending_bugs.return_value = [bug] + self.engine.claude_service.batch_fix_bugs.return_value = (False, "timeout error") + + result = self.engine.fix_project("rtc_backend", run_tests=True) + + assert result.success_count == 0 + assert result.failed_count == 1 + assert "Claude CLI 执行失败" in result.results[0].message + + # 只调用了第1轮,没有重试 + self.engine.claude_service.batch_fix_bugs.assert_called_once() + self.engine.claude_service.retry_fix_bugs.assert_not_called() + + # 上传了失败报告 + self.engine.task_manager.upload_report.assert_called_once() + uploaded = self.engine.task_manager.upload_report.call_args[0][0] + assert uploaded.failure_reason.startswith("Claude CLI 执行失败") + assert uploaded.repair_round == 1 + + +class TestFixSingleBugRetry: + """测试单个 Bug 修复的多轮重试""" + + def setup_method(self): + self.engine = RepairEngine.__new__(RepairEngine) + self.engine.task_manager = MagicMock() + self.engine.claude_service = MagicMock() + + @patch("repair_agent.agent.core.settings") + @patch("repair_agent.agent.core.TestRunner") + @patch("repair_agent.agent.core.GitManager") + def test_single_bug_success_third_round(self, MockGitManager, MockTestRunner, mock_settings): + """测试单个 Bug 第 3 轮修复成功""" + mock_settings.get_project_path.return_value = "/tmp/project" + mock_settings.max_retry_count = 3 + + bug = make_bug() + self.engine.task_manager.get_bug_detail.return_value = bug + + mock_git = MagicMock() + mock_git.get_modified_files.return_value = ["file.py"] + mock_git.get_diff.return_value = "+fix" + MockGitManager.return_value = mock_git + + self.engine.claude_service.batch_fix_bugs.return_value = (True, "r1") + self.engine.claude_service.retry_fix_bugs.side_effect = [ + (True, "r2"), + (True, "r3"), + ] + + fail_result = TestResult(success=False, output="FAIL", failed_tests=["t1"], passed_count=0, failed_count=1) + pass_result = TestResult(success=True, output="OK", failed_tests=[], passed_count=5, failed_count=0) + + mock_runner = MagicMock() + mock_runner.run_full_suite.side_effect = [fail_result, fail_result, pass_result] + MockTestRunner.return_value = mock_runner + + result = self.engine.fix_single_bug(1, run_tests=True) + + assert result.success is True + assert "修复成功 (第 3 轮)" in result.message + + # 3 次报告上传 + assert self.engine.task_manager.upload_report.call_count == 3 + + @patch("repair_agent.agent.core.settings") + @patch("repair_agent.agent.core.TestRunner") + @patch("repair_agent.agent.core.GitManager") + def test_single_bug_all_fail(self, MockGitManager, MockTestRunner, mock_settings): + """测试单个 Bug 3 轮全部失败""" + mock_settings.get_project_path.return_value = "/tmp/project" + mock_settings.max_retry_count = 3 + + bug = make_bug() + self.engine.task_manager.get_bug_detail.return_value = bug + + mock_git = MagicMock() + mock_git.get_modified_files.return_value = ["file.py"] + mock_git.get_diff.return_value = "+fix" + MockGitManager.return_value = mock_git + + self.engine.claude_service.batch_fix_bugs.return_value = (True, "r1") + self.engine.claude_service.retry_fix_bugs.return_value = (True, "retry output") + + fail_result = TestResult(success=False, output="FAIL", failed_tests=["t1"], passed_count=0, failed_count=1) + mock_runner = MagicMock() + mock_runner.run_full_suite.return_value = fail_result + MockTestRunner.return_value = mock_runner + + result = self.engine.fix_single_bug(1, run_tests=True) + + assert result.success is False + assert "3 轮修复仍未通过测试" in result.message + assert self.engine.task_manager.upload_report.call_count == 3 + + +class TestRepairReportModel: + """测试 RepairReport 模型新字段""" + + def test_repair_report_with_failure(self): + """测试带失败原因的修复报告""" + report = RepairReport( + error_log_id=1, + status=BugStatus.FIX_FAILED, + project_id="rtc_backend", + ai_analysis="analysis", + fix_plan="plan", + code_diff="diff", + modified_files=["a.py"], + test_output="FAILED", + test_passed=False, + repair_round=2, + failure_reason="测试未通过", + ) + data = report.model_dump() + assert data["repair_round"] == 2 + assert data["failure_reason"] == "测试未通过" + assert data["test_passed"] is False + + def test_repair_report_success_defaults(self): + """测试成功报告默认值""" + report = RepairReport( + error_log_id=1, + status=BugStatus.FIXED, + project_id="rtc_backend", + ai_analysis="fixed", + fix_plan="plan", + code_diff="diff", + modified_files=[], + test_output="OK", + test_passed=True, + ) + data = report.model_dump() + assert data["repair_round"] == 1 # 默认 + assert data["failure_reason"] is None # 默认 diff --git a/web/src/api.ts b/web/src/api.ts index faa5d29..e0a0b3d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -54,6 +54,8 @@ export interface RepairReport { test_output: string; test_passed: boolean; created_at: string; + repair_round: number; + failure_reason: string | null; } // API Functions @@ -84,4 +86,9 @@ export const getRepairReports = (params: { export const getRepairReportDetail = (id: number) => api.get(`/api/v1/repair/reports/${id}`); +export const getRepairReportsByBug = (errorLogId: number) => + api.get>('/api/v1/repair/reports', { + params: { error_log_id: errorLogId, page_size: 100 } + }); + export default api; diff --git a/web/src/pages/BugDetail.tsx b/web/src/pages/BugDetail.tsx index 12f37f5..c3e23ca 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 } from 'lucide-react'; -import { getBugDetail, triggerRepair, type ErrorLog } from '../api'; +import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History } from 'lucide-react'; +import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api'; const STATUS_LABELS: Record = { NEW: '新发现', @@ -22,6 +22,7 @@ export default function BugDetail() { const [loading, setLoading] = useState(true); const [repairing, setRepairing] = useState(false); const [repairMessage, setRepairMessage] = useState(''); + const [repairHistory, setRepairHistory] = useState([]); const backSearch = location.state?.fromSearch || ''; @@ -40,6 +41,14 @@ export default function BugDetail() { fetchBug(); }, [id]); + useEffect(() => { + if (id) { + getRepairReportsByBug(parseInt(id)).then(res => { + setRepairHistory(res.data.items); + }).catch(console.error); + } + }, [id]); + const handleTriggerRepair = async () => { if (!bug) return; setRepairing(true); @@ -183,6 +192,57 @@ export default function BugDetail() { + + {repairHistory.length > 0 && ( +
+
+ + 修复历史 ({repairHistory.length} 次尝试) +
+
+ + + + + + + + + + + + + {repairHistory.map(report => ( + + + + + + + + + ))} + +
轮次状态测试结果失败原因时间操作
第 {report.repair_round} 轮 + + {STATUS_LABELS[report.status] || report.status} + + + + {report.test_passed ? '通过' : '失败'} + + + {report.failure_reason || '-'} + + {new Date(report.created_at).toLocaleString()} + + + 查看 + +
+
+
+ )} ); } diff --git a/web/src/pages/RepairDetail.tsx b/web/src/pages/RepairDetail.tsx index 247da4e..d196113 100644 --- a/web/src/pages/RepairDetail.tsx +++ b/web/src/pages/RepairDetail.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { ArrowLeft, Bot, FileCode, FlaskConical } from 'lucide-react'; +import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle } from 'lucide-react'; import { getRepairReportDetail, type RepairReport } from '../api'; const STATUS_LABELS: Record = { @@ -78,6 +78,10 @@ export default function RepairDetail() { 创建时间 {new Date(report.created_at).toLocaleString()} +
+ 修复轮次 + 第 {report.repair_round} 轮 +
测试结果 @@ -86,6 +90,13 @@ export default function RepairDetail() {
+ {report.failure_reason && ( +
+

失败原因

+
{report.failure_reason}
+
+ )} +

AI 分析

{report.ai_analysis}
diff --git a/web/src/pages/RepairList.tsx b/web/src/pages/RepairList.tsx index b9826d4..cf8d2be 100644 --- a/web/src/pages/RepairList.tsx +++ b/web/src/pages/RepairList.tsx @@ -96,6 +96,7 @@ export default function RepairList() { 编号 项目 缺陷编号 + 轮次 修改文件数 测试结果 状态 @@ -113,6 +114,7 @@ export default function RepairList() { #{report.error_log_id} + 第 {report.repair_round} 轮 {report.modified_files.length} 个文件