add fix agent
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m35s

This commit is contained in:
zyc 2026-01-30 14:52:21 +08:00
parent 704e9b1a83
commit aab0312cec
23 changed files with 1820 additions and 7 deletions

View File

@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel import select, func from sqlmodel import select, func
from .database import init_db, get_session 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 datetime import datetime, timedelta
from typing import Optional, List from typing import Optional, List
import hashlib import hashlib
@ -85,8 +85,12 @@ async def get_pending_tasks(project_id: str = None, session: AsyncSession = Depe
results = await session.exec(query) results = await session.exec(query)
return results.all() return results.all()
@app.patch("/api/v1/tasks/{task_id}/status", tags=["Tasks"]) @app.put("/api/v1/tasks/{task_id}/status", tags=["Tasks"])
async def update_task_status(task_id: int, status: LogStatus, session: AsyncSession = Depends(get_session)): async def update_task_status(
task_id: int,
status_update: TaskStatusUpdate,
session: AsyncSession = Depends(get_session)
):
statement = select(ErrorLog).where(ErrorLog.id == task_id) statement = select(ErrorLog).where(ErrorLog.id == task_id)
results = await session.exec(statement) results = await session.exec(statement)
task = results.first() task = results.first()
@ -94,13 +98,86 @@ async def update_task_status(task_id: int, status: LogStatus, session: AsyncSess
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") 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) session.add(task)
await session.commit() await session.commit()
await session.refresh(task) await session.refresh(task)
return {"message": "Status updated", "id": task.id, "status": task.status} 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 ==================== # ==================== Dashboard APIs ====================
@app.get("/api/v1/dashboard/stats", tags=["Dashboard"]) @app.get("/api/v1/dashboard/stats", tags=["Dashboard"])
async def get_dashboard_stats(session: AsyncSession = Depends(get_session)): async def get_dashboard_stats(session: AsyncSession = Depends(get_session)):

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from typing import Optional, Dict from typing import Optional, Dict, List
from sqlmodel import SQLModel, Field, Column, JSON from sqlmodel import SQLModel, Field, Column, JSON, Text
from enum import Enum from enum import Enum
class LogStatus(str, Enum): class LogStatus(str, Enum):
@ -56,3 +56,39 @@ class ErrorLogCreate(SQLModel):
error: Dict # {type, message, file_path, line_number, stack_trace} error: Dict # {type, message, file_path, line_number, stack_trace}
context: Optional[Dict] = {} 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

21
repair_agent/.env.example Normal file
View File

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

51
repair_agent/README.md Normal file
View File

@ -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 <bug_id>
# 查看状态
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 入口
```

7
repair_agent/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""
Repair Agent - 自动化 Bug 修复代理
"""
from .agent import RepairEngine
__version__ = "0.1.0"
__all__ = ["RepairEngine"]

163
repair_agent/__main__.py Normal file
View File

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

View File

@ -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",
]

View File

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

303
repair_agent/agent/core.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
from .settings import settings
__all__ = ["settings"]

View File

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

View File

@ -0,0 +1,3 @@
from .bug import Bug, BugStatus, ErrorInfo, FixResult, BatchFixResult, RepairReport
__all__ = ["Bug", "BugStatus", "ErrorInfo", "FixResult", "BatchFixResult"]

View File

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

View File

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

38
test_claude/buggy_code.py Normal file
View File

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

1
test_claude/test.py Normal file
View File

@ -0,0 +1 @@
def hello(name):\n print('hello, ' + name)

View File

@ -2,6 +2,8 @@ import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import BugList from './pages/BugList'; import BugList from './pages/BugList';
import BugDetail from './pages/BugDetail'; import BugDetail from './pages/BugDetail';
import RepairList from './pages/RepairList';
import RepairDetail from './pages/RepairDetail';
import './index.css'; import './index.css';
function App() { function App() {
@ -31,6 +33,14 @@ function App() {
🐛 Bug List 🐛 Bug List
</NavLink> </NavLink>
</li> </li>
<li className="nav-item">
<NavLink
to="/repairs"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
>
🔧 Repair Reports
</NavLink>
</li>
</ul> </ul>
</nav> </nav>
</aside> </aside>
@ -40,6 +50,8 @@ function App() {
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/bugs" element={<BugList />} /> <Route path="/bugs" element={<BugList />} />
<Route path="/bugs/:id" element={<BugDetail />} /> <Route path="/bugs/:id" element={<BugDetail />} />
<Route path="/repairs" element={<RepairList />} />
<Route path="/repairs/:id" element={<RepairDetail />} />
</Routes> </Routes>
</main> </main>
</div> </div>

View File

@ -42,6 +42,20 @@ export interface PaginatedResponse<T> {
total_pages: number; 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 // API Functions
export const getStats = () => api.get<DashboardStats>('/api/v1/dashboard/stats'); export const getStats = () => api.get<DashboardStats>('/api/v1/dashboard/stats');
@ -57,6 +71,15 @@ export const getBugDetail = (id: number) => api.get<ErrorLog>(`/api/v1/bugs/${id
export const getProjects = () => api.get<{ projects: string[] }>('/api/v1/projects'); export const getProjects = () => api.get<{ projects: string[] }>('/api/v1/projects');
export const updateTaskStatus = (taskId: number, status: string) => 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<PaginatedResponse<RepairReport>>('/api/v1/repair/reports', { params });
export const getRepairReportDetail = (id: number) => api.get<RepairReport>(`/api/v1/repair/reports/${id}`);
export default api; export default api;

View File

@ -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<RepairReport | null>(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 <div className="loading">Loading...</div>;
if (!report) return <div className="error">Report not found</div>;
return (
<div className="bug-detail-page">
<div className="header">
<div className="breadcrumb">
<Link to="/repairs">Repair Reports</Link> / #{report.id}
</div>
<div className="title-row">
<h1>Repair Report #{report.id}</h1>
<span className={`status-badge ${report.status}`}>{report.status}</span>
</div>
</div>
<div className="detail-grid" style={{ gridTemplateColumns: '1fr' }}>
<div className="card">
<h2>Basic Info</h2>
<div className="info-row">
<span>Project:</span> <strong>{report.project_id}</strong>
</div>
<div className="info-row">
<span>Bug ID:</span> <Link to={`/bugs/${report.error_log_id}`}>#{report.error_log_id}</Link>
</div>
<div className="info-row">
<span>Created At:</span> {new Date(report.created_at).toLocaleString()}
</div>
<div className="info-row">
<span>Test Result:</span>
<span style={{ color: report.test_passed ? '#10b981' : '#ef4444', fontWeight: 'bold', marginLeft: '8px' }}>
{report.test_passed ? 'PASS' : 'FAIL'}
</span>
</div>
</div>
<div className="card">
<h2>🤖 AI Analysis</h2>
<div className="stack-trace">
<pre style={{ whiteSpace: 'pre-wrap' }}>{report.ai_analysis}</pre>
</div>
</div>
<div className="card">
<h2>📝 Code Changes</h2>
<div className="stack-trace">
<pre>{report.code_diff || 'No changes recorded'}</pre>
</div>
</div>
<div className="card">
<h2>🧪 Test Output</h2>
<div className="stack-trace">
<pre>{report.test_output}</pre>
</div>
</div>
</div>
</div>
);
}
export default RepairDetail;

View File

@ -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<RepairReport[]>([]);
const [loading, setLoading] = useState(true);
const [projects, setProjects] = useState<string[]>([]);
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 (
<div className="bug-list-page">
<div className="header">
<h1>🔧 Repair Reports</h1>
<div className="filters">
<select
value={filters.project_id}
onChange={(e) => handleFilterChange('project_id', e.target.value)}
>
<option value="">All Projects</option>
{projects.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
<div className="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Project</th>
<th>Bug ID</th>
<th>Modified Files</th>
<th>Test Passed</th>
<th>Status</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="text-center">Loading...</td>
</tr>
) : reports.length === 0 ? (
<tr>
<td colSpan={8} className="text-center">No reports found</td>
</tr>
) : (
reports.map((report) => (
<tr key={report.id}>
<td>#{report.id}</td>
<td>{report.project_id}</td>
<td>
<Link to={`/bugs/${report.error_log_id}`}>#{report.error_log_id}</Link>
</td>
<td>{report.modified_files.length} files</td>
<td>
<span style={{ color: report.test_passed ? '#10b981' : '#ef4444' }}>
{report.test_passed ? 'PASS' : 'FAIL'}
</span>
</td>
<td>
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(report.status) }}
>
{report.status}
</span>
</td>
<td>{new Date(report.created_at).toLocaleString()}</td>
<td>
<Link to={`/repairs/${report.id}`} className="btn-link">
View
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="pagination">
<button
disabled={filters.page === 1}
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page - 1 }))}
>
Previous
</button>
<span>
Page {filters.page} of {totalPages}
</span>
<button
disabled={filters.page === totalPages}
onClick={() => setFilters((prev) => ({ ...prev, page: prev.page + 1 }))}
>
Next
</button>
</div>
</div>
);
}
export default RepairList;