add fix agent
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m35s
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m35s
This commit is contained in:
parent
704e9b1a83
commit
aab0312cec
85
app/main.py
85
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)):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
21
repair_agent/.env.example
Normal file
21
repair_agent/.env.example
Normal 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
51
repair_agent/README.md
Normal 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
7
repair_agent/__init__.py
Normal 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
163
repair_agent/__main__.py
Normal 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()
|
||||
13
repair_agent/agent/__init__.py
Normal file
13
repair_agent/agent/__init__.py
Normal 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",
|
||||
]
|
||||
167
repair_agent/agent/claude_service.py
Normal file
167
repair_agent/agent/claude_service.py
Normal 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
303
repair_agent/agent/core.py
Normal 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()
|
||||
177
repair_agent/agent/git_manager.py
Normal file
177
repair_agent/agent/git_manager.py
Normal 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 ""
|
||||
152
repair_agent/agent/task_manager.py
Normal file
152
repair_agent/agent/task_manager.py
Normal 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()
|
||||
153
repair_agent/agent/test_runner.py
Normal file
153
repair_agent/agent/test_runner.py
Normal 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,
|
||||
)
|
||||
3
repair_agent/config/__init__.py
Normal file
3
repair_agent/config/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .settings import settings
|
||||
|
||||
__all__ = ["settings"]
|
||||
66
repair_agent/config/settings.py
Normal file
66
repair_agent/config/settings.py
Normal 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()
|
||||
3
repair_agent/models/__init__.py
Normal file
3
repair_agent/models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .bug import Bug, BugStatus, ErrorInfo, FixResult, BatchFixResult, RepairReport
|
||||
|
||||
__all__ = ["Bug", "BugStatus", "ErrorInfo", "FixResult", "BatchFixResult"]
|
||||
94
repair_agent/models/bug.py
Normal file
94
repair_agent/models/bug.py
Normal 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
|
||||
|
||||
8
repair_agent/requirements.txt
Normal file
8
repair_agent/requirements.txt
Normal 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
38
test_claude/buggy_code.py
Normal 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
1
test_claude/test.py
Normal file
@ -0,0 +1 @@
|
||||
def hello(name):\n print('hello, ' + name)
|
||||
@ -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
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
to="/repairs"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
🔧 Repair Reports
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
@ -40,6 +50,8 @@ function App() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/bugs" element={<BugList />} />
|
||||
<Route path="/bugs/:id" element={<BugDetail />} />
|
||||
<Route path="/repairs" element={<RepairList />} />
|
||||
<Route path="/repairs/:id" element={<RepairDetail />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -42,6 +42,20 @@ export interface PaginatedResponse<T> {
|
||||
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<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 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;
|
||||
|
||||
|
||||
87
web/src/pages/RepairDetail.tsx
Normal file
87
web/src/pages/RepairDetail.tsx
Normal 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;
|
||||
158
web/src/pages/RepairList.tsx
Normal file
158
web/src/pages/RepairList.tsx
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user