fix bug three
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m31s

This commit is contained in:
zyc 2026-02-13 14:04:21 +08:00
parent fe62f9ca81
commit e9ba36db92
15 changed files with 919 additions and 125 deletions

View File

@ -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

View File

@ -135,13 +135,16 @@ 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)
@ -153,6 +156,8 @@ async def get_repair_reports(
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()

View File

@ -78,6 +78,10 @@ class RepairTask(SQLModel, table=True):
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

2
pyproject.toml Normal file
View File

@ -0,0 +1,2 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"

View File

@ -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不修复

View File

@ -84,103 +84,121 @@ class RepairEngine:
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,19 +208,8 @@ 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)
@ -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(
@ -271,43 +310,88 @@ class RepairEngine:
message=f"未找到项目路径: {bug.project_id}",
)
# 单个 Bug 也使用批量修复接口
success, output = self.claude_service.batch_fix_bugs([bug], 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)
if success:
git_manager = GitManager(project_path)
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:
git_manager.reset_hard()
self.task_manager.update_status(
bug_id, BugStatus.FIX_FAILED, "测试未通过"
)
return FixResult(
bug_id=bug_id,
success=False,
message="测试未通过",
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()
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):
"""关闭资源"""
self.task_manager.close()

View File

@ -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

BIN
test.db Normal file

Binary file not shown.

0
tests/__init__.py Normal file
View File

224
tests/test_api.py Normal file
View File

@ -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

317
tests/test_repair_engine.py Normal file
View File

@ -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 # 默认

View File

@ -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<RepairReport>(`/api/v1/repair/reports/${id}`);
export const getRepairReportsByBug = (errorLogId: number) =>
api.get<PaginatedResponse<RepairReport>>('/api/v1/repair/reports', {
params: { error_log_id: errorLogId, page_size: 100 }
});
export default api;

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, Link, useLocation } from 'react-router-dom';
import { ArrowLeft, Play, Loader2, FileCode, GitCommit } 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<string, string> = {
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<RepairReport[]>([]);
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() {
</tbody>
</table>
</div>
{repairHistory.length > 0 && (
<div className="detail-card">
<div className="detail-section-title" style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<History size={14} />
({repairHistory.length} )
</div>
<div className="table-container">
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{repairHistory.map(report => (
<tr key={report.id}>
<td> {report.repair_round} </td>
<td>
<span className={`status-badge status-${report.status}`}>
{STATUS_LABELS[report.status] || report.status}
</span>
</td>
<td>
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
{report.test_passed ? '通过' : '失败'}
</span>
</td>
<td style={{ maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{report.failure_reason || '-'}
</td>
<td className="cell-secondary">
{new Date(report.created_at).toLocaleString()}
</td>
<td>
<Link to={`/repairs/${report.id}`} className="btn-link">
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View File

@ -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<string, string> = {
@ -78,6 +78,10 @@ export default function RepairDetail() {
<span></span>
<span>{new Date(report.created_at).toLocaleString()}</span>
</div>
<div className="info-row">
<span></span>
<strong> {report.repair_round} </strong>
</div>
<div className="info-row">
<span></span>
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
@ -86,6 +90,13 @@ export default function RepairDetail() {
</div>
</div>
{report.failure_reason && (
<div className="card" style={{ borderLeft: '3px solid var(--error)' }}>
<h2><AlertTriangle size={16} /> </h2>
<pre className="code-block error">{report.failure_reason}</pre>
</div>
)}
<div className="card">
<h2><Bot size={16} /> AI </h2>
<pre className="code-block neutral">{report.ai_analysis}</pre>

View File

@ -96,6 +96,7 @@ export default function RepairList() {
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@ -113,6 +114,7 @@ export default function RepairList() {
#{report.error_log_id}
</Link>
</td>
<td> {report.repair_round} </td>
<td>{report.modified_files.length} </td>
<td>
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>