From aab0312cecdc881014d064887c0143f0078c5e0c Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Fri, 30 Jan 2026 14:52:21 +0800 Subject: [PATCH] add fix agent --- app/main.py | 85 +++++++- app/models.py | 40 +++- repair_agent/.env.example | 21 ++ repair_agent/README.md | 51 +++++ repair_agent/__init__.py | 7 + repair_agent/__main__.py | 163 ++++++++++++++ repair_agent/agent/__init__.py | 13 ++ repair_agent/agent/claude_service.py | 167 +++++++++++++++ repair_agent/agent/core.py | 303 +++++++++++++++++++++++++++ repair_agent/agent/git_manager.py | 177 ++++++++++++++++ repair_agent/agent/task_manager.py | 152 ++++++++++++++ repair_agent/agent/test_runner.py | 153 ++++++++++++++ repair_agent/config/__init__.py | 3 + repair_agent/config/settings.py | 66 ++++++ repair_agent/models/__init__.py | 3 + repair_agent/models/bug.py | 94 +++++++++ repair_agent/requirements.txt | 8 + test_claude/buggy_code.py | 38 ++++ test_claude/test.py | 1 + web/src/App.tsx | 12 ++ web/src/api.ts | 25 ++- web/src/pages/RepairDetail.tsx | 87 ++++++++ web/src/pages/RepairList.tsx | 158 ++++++++++++++ 23 files changed, 1820 insertions(+), 7 deletions(-) create mode 100644 repair_agent/.env.example create mode 100644 repair_agent/README.md create mode 100644 repair_agent/__init__.py create mode 100644 repair_agent/__main__.py create mode 100644 repair_agent/agent/__init__.py create mode 100644 repair_agent/agent/claude_service.py create mode 100644 repair_agent/agent/core.py create mode 100644 repair_agent/agent/git_manager.py create mode 100644 repair_agent/agent/task_manager.py create mode 100644 repair_agent/agent/test_runner.py create mode 100644 repair_agent/config/__init__.py create mode 100644 repair_agent/config/settings.py create mode 100644 repair_agent/models/__init__.py create mode 100644 repair_agent/models/bug.py create mode 100644 repair_agent/requirements.txt create mode 100644 test_claude/buggy_code.py create mode 100644 test_claude/test.py create mode 100644 web/src/pages/RepairDetail.tsx create mode 100644 web/src/pages/RepairList.tsx diff --git a/app/main.py b/app/main.py index 03555e0..eca3df6 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel import select, func from .database import init_db, get_session -from .models import ErrorLog, ErrorLogCreate, LogStatus +from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate from datetime import datetime, timedelta from typing import Optional, List import hashlib @@ -85,8 +85,12 @@ async def get_pending_tasks(project_id: str = None, session: AsyncSession = Depe results = await session.exec(query) return results.all() -@app.patch("/api/v1/tasks/{task_id}/status", tags=["Tasks"]) -async def update_task_status(task_id: int, status: LogStatus, session: AsyncSession = Depends(get_session)): +@app.put("/api/v1/tasks/{task_id}/status", tags=["Tasks"]) +async def update_task_status( + task_id: int, + status_update: TaskStatusUpdate, + session: AsyncSession = Depends(get_session) +): statement = select(ErrorLog).where(ErrorLog.id == task_id) results = await session.exec(statement) task = results.first() @@ -94,13 +98,86 @@ async def update_task_status(task_id: int, status: LogStatus, session: AsyncSess if not task: raise HTTPException(status_code=404, detail="Task not found") - task.status = status + task.status = status_update.status + # We could log the message to a history table if needed + session.add(task) await session.commit() await session.refresh(task) + return {"message": "Status updated", "id": task.id, "status": task.status} + +# ==================== Repair Reports ==================== +@app.post("/api/v1/repair/reports", tags=["Repair"]) +async def create_repair_report(report: RepairTaskCreate, session: AsyncSession = Depends(get_session)): + """Upload a new repair report""" + # 1. Create repair task record + repair_task = RepairTask.from_orm(report) + session.add(repair_task) + + # 2. Update error log status (optional, but good for consistency) + if report.status in [LogStatus.FIXED, LogStatus.FIX_FAILED]: + log_stmt = select(ErrorLog).where(ErrorLog.id == report.error_log_id) + results = await session.exec(log_stmt) + error_log = results.first() + if error_log: + error_log.status = report.status + session.add(error_log) + + await session.commit() + await session.refresh(repair_task) + return {"message": "Report uploaded", "id": repair_task.id} + +@app.get("/api/v1/repair/reports", tags=["Repair"]) +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, + session: AsyncSession = Depends(get_session) +): + """Get request reports list""" + query = select(RepairTask).order_by(RepairTask.created_at.desc()) + + if project_id: + query = query.where(RepairTask.project_id == project_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) + count_result = await session.exec(count_query) + total = count_result.one() + + return { + "items": tasks, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size + } + +@app.get("/api/v1/repair/reports/{report_id}", tags=["Repair"]) +async def get_repair_report_detail(report_id: int, session: AsyncSession = Depends(get_session)): + """Get detailed repair report""" + statement = select(RepairTask).where(RepairTask.id == report_id) + results = await session.exec(statement) + task = results.first() + + if not task: + raise HTTPException(status_code=404, detail="Report not found") + + return task + + + # ==================== Dashboard APIs ==================== @app.get("/api/v1/dashboard/stats", tags=["Dashboard"]) async def get_dashboard_stats(session: AsyncSession = Depends(get_session)): diff --git a/app/models.py b/app/models.py index 9fdb7ba..ff43e62 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from typing import Optional, Dict -from sqlmodel import SQLModel, Field, Column, JSON +from typing import Optional, Dict, List +from sqlmodel import SQLModel, Field, Column, JSON, Text from enum import Enum class LogStatus(str, Enum): @@ -56,3 +56,39 @@ class ErrorLogCreate(SQLModel): error: Dict # {type, message, file_path, line_number, stack_trace} context: Optional[Dict] = {} + +class TaskStatusUpdate(SQLModel): + status: LogStatus + message: Optional[str] = None + +class RepairTask(SQLModel, table=True): + """Record of a repair attempt""" + id: Optional[int] = Field(default=None, primary_key=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 + + created_at: datetime = Field(default_factory=datetime.utcnow) + +class RepairTaskCreate(SQLModel): + """Schema for creating a repair report via API""" + error_log_id: int + status: LogStatus + project_id: str + ai_analysis: str + fix_plan: str + code_diff: str + modified_files: List[str] + test_output: str + test_passed: bool + diff --git a/repair_agent/.env.example b/repair_agent/.env.example new file mode 100644 index 0000000..a990a46 --- /dev/null +++ b/repair_agent/.env.example @@ -0,0 +1,21 @@ +# Log Center 配置 +LOG_CENTER_URL=https://qiyuan-log-center-api.airlabs.art + +# Claude CLI 配置 +CLAUDE_CLI_PATH=claude +CLAUDE_TIMEOUT=300 + +# Git 配置 +GIT_USER_NAME=repair-agent +GIT_USER_EMAIL=agent@airlabs.art +GITEA_TOKEN=your_token_here + +# 项目路径映射 (project_id -> 本地路径) +PATH_rtc_backend=/Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend +PATH_rtc_web=/Users/maidong/Desktop/zyc/qy_gitlab/rtc_web + +# 安全配置 +MAX_RETRY_COUNT=3 +MAX_MODIFIED_LINES=50 +MAX_MODIFIED_FILES=5 +CRITICAL_FILES=payment,auth,security diff --git a/repair_agent/README.md b/repair_agent/README.md new file mode 100644 index 0000000..45cc3bb --- /dev/null +++ b/repair_agent/README.md @@ -0,0 +1,51 @@ +# Repair Agent - 自动化 Bug 修复代理 + +本地运行的自动化 Bug 修复工具,从 Log Center 获取 Bug,使用 Claude Code CLI 进行修复。 + +## 安装 + +```bash +cd log_center/repair_agent +pip install -r requirements.txt +``` + +## 配置 + +复制 `.env.example` 为 `.env` 并配置: + +```bash +cp .env.example .env +``` + +## 使用 + +```bash +# 查看待修复的 Bug +python -m repair_agent list + +# 修复指定项目的所有 Bug +python -m repair_agent fix rtc_backend + +# 修复单个 Bug +python -m repair_agent fix-one + +# 查看状态 +python -m repair_agent status +``` + +## 架构 + +``` +repair_agent/ +├── agent/ +│ ├── core.py # 核心引擎 +│ ├── task_manager.py # Log Center 交互 +│ ├── git_manager.py # Git 操作 +│ ├── claude_service.py # Claude CLI 调用 +│ └── test_runner.py # 测试执行 +├── config/ +│ └── settings.py # 配置管理 +├── models/ +│ └── bug.py # 数据模型 +└── __main__.py # CLI 入口 +``` diff --git a/repair_agent/__init__.py b/repair_agent/__init__.py new file mode 100644 index 0000000..e5d6b61 --- /dev/null +++ b/repair_agent/__init__.py @@ -0,0 +1,7 @@ +""" +Repair Agent - 自动化 Bug 修复代理 +""" +from .agent import RepairEngine + +__version__ = "0.1.0" +__all__ = ["RepairEngine"] diff --git a/repair_agent/__main__.py b/repair_agent/__main__.py new file mode 100644 index 0000000..9204d63 --- /dev/null +++ b/repair_agent/__main__.py @@ -0,0 +1,163 @@ +""" +Repair Agent CLI - 命令行入口 +""" +import typer +from loguru import logger +from rich.console import Console +from rich.table import Table + +from .agent import RepairEngine, TaskManager +from .config import settings + +app = typer.Typer( + name="repair-agent", + help="自动化 Bug 修复代理", +) +console = Console() + + +@app.command() +def list( + project: str = typer.Option(None, "--project", "-p", help="筛选项目ID"), +): + """查看待修复的 Bug 列表""" + task_manager = TaskManager() + bugs = task_manager.fetch_pending_bugs(project) + + if not bugs: + console.print("[yellow]没有待修复的 Bug[/yellow]") + return + + table = Table(title="待修复 Bug 列表") + table.add_column("ID", style="cyan") + table.add_column("项目", style="green") + table.add_column("错误类型", style="red") + table.add_column("消息", style="white") + table.add_column("文件", style="blue") + table.add_column("状态", style="yellow") + + for bug in bugs: + table.add_row( + str(bug.id), + bug.project_id, + bug.error.type, + bug.error.message[:50] + "..." if len(bug.error.message) > 50 else bug.error.message, + bug.error.file_path or "-", + bug.status.value, + ) + + console.print(table) + task_manager.close() + + +@app.command() +def fix( + project: str = typer.Argument(..., help="项目ID (如 rtc_backend)"), + test: bool = typer.Option(True, "--test/--no-test", help="是否运行测试"), + commit: bool = typer.Option(False, "--commit", "-c", help="是否自动提交"), +): + """修复指定项目的所有 Bug""" + console.print(f"[bold blue]开始修复项目: {project}[/bold blue]") + + engine = RepairEngine() + result = engine.fix_project( + project_id=project, + run_tests=test, + auto_commit=commit, + ) + + if result.total == 0: + console.print("[yellow]没有需要修复的 Bug[/yellow]") + else: + console.print(f"\n[bold]修复结果:[/bold]") + console.print(f" 总计: {result.total}") + console.print(f" [green]成功: {result.success_count}[/green]") + console.print(f" [red]失败: {result.failed_count}[/red]") + + if result.results: + console.print("\n[bold]详细结果:[/bold]") + for r in result.results: + status = "[green]✓[/green]" if r.success else "[red]✗[/red]" + console.print(f" {status} Bug #{r.bug_id}: {r.message}") + if r.modified_files: + console.print(f" 修改文件: {', '.join(r.modified_files)}") + + engine.close() + + +@app.command("fix-one") +def fix_one( + bug_id: int = typer.Argument(..., help="Bug ID"), + test: bool = typer.Option(True, "--test/--no-test", help="是否运行测试"), +): + """修复单个 Bug""" + console.print(f"[bold blue]开始修复 Bug #{bug_id}[/bold blue]") + + engine = RepairEngine() + result = engine.fix_single_bug(bug_id, run_tests=test) + + if result.success: + console.print(f"[green]✓ 修复成功![/green]") + if result.modified_files: + console.print(f" 修改文件: {', '.join(result.modified_files)}") + else: + console.print(f"[red]✗ 修复失败: {result.message}[/red]") + + engine.close() + + +@app.command() +def status(): + """查看配置状态""" + console.print("[bold]Repair Agent 配置状态[/bold]\n") + + console.print(f"Log Center URL: [cyan]{settings.log_center_url}[/cyan]") + console.print(f"Claude CLI: [cyan]{settings.claude_cli_path}[/cyan]") + console.print(f"最大重试次数: [cyan]{settings.max_retry_count}[/cyan]") + console.print(f"最大修改行数: [cyan]{settings.max_modified_lines}[/cyan]") + console.print(f"最大修改文件数: [cyan]{settings.max_modified_files}[/cyan]") + console.print(f"核心文件关键词: [cyan]{settings.critical_files}[/cyan]") + + console.print("\n[bold]项目路径映射:[/bold]") + console.print(f" rtc_backend: [blue]{settings.path_rtc_backend}[/blue]") + console.print(f" rtc_web: [blue]{settings.path_rtc_web}[/blue]") + + +@app.command() +def analyze( + bug_id: int = typer.Argument(..., help="Bug ID"), +): + """分析单个 Bug(不修复)""" + engine = RepairEngine() + bug = engine.task_manager.get_bug_detail(bug_id) + + if not bug: + console.print(f"[red]Bug #{bug_id} 不存在[/red]") + return + + project_path = settings.get_project_path(bug.project_id) + if not project_path: + console.print(f"[red]未找到项目路径: {bug.project_id}[/red]") + return + + console.print(f"[bold blue]分析 Bug #{bug_id}[/bold blue]\n") + console.print(bug.format_for_prompt()) + + console.print("\n[bold]AI 分析结果:[/bold]") + success, output = engine.claude_service.analyze_bug(bug, project_path) + + if success: + console.print(output) + else: + console.print(f"[red]分析失败: {output}[/red]") + + engine.close() + + +def main(): + """入口函数""" + app() + + +if __name__ == "__main__": + main() diff --git a/repair_agent/agent/__init__.py b/repair_agent/agent/__init__.py new file mode 100644 index 0000000..649c0ee --- /dev/null +++ b/repair_agent/agent/__init__.py @@ -0,0 +1,13 @@ +from .core import RepairEngine +from .task_manager import TaskManager +from .git_manager import GitManager +from .claude_service import ClaudeService +from .test_runner import TestRunner + +__all__ = [ + "RepairEngine", + "TaskManager", + "GitManager", + "ClaudeService", + "TestRunner", +] diff --git a/repair_agent/agent/claude_service.py b/repair_agent/agent/claude_service.py new file mode 100644 index 0000000..1ea1b50 --- /dev/null +++ b/repair_agent/agent/claude_service.py @@ -0,0 +1,167 @@ +""" +Claude Service - 调用 Claude Code CLI +""" +import subprocess +from typing import Optional +from loguru import logger + +from ..config import settings +from ..models import Bug + + +class ClaudeService: + """通过 CLI 调用 Claude Code""" + + def __init__(self): + self.cli_path = settings.claude_cli_path + self.timeout = settings.claude_timeout + + def execute_prompt( + self, + prompt: str, + cwd: str, + tools: str = "Edit,Read,Write", + ) -> tuple[bool, str]: + """ + 执行 Claude CLI 命令 + + Args: + prompt: 提示词 + cwd: 工作目录 + tools: 可用工具 + + Returns: + (成功与否, 输出内容) + """ + try: + cmd = [ + self.cli_path, + "-p", prompt, + "--tools", tools, + "--dangerously-skip-permissions", + ] + + logger.debug(f"执行 Claude CLI: {' '.join(cmd[:3])}...") + + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + timeout=self.timeout, + ) + + output = result.stdout + result.stderr + + if result.returncode == 0: + logger.info("Claude CLI 执行成功") + return True, output.strip() + else: + logger.error(f"Claude CLI 执行失败: {output}") + return False, output.strip() + + except subprocess.TimeoutExpired: + logger.error(f"Claude CLI 超时 ({self.timeout}秒)") + return False, "执行超时" + except FileNotFoundError: + logger.error(f"Claude CLI 未找到: {self.cli_path}") + return False, "Claude CLI 未安装" + except Exception as e: + logger.error(f"Claude CLI 执行异常: {e}") + return False, str(e) + + def batch_fix_bugs( + self, + bugs: list[Bug], + project_path: str, + ) -> tuple[bool, str]: + """ + 批量修复多个 Bug + + Args: + bugs: Bug 列表 + project_path: 项目路径 + + Returns: + (成功与否, Claude 输出) + """ + if not bugs: + return False, "没有需要修复的 Bug" + + # 构造批量修复 Prompt + prompt_parts = [ + f"请修复以下 {len(bugs)} 个 Bug:", + "", + ] + + for bug in bugs: + prompt_parts.append(bug.format_for_prompt()) + prompt_parts.append("") + + prompt_parts.extend([ + "## 修复要求", + "1. 依次修复上述所有 Bug", + "2. 每个 Bug 只做最小必要的改动", + "3. 确保不破坏现有功能", + "4. 修复完成后简要说明每个 Bug 的修复方式", + "", + "请开始修复。", + ]) + + prompt = "\n".join(prompt_parts) + + logger.info(f"开始批量修复 {len(bugs)} 个 Bug...") + return self.execute_prompt(prompt, project_path) + + def analyze_bug(self, bug: Bug, project_path: str) -> tuple[bool, str]: + """ + 分析单个 Bug(不修复) + + Args: + bug: Bug 对象 + project_path: 项目路径 + + Returns: + (成功与否, 分析结果) + """ + prompt = f""" +请分析以下 Bug,但不要修改任何代码: + +{bug.format_for_prompt()} + +请分析: +1. 错误的根本原因 +2. 建议的修复方案 +3. 可能影响的其他文件 +""" + + return self.execute_prompt(prompt, project_path, tools="Read") + + def review_changes(self, diff: str, project_path: str) -> tuple[bool, str]: + """ + 审核代码变更 + + Args: + diff: Git diff 内容 + project_path: 项目路径 + + Returns: + (是否通过, 审核意见) + """ + prompt = f""" +请审核以下代码变更是否安全: + +```diff +{diff} +``` + +审核要点: +1. 是否修复了目标问题 +2. 是否引入了新的 Bug +3. 是否破坏了原有业务逻辑 +4. 是否有安全风险 + +如果审核通过,回复 "APPROVED"。否则说明问题。 +""" + + return self.execute_prompt(prompt, project_path, tools="Read") diff --git a/repair_agent/agent/core.py b/repair_agent/agent/core.py new file mode 100644 index 0000000..6c357c5 --- /dev/null +++ b/repair_agent/agent/core.py @@ -0,0 +1,303 @@ +""" +Core Engine - 核心修复引擎 +""" +from datetime import datetime +from typing import Optional +from loguru import logger + +from ..config import settings +from ..models import Bug, BugStatus, FixResult, BatchFixResult, RepairReport +from .task_manager import TaskManager +from .git_manager import GitManager +from .claude_service import ClaudeService +from .test_runner import TestRunner + + +class RepairEngine: + """核心修复引擎 - 编排整个修复流程""" + + def __init__(self): + self.task_manager = TaskManager() + self.claude_service = ClaudeService() + + def fix_project( + self, + project_id: str, + run_tests: bool = True, + auto_commit: bool = False, + ) -> BatchFixResult: + """ + 修复指定项目的所有待修复 Bug + + Args: + project_id: 项目ID + run_tests: 是否运行测试 + auto_commit: 是否自动提交 + + Returns: + BatchFixResult + """ + logger.info(f"开始修复项目: {project_id}") + + # 获取项目路径 + project_path = settings.get_project_path(project_id) + if not project_path: + logger.error(f"未找到项目路径配置: {project_id}") + return BatchFixResult( + project_id=project_id, + total=0, + success_count=0, + failed_count=0, + results=[], + ) + + # 获取待修复的 Bug + bugs = self.task_manager.fetch_pending_bugs(project_id) + if not bugs: + logger.info("没有待修复的 Bug") + return BatchFixResult( + project_id=project_id, + total=0, + success_count=0, + failed_count=0, + results=[], + ) + + logger.info(f"获取到 {len(bugs)} 个待修复 Bug") + + # 初始化 Git 管理器 + git_manager = GitManager(project_path) + + # 拉取最新代码 + git_manager.pull() + + # 创建修复分支 + branch_name = f"fix/auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + if auto_commit: + git_manager.create_branch(branch_name) + + # 更新所有 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) + + results = [] + if success: + # 获取修改的文件 + modified_files = git_manager.get_modified_files() + diff = git_manager.get_diff() + + logger.info(f"修复完成,修改了 {len(modified_files)} 个文件") + + # 安全检查 + if not self._safety_check(modified_files, diff): + logger.warning("安全检查未通过,回滚更改") + git_manager.reset_hard() + + for bug in bugs: + self.task_manager.update_status( + bug.id, + BugStatus.FIX_FAILED, + "安全检查未通过" + ) + results.append(FixResult( + bug_id=bug.id, + success=False, + message="安全检查未通过", + )) + + return BatchFixResult( + project_id=project_id, + total=len(bugs), + success_count=0, + failed_count=len(bugs), + results=results, + ) + + # 运行测试 + 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("测试未通过,回滚更改") + 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="测试未通过", + )) + + return BatchFixResult( + project_id=project_id, + total=len(bugs), + success_count=0, + failed_count=len(bugs), + results=results, + ) + + # 标记成功并上传报告 + 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}") + + results.append(FixResult( + bug_id=bug.id, + success=True, + message="修复成功", + modified_files=modified_files, + diff=diff, + )) + + # 自动提交 + if auto_commit and modified_files: + bug_ids = ", ".join([f"#{b.id}" for b in bugs]) + git_manager.commit(f"fix: auto repair bugs {bug_ids}") + logger.info("代码已提交") + 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], + )) + + success_count = sum(1 for r in results if r.success) + + return BatchFixResult( + project_id=project_id, + total=len(bugs), + success_count=success_count, + failed_count=len(bugs) - success_count, + results=results, + ) + + def _safety_check(self, modified_files: list[str], diff: str) -> bool: + """ + 安全检查 + + Args: + modified_files: 修改的文件列表 + diff: Git diff + + Returns: + 是否通过检查 + """ + # 检查修改文件数量 + if len(modified_files) > settings.max_modified_files: + logger.warning(f"修改文件数超限: {len(modified_files)} > {settings.max_modified_files}") + return False + + # 检查修改行数 + added_lines = diff.count("\n+") - diff.count("\n+++") + deleted_lines = diff.count("\n-") - diff.count("\n---") + total_lines = added_lines + deleted_lines + + if total_lines > settings.max_modified_lines: + logger.warning(f"修改行数超限: {total_lines} > {settings.max_modified_lines}") + return False + + # 检查核心文件 + critical_keywords = settings.get_critical_files() + for file_path in modified_files: + for keyword in critical_keywords: + if keyword.lower() in file_path.lower(): + logger.warning(f"修改了核心文件: {file_path}") + return False + + return True + + def fix_single_bug( + self, + bug_id: int, + run_tests: bool = True, + ) -> FixResult: + """修复单个 Bug""" + bug = self.task_manager.get_bug_detail(bug_id) + if not bug: + return FixResult( + bug_id=bug_id, + success=False, + message="Bug 不存在", + ) + + project_path = settings.get_project_path(bug.project_id) + if not project_path: + return FixResult( + bug_id=bug_id, + 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) + modified_files = git_manager.get_modified_files() + + 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="测试未通过", + ) + + self.task_manager.update_status(bug_id, BugStatus.FIXED) + return FixResult( + bug_id=bug_id, + success=True, + message="修复成功", + modified_files=modified_files, + ) + else: + self.task_manager.update_status(bug_id, BugStatus.FIX_FAILED, output[:200]) + return FixResult( + bug_id=bug_id, + success=False, + message=output[:200], + ) + + def close(self): + """关闭资源""" + self.task_manager.close() diff --git a/repair_agent/agent/git_manager.py b/repair_agent/agent/git_manager.py new file mode 100644 index 0000000..fb20482 --- /dev/null +++ b/repair_agent/agent/git_manager.py @@ -0,0 +1,177 @@ +""" +Git Manager - Git 操作管理 +""" +import os +from typing import Optional +from git import Repo, InvalidGitRepositoryError +from loguru import logger + +from ..config import settings + + +class GitManager: + """负责 Git 操作""" + + def __init__(self, project_path: str): + self.project_path = project_path + self.repo: Optional[Repo] = None + self._init_repo() + + def _init_repo(self): + """初始化 Git 仓库""" + try: + self.repo = Repo(self.project_path) + # 配置 Git 用户 + with self.repo.config_writer() as config: + config.set_value("user", "name", settings.git_user_name) + config.set_value("user", "email", settings.git_user_email) + logger.info(f"Git 仓库初始化成功: {self.project_path}") + except InvalidGitRepositoryError: + logger.error(f"无效的 Git 仓库: {self.project_path}") + self.repo = None + + def pull(self) -> bool: + """拉取最新代码""" + if not self.repo: + return False + + try: + origin = self.repo.remotes.origin + origin.pull() + logger.info("代码拉取成功") + return True + except Exception as e: + logger.error(f"拉取代码失败: {e}") + return False + + def create_branch(self, branch_name: str) -> bool: + """ + 创建并切换到新分支 + + Args: + branch_name: 分支名称 + + Returns: + 是否成功 + """ + if not self.repo: + return False + + try: + # 先切换到 main 分支 + if "main" in self.repo.heads: + self.repo.heads.main.checkout() + elif "master" in self.repo.heads: + self.repo.heads.master.checkout() + + # 创建新分支 + new_branch = self.repo.create_head(branch_name) + new_branch.checkout() + logger.info(f"创建并切换到分支: {branch_name}") + return True + + except Exception as e: + logger.error(f"创建分支失败: {e}") + return False + + def checkout(self, branch_name: str) -> bool: + """切换分支""" + if not self.repo: + return False + + try: + if branch_name in self.repo.heads: + self.repo.heads[branch_name].checkout() + logger.info(f"切换到分支: {branch_name}") + return True + else: + logger.error(f"分支不存在: {branch_name}") + return False + except Exception as e: + logger.error(f"切换分支失败: {e}") + return False + + def get_diff(self) -> str: + """获取当前修改的 diff""" + if not self.repo: + return "" + + try: + return self.repo.git.diff() + except Exception as e: + logger.error(f"获取 diff 失败: {e}") + return "" + + def get_modified_files(self) -> list[str]: + """获取已修改的文件列表""" + if not self.repo: + return [] + + try: + return [item.a_path for item in self.repo.index.diff(None)] + except Exception as e: + logger.error(f"获取修改文件失败: {e}") + return [] + + def commit(self, message: str) -> bool: + """提交更改""" + if not self.repo: + return False + + try: + # 添加所有修改 + self.repo.git.add(A=True) + + # 检查是否有更改 + if not self.repo.is_dirty(): + logger.warning("没有需要提交的更改") + return False + + self.repo.index.commit(message) + logger.info(f"提交成功: {message}") + return True + + except Exception as e: + logger.error(f"提交失败: {e}") + return False + + def push(self, branch_name: Optional[str] = None) -> bool: + """推送到远程""" + if not self.repo: + return False + + try: + origin = self.repo.remotes.origin + if branch_name: + origin.push(branch_name) + else: + origin.push() + logger.info("推送成功") + return True + except Exception as e: + logger.error(f"推送失败: {e}") + return False + + def reset_hard(self) -> bool: + """重置所有更改""" + if not self.repo: + return False + + try: + self.repo.git.reset("--hard") + self.repo.git.clean("-fd") + logger.info("重置成功") + return True + except Exception as e: + logger.error(f"重置失败: {e}") + return False + + def get_current_branch(self) -> str: + """获取当前分支名""" + if not self.repo: + return "" + + try: + return self.repo.active_branch.name + except Exception: + return "" diff --git a/repair_agent/agent/task_manager.py b/repair_agent/agent/task_manager.py new file mode 100644 index 0000000..445f1ba --- /dev/null +++ b/repair_agent/agent/task_manager.py @@ -0,0 +1,152 @@ +""" +Task Manager - 与 Log Center 交互 +""" +import httpx +from typing import Optional +from loguru import logger + +from ..config import settings +from ..models import Bug, BugStatus, RepairReport + + +class TaskManager: + """负责与 Log Center 交互""" + + def __init__(self): + self.base_url = settings.log_center_url + self.client = httpx.Client(timeout=30) + + def fetch_pending_bugs(self, project_id: Optional[str] = None) -> list[Bug]: + """ + 获取待修复的 Bug 列表 + + Args: + project_id: 可选,筛选特定项目 + + Returns: + Bug 列表 + """ + try: + params = {"status": "NEW"} + if project_id: + params["project_id"] = project_id + + response = self.client.get( + f"{self.base_url}/api/v1/bugs", + params=params + ) + response.raise_for_status() + + data = response.json() + bugs = [] + + for item in data.get("items", []): + # stack_trace 可能是列表或字符串 + stack_trace = item.get("stack_trace") + if isinstance(stack_trace, str): + stack_trace = stack_trace.split("\n") + + bug = Bug( + id=item["id"], + project_id=item["project_id"], + environment=item.get("environment", "production"), + level=item.get("level", "ERROR"), + error={ + "type": item.get("error_type", "Unknown"), + "message": item.get("error_message", ""), + "file_path": item.get("file_path"), + "line_number": item.get("line_number"), + "stack_trace": stack_trace, + }, + context=item.get("context"), + status=BugStatus(item.get("status", "NEW")), + retry_count=item.get("retry_count", 0), + ) + bugs.append(bug) + + logger.info(f"获取到 {len(bugs)} 个待修复 Bug") + return bugs + + except httpx.HTTPError as e: + logger.error(f"获取 Bug 列表失败: {e}") + return [] + + def update_status(self, bug_id: int, status: BugStatus, message: str = "") -> bool: + """ + 更新 Bug 状态 + + Args: + bug_id: Bug ID + status: 新状态 + message: 状态说明 + + Returns: + 是否成功 + """ + try: + response = self.client.put( + f"{self.base_url}/api/v1/tasks/{bug_id}/status", + json={ + "status": status.value, + "message": message + } + ) + response.raise_for_status() + logger.info(f"Bug #{bug_id} 状态更新为 {status.value}") + return True + + except httpx.HTTPError as e: + logger.error(f"更新状态失败: {e}") + return False + + def get_bug_detail(self, bug_id: int) -> Optional[Bug]: + """获取 Bug 详情""" + try: + response = self.client.get(f"{self.base_url}/api/v1/bugs/{bug_id}") + response.raise_for_status() + + item = response.json() + + # stack_trace 可能是列表或字符串 + stack_trace = item.get("stack_trace") + if isinstance(stack_trace, str): + stack_trace = stack_trace.split("\n") + + return Bug( + id=item["id"], + project_id=item["project_id"], + environment=item.get("environment", "production"), + level=item.get("level", "ERROR"), + error={ + "type": item.get("error_type", "Unknown"), + "message": item.get("error_message", ""), + "file_path": item.get("file_path"), + "line_number": item.get("line_number"), + "stack_trace": stack_trace, + }, + context=item.get("context"), + status=BugStatus(item.get("status", "NEW")), + retry_count=item.get("retry_count", 0), + ) + + except httpx.HTTPError as e: + logger.error(f"获取 Bug 详情失败: {e}") + return None + + def upload_report(self, report: RepairReport) -> bool: + """上传修复报告""" + try: + response = self.client.post( + f"{self.base_url}/api/v1/repair/reports", + json=report.model_dump() + ) + response.raise_for_status() + logger.info(f"Bug #{report.error_log_id} 修复报告已上传") + return True + except httpx.HTTPError as e: + logger.error(f"上传修复报告失败: {e}") + return False + + def close(self): + """关闭连接""" + self.client.close() diff --git a/repair_agent/agent/test_runner.py b/repair_agent/agent/test_runner.py new file mode 100644 index 0000000..122ca22 --- /dev/null +++ b/repair_agent/agent/test_runner.py @@ -0,0 +1,153 @@ +""" +Test Runner - 测试执行 +""" +import subprocess +import os +from typing import Optional +from dataclasses import dataclass +from loguru import logger + + +@dataclass +class TestResult: + """测试结果""" + success: bool + output: str + failed_tests: list[str] + passed_count: int + failed_count: int + + +class TestRunner: + """负责运行测试""" + + def __init__(self, project_path: str, project_id: str): + self.project_path = project_path + self.project_id = project_id + + def _detect_test_command(self) -> list[str]: + """检测测试命令""" + # Django 项目 + if os.path.exists(os.path.join(self.project_path, "manage.py")): + return ["python", "manage.py", "test", "--keepdb", "-v", "2"] + + # Python pytest + if os.path.exists(os.path.join(self.project_path, "pytest.ini")) or \ + os.path.exists(os.path.join(self.project_path, "pyproject.toml")): + return ["pytest", "-v"] + + # Node.js + if os.path.exists(os.path.join(self.project_path, "package.json")): + return ["npm", "test"] + + # 默认 pytest + return ["pytest", "-v"] + + def run_full_suite(self, timeout: int = 300) -> TestResult: + """ + 运行完整测试套件 + + Args: + timeout: 超时时间(秒) + + Returns: + TestResult + """ + cmd = self._detect_test_command() + logger.info(f"运行测试: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + cwd=self.project_path, + capture_output=True, + text=True, + timeout=timeout, + ) + + output = result.stdout + result.stderr + success = result.returncode == 0 + + # 简单解析失败的测试 + failed_tests = [] + for line in output.split("\n"): + if "FAILED" in line or "ERROR" in line: + failed_tests.append(line.strip()) + + logger.info(f"测试{'通过' if success else '失败'}") + + return TestResult( + success=success, + output=output, + failed_tests=failed_tests, + passed_count=0, # TODO: 解析具体数量 + failed_count=len(failed_tests), + ) + + except subprocess.TimeoutExpired: + logger.error(f"测试超时 ({timeout}秒)") + return TestResult( + success=False, + output="测试执行超时", + failed_tests=[], + passed_count=0, + failed_count=0, + ) + except Exception as e: + logger.error(f"测试执行失败: {e}") + return TestResult( + success=False, + output=str(e), + failed_tests=[], + passed_count=0, + failed_count=0, + ) + + def run_specific_test( + self, + test_path: str, + timeout: int = 60, + ) -> TestResult: + """ + 运行特定测试 + + Args: + test_path: 测试路径 (如 tests.test_user.TestUserLogin) + timeout: 超时时间 + + Returns: + TestResult + """ + if self.project_id == "rtc_backend": + cmd = ["python", "manage.py", "test", test_path, "--keepdb", "-v", "2"] + else: + cmd = ["pytest", test_path, "-v"] + + logger.info(f"运行测试: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + cwd=self.project_path, + capture_output=True, + text=True, + timeout=timeout, + ) + + return TestResult( + success=result.returncode == 0, + output=result.stdout + result.stderr, + failed_tests=[], + passed_count=0, + failed_count=0 if result.returncode == 0 else 1, + ) + + except Exception as e: + logger.error(f"测试执行失败: {e}") + return TestResult( + success=False, + output=str(e), + failed_tests=[], + passed_count=0, + failed_count=0, + ) diff --git a/repair_agent/config/__init__.py b/repair_agent/config/__init__.py new file mode 100644 index 0000000..84a6cc5 --- /dev/null +++ b/repair_agent/config/__init__.py @@ -0,0 +1,3 @@ +from .settings import settings + +__all__ = ["settings"] diff --git a/repair_agent/config/settings.py b/repair_agent/config/settings.py new file mode 100644 index 0000000..88ef7ad --- /dev/null +++ b/repair_agent/config/settings.py @@ -0,0 +1,66 @@ +""" +配置管理 +""" +import os +from typing import Optional +from pydantic_settings import BaseSettings +from pydantic import Field + + +class Settings(BaseSettings): + """Repair Agent 配置""" + + # Log Center + log_center_url: str = Field( + default="https://qiyuan-log-center-api.airlabs.art", + description="Log Center API 地址" + ) + + # Claude CLI + claude_cli_path: str = Field(default="claude", description="Claude CLI 路径") + claude_timeout: int = Field(default=300, description="Claude CLI 超时时间(秒)") + + # Git + git_user_name: str = Field(default="repair-agent", description="Git 用户名") + git_user_email: str = Field(default="agent@airlabs.art", description="Git 邮箱") + gitea_token: Optional[str] = Field(default=None, description="Gitea Token") + + # 安全配置 + max_retry_count: int = Field(default=3, description="最大重试次数") + max_modified_lines: int = Field(default=50, description="最大修改行数") + max_modified_files: int = Field(default=5, description="最大修改文件数") + critical_files: str = Field( + default="payment,auth,security", + description="核心文件关键词,逗号分隔" + ) + + # 项目路径映射 + path_rtc_backend: str = Field( + default="/Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend", + description="rtc_backend 本地路径" + ) + path_rtc_web: str = Field( + default="/Users/maidong/Desktop/zyc/qy_gitlab/rtc_web", + description="rtc_web 本地路径" + ) + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + extra = "ignore" + + def get_project_path(self, project_id: str) -> Optional[str]: + """获取项目本地路径""" + path_map = { + "rtc_backend": self.path_rtc_backend, + "rtc_web": self.path_rtc_web, + } + return path_map.get(project_id) + + def get_critical_files(self) -> list[str]: + """获取核心文件关键词列表""" + return [f.strip() for f in self.critical_files.split(",") if f.strip()] + + +# 单例 +settings = Settings() diff --git a/repair_agent/models/__init__.py b/repair_agent/models/__init__.py new file mode 100644 index 0000000..13cf66f --- /dev/null +++ b/repair_agent/models/__init__.py @@ -0,0 +1,3 @@ +from .bug import Bug, BugStatus, ErrorInfo, FixResult, BatchFixResult, RepairReport + +__all__ = ["Bug", "BugStatus", "ErrorInfo", "FixResult", "BatchFixResult"] diff --git a/repair_agent/models/bug.py b/repair_agent/models/bug.py new file mode 100644 index 0000000..ede3a09 --- /dev/null +++ b/repair_agent/models/bug.py @@ -0,0 +1,94 @@ +""" +Bug 数据模型 +""" +from datetime import datetime +from typing import Optional, Any +from enum import Enum +from pydantic import BaseModel, Field + + +class BugStatus(str, Enum): + """Bug 状态""" + NEW = "NEW" + VERIFYING = "VERIFYING" + CANNOT_REPRODUCE = "CANNOT_REPRODUCE" + PENDING_FIX = "PENDING_FIX" + FIXING = "FIXING" + FIXED = "FIXED" + FIX_FAILED = "FIX_FAILED" + VERIFIED = "VERIFIED" + DEPLOYED = "DEPLOYED" + + +class ErrorInfo(BaseModel): + """错误信息""" + type: str = Field(description="异常类型") + message: str = Field(description="错误消息") + file_path: Optional[str] = Field(default=None, description="文件路径") + line_number: Optional[int] = Field(default=None, description="行号") + stack_trace: Optional[list[str]] = Field(default=None, description="堆栈信息") + + +class Bug(BaseModel): + """Bug 数据模型""" + id: int = Field(description="Bug ID") + project_id: str = Field(description="项目ID") + environment: str = Field(default="production", description="环境") + level: str = Field(default="ERROR", description="日志级别") + error: ErrorInfo = Field(description="错误信息") + context: Optional[dict[str, Any]] = Field(default=None, description="上下文") + status: BugStatus = Field(default=BugStatus.NEW, description="状态") + retry_count: int = Field(default=0, description="重试次数") + created_at: Optional[datetime] = Field(default=None, description="创建时间") + + def format_for_prompt(self) -> str: + """格式化为 Prompt 内容""" + lines = [ + f"## Bug #{self.id}: {self.error.type}", + f"- 文件: {self.error.file_path or 'unknown'}", + f"- 行号: {self.error.line_number or 'unknown'}", + f"- 错误: {self.error.message}", + ] + + if self.error.stack_trace: + lines.append("- 堆栈:") + lines.append("```") + lines.extend(self.error.stack_trace[:10]) # 最多10行 + lines.append("```") + + if self.context: + lines.append(f"- 上下文: {self.context}") + + return "\n".join(lines) + + +class FixResult(BaseModel): + """修复结果""" + bug_id: int + success: bool + message: str + modified_files: list[str] = Field(default_factory=list) + diff: Optional[str] = None + + +class BatchFixResult(BaseModel): + """批量修复结果""" + project_id: str + total: int + success_count: int + failed_count: int + results: list[FixResult] = Field(default_factory=list) + + +class RepairReport(BaseModel): + """修复报告""" + error_log_id: int + status: BugStatus + project_id: str + ai_analysis: str + fix_plan: str + code_diff: str + modified_files: list[str] + test_output: str + test_passed: bool + diff --git a/repair_agent/requirements.txt b/repair_agent/requirements.txt new file mode 100644 index 0000000..540a421 --- /dev/null +++ b/repair_agent/requirements.txt @@ -0,0 +1,8 @@ +httpx>=0.27.0 +pydantic>=2.0 +pydantic-settings>=2.0 +python-dotenv>=1.0 +gitpython>=3.1 +typer>=0.9 +loguru>=0.7 +rich>=13.0 diff --git a/test_claude/buggy_code.py b/test_claude/buggy_code.py new file mode 100644 index 0000000..b84d9df --- /dev/null +++ b/test_claude/buggy_code.py @@ -0,0 +1,38 @@ +""" +用户管理模块 +""" + + +def get_user_age(user): + """获取用户年龄""" + if user is None: + return None + return user["age"] + 1 + + +def calculate_discount(price, discount): + """计算折扣价""" + if discount == 0: + return price + return price / discount + + +def format_username(first_name, last_name): + """格式化用户名""" + return first_name + " " + str(last_name) + + +def get_user_email(users, index): + """获取用户邮箱""" + if index < 0 or index >= len(users): + return None + return users[index]["email"] + + +def parse_config(config_str): + """解析配置""" + import json + try: + return json.loads(config_str) + except (json.JSONDecodeError, TypeError): + return None diff --git a/test_claude/test.py b/test_claude/test.py new file mode 100644 index 0000000..e67c65b --- /dev/null +++ b/test_claude/test.py @@ -0,0 +1 @@ +def hello(name):\n print('hello, ' + name) diff --git a/web/src/App.tsx b/web/src/App.tsx index 7a98e35..0727e2e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,8 @@ import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'; import Dashboard from './pages/Dashboard'; import BugList from './pages/BugList'; import BugDetail from './pages/BugDetail'; +import RepairList from './pages/RepairList'; +import RepairDetail from './pages/RepairDetail'; import './index.css'; function App() { @@ -31,6 +33,14 @@ function App() { 🐛 Bug List +
  • + `nav-link ${isActive ? 'active' : ''}`} + > + 🔧 Repair Reports + +
  • @@ -40,6 +50,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/web/src/api.ts b/web/src/api.ts index af7a479..3358c6d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -42,6 +42,20 @@ export interface PaginatedResponse { total_pages: number; } +export interface RepairReport { + id: number; + error_log_id: number; + project_id: string; + status: string; + ai_analysis: string; + fix_plan: string; + code_diff: string; + modified_files: string[]; + test_output: string; + test_passed: boolean; + created_at: string; +} + // API Functions export const getStats = () => api.get('/api/v1/dashboard/stats'); @@ -57,6 +71,15 @@ export const getBugDetail = (id: number) => api.get(`/api/v1/bugs/${id export const getProjects = () => api.get<{ projects: string[] }>('/api/v1/projects'); export const updateTaskStatus = (taskId: number, status: string) => - api.patch(`/api/v1/tasks/${taskId}/status`, null, { params: { status } }); + api.put(`/api/v1/tasks/${taskId}/status`, { status }); + +export const getRepairReports = (params: { + page?: number; + page_size?: number; + project_id?: string; +}) => api.get>('/api/v1/repair/reports', { params }); + +export const getRepairReportDetail = (id: number) => api.get(`/api/v1/repair/reports/${id}`); export default api; + diff --git a/web/src/pages/RepairDetail.tsx b/web/src/pages/RepairDetail.tsx new file mode 100644 index 0000000..8fc4723 --- /dev/null +++ b/web/src/pages/RepairDetail.tsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { getRepairReportDetail, type RepairReport } from '../api'; + +function RepairDetail() { + const { id } = useParams<{ id: string }>(); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (id) { + fetchDetail(parseInt(id)); + } + }, [id]); + + const fetchDetail = async (reportId: number) => { + try { + const res = await getRepairReportDetail(reportId); + setReport(res.data); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + if (loading) return
    Loading...
    ; + if (!report) return
    Report not found
    ; + + return ( +
    +
    +
    + Repair Reports / #{report.id} +
    +
    +

    Repair Report #{report.id}

    + {report.status} +
    +
    + +
    +
    +

    Basic Info

    +
    + Project: {report.project_id} +
    +
    + Bug ID: #{report.error_log_id} +
    +
    + Created At: {new Date(report.created_at).toLocaleString()} +
    +
    + Test Result: + + {report.test_passed ? 'PASS' : 'FAIL'} + +
    +
    + +
    +

    🤖 AI Analysis

    +
    +
    {report.ai_analysis}
    +
    +
    + +
    +

    📝 Code Changes

    +
    +
    {report.code_diff || 'No changes recorded'}
    +
    +
    + +
    +

    🧪 Test Output

    +
    +
    {report.test_output}
    +
    +
    +
    +
    + ); +} + +export default RepairDetail; diff --git a/web/src/pages/RepairList.tsx b/web/src/pages/RepairList.tsx new file mode 100644 index 0000000..0bc7478 --- /dev/null +++ b/web/src/pages/RepairList.tsx @@ -0,0 +1,158 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { getRepairReports, type RepairReport, getProjects } from '../api'; + +function RepairList() { + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + const [projects, setProjects] = useState([]); + const [filters, setFilters] = useState({ + project_id: '', + page: 1, + }); + const [totalPages, setTotalPages] = useState(1); + + useEffect(() => { + fetchProjects(); + }, []); + + useEffect(() => { + fetchReports(); + }, [filters]); + + const fetchProjects = async () => { + try { + const res = await getProjects(); + setProjects(res.data.projects); + } catch (err) { + console.error(err); + } + }; + + const fetchReports = async () => { + setLoading(true); + try { + const res = await getRepairReports({ + page: filters.page, + project_id: filters.project_id || undefined, + }); + setReports(res.data.items); + setTotalPages(res.data.total_pages); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + const handleFilterChange = (key: string, value: any) => { + setFilters((prev) => ({ ...prev, [key]: value, page: 1 })); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'FIXED': return '#10b981'; + case 'FIX_FAILED': return '#ef4444'; + default: return '#6b7280'; + } + }; + + return ( +
    +
    +

    🔧 Repair Reports

    +
    + +
    +
    + +
    + + + + + + + + + + + + + + + {loading ? ( + + + + ) : reports.length === 0 ? ( + + + + ) : ( + reports.map((report) => ( + + + + + + + + + + + )) + )} + +
    IDProjectBug IDModified FilesTest PassedStatusDateActions
    Loading...
    No reports found
    #{report.id}{report.project_id} + #{report.error_log_id} + {report.modified_files.length} files + + {report.test_passed ? 'PASS' : 'FAIL'} + + + + {report.status} + + {new Date(report.created_at).toLocaleString()} + + View + +
    +
    + +
    + + + Page {filters.page} of {totalPages} + + +
    +
    + ); +} + +export default RepairList;