fix bug three
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m31s
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m31s
This commit is contained in:
parent
fe62f9ca81
commit
e9ba36db92
@ -2,6 +2,7 @@ from sqlmodel import SQLModel, create_engine
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy import text
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
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.drop_all)
|
||||||
await conn.run_sync(SQLModel.metadata.create_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 def get_session() -> AsyncSession:
|
||||||
async_session = sessionmaker(
|
async_session = sessionmaker(
|
||||||
engine, class_=AsyncSession, expire_on_commit=False
|
engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
|||||||
@ -135,13 +135,16 @@ async def get_repair_reports(
|
|||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
|
error_log_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session)
|
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())
|
query = select(RepairTask).order_by(RepairTask.created_at.desc())
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.where(RepairTask.project_id == project_id)
|
query = query.where(RepairTask.project_id == project_id)
|
||||||
|
if error_log_id:
|
||||||
|
query = query.where(RepairTask.error_log_id == error_log_id)
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
query = query.offset(offset).limit(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))
|
count_query = select(func.count(RepairTask.id))
|
||||||
if project_id:
|
if project_id:
|
||||||
count_query = count_query.where(RepairTask.project_id == project_id)
|
count_query = count_query.where(RepairTask.project_id == project_id)
|
||||||
|
if error_log_id:
|
||||||
|
count_query = count_query.where(RepairTask.error_log_id == error_log_id)
|
||||||
count_result = await session.exec(count_query)
|
count_result = await session.exec(count_query)
|
||||||
total = count_result.one()
|
total = count_result.one()
|
||||||
|
|
||||||
|
|||||||
@ -78,6 +78,10 @@ class RepairTask(SQLModel, table=True):
|
|||||||
test_output: str = Field(sa_column=Column(Text))
|
test_output: str = Field(sa_column=Column(Text))
|
||||||
test_passed: bool
|
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)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
class RepairTaskCreate(SQLModel):
|
class RepairTaskCreate(SQLModel):
|
||||||
@ -91,4 +95,6 @@ class RepairTaskCreate(SQLModel):
|
|||||||
modified_files: List[str]
|
modified_files: List[str]
|
||||||
test_output: str
|
test_output: str
|
||||||
test_passed: bool
|
test_passed: bool
|
||||||
|
repair_round: int = 1
|
||||||
|
failure_reason: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
2
pyproject.toml
Normal file
2
pyproject.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
@ -123,6 +123,68 @@ class ClaudeService:
|
|||||||
logger.info(f"开始批量修复 {len(bugs)} 个 Bug...")
|
logger.info(f"开始批量修复 {len(bugs)} 个 Bug...")
|
||||||
return self.execute_prompt(prompt, project_path)
|
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]:
|
def analyze_bug(self, bug: Bug, project_path: str) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
分析单个 Bug(不修复)
|
分析单个 Bug(不修复)
|
||||||
|
|||||||
@ -84,103 +84,121 @@ class RepairEngine:
|
|||||||
for bug in bugs:
|
for bug in bugs:
|
||||||
self.task_manager.update_status(bug.id, BugStatus.FIXING)
|
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 = []
|
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 = []
|
modified_files = []
|
||||||
diff = ""
|
diff = ""
|
||||||
if git_manager:
|
if git_manager:
|
||||||
modified_files = git_manager.get_modified_files()
|
modified_files = git_manager.get_modified_files()
|
||||||
diff = git_manager.get_diff()
|
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):
|
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()
|
git_manager.reset_hard()
|
||||||
|
|
||||||
for bug in bugs:
|
for bug in bugs:
|
||||||
self.task_manager.update_status(
|
self._upload_round_report(
|
||||||
bug.id,
|
bug=bug, project_id=project_id, round_num=round_num,
|
||||||
BugStatus.FIX_FAILED,
|
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(
|
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason)
|
||||||
bug_id=bug.id,
|
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
|
||||||
success=False,
|
break
|
||||||
message="安全检查未通过",
|
|
||||||
))
|
|
||||||
|
|
||||||
return BatchFixResult(
|
# Step 4: 运行测试
|
||||||
project_id=project_id,
|
|
||||||
total=len(bugs),
|
|
||||||
success_count=0,
|
|
||||||
failed_count=len(bugs),
|
|
||||||
results=results,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 运行测试
|
|
||||||
test_result = None
|
test_result = None
|
||||||
if run_tests:
|
if run_tests:
|
||||||
test_runner = TestRunner(project_path, project_id)
|
test_runner = TestRunner(project_path, project_id)
|
||||||
test_result = test_runner.run_full_suite()
|
test_result = test_runner.run_full_suite()
|
||||||
|
|
||||||
if not test_result.success:
|
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:
|
if git_manager:
|
||||||
git_manager.reset_hard()
|
git_manager.reset_hard()
|
||||||
|
|
||||||
|
if is_last_round:
|
||||||
|
final_msg = f"经过 {max_rounds} 轮修复仍未通过测试"
|
||||||
for bug in bugs:
|
for bug in bugs:
|
||||||
self.task_manager.update_status(
|
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, final_msg)
|
||||||
bug.id,
|
results.append(FixResult(bug_id=bug.id, success=False, message=final_msg))
|
||||||
BugStatus.FIX_FAILED,
|
else:
|
||||||
f"测试未通过: {test_result.output[:200]}"
|
logger.info(f"第 {round_num} 轮测试未通过,准备第 {round_num + 1} 轮重试...")
|
||||||
)
|
|
||||||
results.append(FixResult(
|
|
||||||
bug_id=bug.id,
|
|
||||||
success=False,
|
|
||||||
message="测试未通过",
|
|
||||||
))
|
|
||||||
|
|
||||||
return BatchFixResult(
|
continue # 进入下一轮
|
||||||
project_id=project_id,
|
|
||||||
total=len(bugs),
|
|
||||||
success_count=0,
|
|
||||||
failed_count=len(bugs),
|
|
||||||
results=results,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 标记成功并上传报告
|
# Step 5: 测试通过(或跳过测试)— 成功!
|
||||||
for bug in bugs:
|
for bug in bugs:
|
||||||
self.task_manager.update_status(bug.id, BugStatus.FIXED)
|
self.task_manager.update_status(bug.id, BugStatus.FIXED)
|
||||||
|
self._upload_round_report(
|
||||||
# 上传修复报告
|
bug=bug, project_id=project_id, round_num=round_num,
|
||||||
try:
|
ai_analysis=output, diff=diff, modified_files=modified_files,
|
||||||
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_output=test_result.output if test_result else "Tests skipped",
|
||||||
test_passed=test_result.success if test_result else True
|
test_passed=test_result.success if test_result else True,
|
||||||
|
failure_reason=None,
|
||||||
|
status=BugStatus.FIXED,
|
||||||
)
|
)
|
||||||
self.task_manager.upload_report(report)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"上传报告失败: {e}")
|
|
||||||
|
|
||||||
results.append(FixResult(
|
results.append(FixResult(
|
||||||
bug_id=bug.id,
|
bug_id=bug.id, success=True,
|
||||||
success=True,
|
message=f"修复成功 (第 {round_num} 轮)",
|
||||||
message="修复成功",
|
modified_files=modified_files, diff=diff,
|
||||||
modified_files=modified_files,
|
|
||||||
diff=diff,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# 自动提交(仅在 Git 启用时)
|
# 自动提交(仅在 Git 启用时)
|
||||||
@ -190,19 +208,8 @@ class RepairEngine:
|
|||||||
logger.info("代码已提交")
|
logger.info("代码已提交")
|
||||||
elif not git_enabled and auto_commit:
|
elif not git_enabled and auto_commit:
|
||||||
logger.info("未配置 GitHub 仓库,跳过自动提交")
|
logger.info("未配置 GitHub 仓库,跳过自动提交")
|
||||||
else:
|
|
||||||
logger.error(f"修复失败: {output}")
|
break # 成功,退出循环
|
||||||
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],
|
|
||||||
))
|
|
||||||
|
|
||||||
success_count = sum(1 for r in results if r.success)
|
success_count = sum(1 for r in results if r.success)
|
||||||
|
|
||||||
@ -214,6 +221,38 @@ class RepairEngine:
|
|||||||
results=results,
|
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:
|
def _safety_check(self, modified_files: list[str], diff: str) -> bool:
|
||||||
"""
|
"""
|
||||||
安全检查
|
安全检查
|
||||||
@ -254,7 +293,7 @@ class RepairEngine:
|
|||||||
bug_id: int,
|
bug_id: int,
|
||||||
run_tests: bool = True,
|
run_tests: bool = True,
|
||||||
) -> FixResult:
|
) -> FixResult:
|
||||||
"""修复单个 Bug"""
|
"""修复单个 Bug(带多轮重试)"""
|
||||||
bug = self.task_manager.get_bug_detail(bug_id)
|
bug = self.task_manager.get_bug_detail(bug_id)
|
||||||
if not bug:
|
if not bug:
|
||||||
return FixResult(
|
return FixResult(
|
||||||
@ -271,43 +310,88 @@ class RepairEngine:
|
|||||||
message=f"未找到项目路径: {bug.project_id}",
|
message=f"未找到项目路径: {bug.project_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 单个 Bug 也使用批量修复接口
|
self.task_manager.update_status(bug_id, BugStatus.FIXING)
|
||||||
success, output = self.claude_service.batch_fix_bugs([bug], project_path)
|
|
||||||
|
|
||||||
if success:
|
max_rounds = settings.max_retry_count
|
||||||
git_manager = GitManager(project_path)
|
git_manager = GitManager(project_path)
|
||||||
modified_files = git_manager.get_modified_files()
|
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:
|
if run_tests:
|
||||||
test_runner = TestRunner(project_path, bug.project_id)
|
test_runner = TestRunner(project_path, bug.project_id)
|
||||||
test_result = test_runner.run_full_suite()
|
test_result = test_runner.run_full_suite()
|
||||||
|
|
||||||
if not test_result.success:
|
if not test_result.success:
|
||||||
git_manager.reset_hard()
|
last_test_output = test_result.output
|
||||||
self.task_manager.update_status(
|
last_diff = diff
|
||||||
bug_id, BugStatus.FIX_FAILED, "测试未通过"
|
is_last_round = (round_num == max_rounds)
|
||||||
)
|
failure_reason = f"测试未通过 (第 {round_num}/{max_rounds} 轮)"
|
||||||
return FixResult(
|
|
||||||
bug_id=bug_id,
|
self._upload_round_report(
|
||||||
success=False,
|
bug=bug, project_id=bug.project_id, round_num=round_num,
|
||||||
message="测试未通过",
|
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)
|
self.task_manager.update_status(bug_id, BugStatus.FIXED)
|
||||||
return FixResult(
|
self._upload_round_report(
|
||||||
bug_id=bug_id,
|
bug=bug, project_id=bug.project_id, round_num=round_num,
|
||||||
success=True,
|
ai_analysis=output, diff=diff, modified_files=modified_files,
|
||||||
message="修复成功",
|
test_output=test_result.output if test_result else "Tests skipped",
|
||||||
modified_files=modified_files,
|
test_passed=True, failure_reason=None, status=BugStatus.FIXED,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.task_manager.update_status(bug_id, BugStatus.FIX_FAILED, output[:200])
|
|
||||||
return FixResult(
|
return FixResult(
|
||||||
bug_id=bug_id,
|
bug_id=bug_id, success=True,
|
||||||
success=False,
|
message=f"修复成功 (第 {round_num} 轮)",
|
||||||
message=output[:200],
|
modified_files=modified_files, diff=diff,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 不应到达这里,但做安全兜底
|
||||||
|
return FixResult(bug_id=bug_id, success=False, message="修复流程异常结束")
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""关闭资源"""
|
"""关闭资源"""
|
||||||
self.task_manager.close()
|
self.task_manager.close()
|
||||||
|
|||||||
@ -91,4 +91,6 @@ class RepairReport(BaseModel):
|
|||||||
modified_files: list[str]
|
modified_files: list[str]
|
||||||
test_output: str
|
test_output: str
|
||||||
test_passed: bool
|
test_passed: bool
|
||||||
|
repair_round: int = 1
|
||||||
|
failure_reason: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
224
tests/test_api.py
Normal file
224
tests/test_api.py
Normal 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
317
tests/test_repair_engine.py
Normal 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 # 默认
|
||||||
@ -54,6 +54,8 @@ export interface RepairReport {
|
|||||||
test_output: string;
|
test_output: string;
|
||||||
test_passed: boolean;
|
test_passed: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
repair_round: number;
|
||||||
|
failure_reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Functions
|
// 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 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;
|
export default api;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link, useLocation } from 'react-router-dom';
|
import { useParams, Link, useLocation } from 'react-router-dom';
|
||||||
import { ArrowLeft, Play, Loader2, FileCode, GitCommit } from 'lucide-react';
|
import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History } from 'lucide-react';
|
||||||
import { getBugDetail, triggerRepair, type ErrorLog } from '../api';
|
import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
NEW: '新发现',
|
NEW: '新发现',
|
||||||
@ -22,6 +22,7 @@ export default function BugDetail() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [repairing, setRepairing] = useState(false);
|
const [repairing, setRepairing] = useState(false);
|
||||||
const [repairMessage, setRepairMessage] = useState('');
|
const [repairMessage, setRepairMessage] = useState('');
|
||||||
|
const [repairHistory, setRepairHistory] = useState<RepairReport[]>([]);
|
||||||
|
|
||||||
const backSearch = location.state?.fromSearch || '';
|
const backSearch = location.state?.fromSearch || '';
|
||||||
|
|
||||||
@ -40,6 +41,14 @@ export default function BugDetail() {
|
|||||||
fetchBug();
|
fetchBug();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
getRepairReportsByBug(parseInt(id)).then(res => {
|
||||||
|
setRepairHistory(res.data.items);
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
const handleTriggerRepair = async () => {
|
const handleTriggerRepair = async () => {
|
||||||
if (!bug) return;
|
if (!bug) return;
|
||||||
setRepairing(true);
|
setRepairing(true);
|
||||||
@ -183,6 +192,57 @@ export default function BugDetail() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { ArrowLeft, Bot, FileCode, FlaskConical } from 'lucide-react';
|
import { ArrowLeft, Bot, FileCode, FlaskConical, AlertTriangle } from 'lucide-react';
|
||||||
import { getRepairReportDetail, type RepairReport } from '../api';
|
import { getRepairReportDetail, type RepairReport } from '../api';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@ -78,6 +78,10 @@ export default function RepairDetail() {
|
|||||||
<span>创建时间</span>
|
<span>创建时间</span>
|
||||||
<span>{new Date(report.created_at).toLocaleString()}</span>
|
<span>{new Date(report.created_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<span>修复轮次</span>
|
||||||
|
<strong>第 {report.repair_round} 轮</strong>
|
||||||
|
</div>
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span>测试结果</span>
|
<span>测试结果</span>
|
||||||
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
|
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
|
||||||
@ -86,6 +90,13 @@ export default function RepairDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="card">
|
||||||
<h2><Bot size={16} /> AI 分析</h2>
|
<h2><Bot size={16} /> AI 分析</h2>
|
||||||
<pre className="code-block neutral">{report.ai_analysis}</pre>
|
<pre className="code-block neutral">{report.ai_analysis}</pre>
|
||||||
|
|||||||
@ -96,6 +96,7 @@ export default function RepairList() {
|
|||||||
<th>编号</th>
|
<th>编号</th>
|
||||||
<th>项目</th>
|
<th>项目</th>
|
||||||
<th>缺陷编号</th>
|
<th>缺陷编号</th>
|
||||||
|
<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}
|
#{report.error_log_id}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
<td>第 {report.repair_round} 轮</td>
|
||||||
<td>{report.modified_files.length} 个文件</td>
|
<td>{report.modified_files.length} 个文件</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
|
<span className={report.test_passed ? 'test-pass' : 'test-fail'}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user