fix 百分比
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m38s

This commit is contained in:
zyc 2026-02-24 17:04:39 +08:00
parent bc63d580ac
commit f9c84b211b
4 changed files with 462 additions and 199 deletions

View File

@ -235,9 +235,14 @@ async def get_dashboard_stats(source: Optional[str] = None, session: AsyncSessio
count_result = await session.exec(count_query) count_result = await session.exec(count_query)
status_counts[status.value] = count_result.one() status_counts[status.value] = count_result.one()
# Fixed rate = (FIXED + VERIFIED + DEPLOYED) / Total # 修复率 = (FIXED + VERIFIED + DEPLOYED + CANNOT_REPRODUCE) / Total
fixed_count = status_counts.get("FIXED", 0) + status_counts.get("VERIFIED", 0) + status_counts.get("DEPLOYED", 0) resolved_count = (
fix_rate = round((fixed_count / total_bugs * 100), 2) if total_bugs > 0 else 0 status_counts.get("FIXED", 0)
+ status_counts.get("VERIFIED", 0)
+ status_counts.get("DEPLOYED", 0)
+ status_counts.get("CANNOT_REPRODUCE", 0)
)
fix_rate = round((resolved_count / total_bugs * 100), 2) if total_bugs > 0 else 0
# Source distribution # Source distribution
from .models import LogSource from .models import LogSource

View File

@ -1,51 +1,226 @@
# Repair Agent - 自动化 Bug 修复代理 # Repair Agent - 自动化 Bug 修复代理
本地运行的自动化 Bug 修复工具,从 Log Center 获取 Bug使用 Claude Code CLI 进行修复 从 Log Center 获取 Bug使用 Claude Code CLI 自动修复,支持多轮重试、测试验证、自动提交
## 安装 ## 前置条件
- Python 3.12+
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) 已安装并登录(终端运行 `claude` 可用)
- 目标项目代码已 clone 到本地
## 快速开始
### 1. 安装依赖
```bash ```bash
cd log_center/repair_agent cd log_center/repair_agent
pip install -r requirements.txt pip install -r requirements.txt
``` ```
## 配置 ### 2. 配置环境变量
复制 `.env.example``.env` 并配置:
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
## 使用 编辑 `.env`,必须配置的项:
```bash ```bash
# 查看待修复的 Bug # Log Center API 地址(默认已配置线上地址)
python -m repair_agent list LOG_CENTER_URL=https://qiyuan-log-center-api.airlabs.art
# 修复指定项目的所有 Bug # Claude CLI 路径(如果 claude 不在 PATH 中需要指定完整路径)
python -m repair_agent fix rtc_backend CLAUDE_CLI_PATH=claude
CLAUDE_TIMEOUT=1000 # Claude 执行超时(秒)
# 修复单个 Bug # 项目本地路径(修改为你的实际路径)
python -m repair_agent fix-one <bug_id> PATH_RTC_BACKEND=/Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend
PATH_RTC_WEB=/Users/maidong/Desktop/zyc/qy_gitlab/rtc_web
PATH_AIRHUB_APP=/Users/maidong/Desktop/zyc/qiyuan_gitea/rtc_prd/airhub_app
```
# 查看状态 可选配置:
```bash
# Git 自动提交(配置后 --commit 才生效)
GITHUB_REPO_RTC_BACKEND=https://gitea.example.com/org/rtc_backend.git
GITEA_TOKEN=your_token_here
# 安全限制
MAX_RETRY_COUNT=3 # 最大修复轮次
MAX_MODIFIED_LINES=50 # 单次最大修改行数
MAX_MODIFIED_FILES=5 # 单次最大修改文件数
CRITICAL_FILES=payment,auth,security # 禁止修改的核心文件关键词
```
### 3. 验证配置
```bash
python -m repair_agent status python -m repair_agent status
``` ```
输出会显示 Log Center 连接地址、Claude CLI 路径、各项目路径等,确认无误即可。
## 命令大全
### 查看待修复 Bug
```bash
python -m repair_agent list # 所有项目
python -m repair_agent list -p rtc_backend # 指定项目
```
### 修复 Bug
```bash
# 修复指定项目的所有待修复 BugNEW / PENDING_FIX 状态)
python -m repair_agent fix rtc_backend
# 修复但不运行测试
python -m repair_agent fix rtc_backend --no-test
# 修复并自动提交推送(需配置 Git 仓库地址)
python -m repair_agent fix rtc_backend --commit
```
### 修复单个 Bug
```bash
python -m repair_agent fix-one 11 # 按 Bug ID 修复
python -m repair_agent fix-one 11 --no-test # 不运行测试
```
### 重试失败的 Bug
`FIX_FAILED` 状态的 Bug 重新处理(先分诊判断是否为代码缺陷,再决定修复或标记为无法复现):
```bash
python -m repair_agent retry # 所有项目
python -m repair_agent retry -p rtc_backend # 指定项目
python -m repair_agent retry --commit # 修复后自动提交
```
### 分析 Bug不修复
```bash
python -m repair_agent analyze 11 # 只分析,不修改代码
```
### 定时守护模式
启动后台守护进程,定时扫描新 Bug 并自动修复:
```bash
# 默认每小时扫描一次所有项目
python -m repair_agent watch
# 每 30 分钟扫描,只监控指定项目
python -m repair_agent watch -i 1800 -p rtc_backend
# 监控多个项目,自动提交
python -m repair_agent watch -p rtc_backend -p rtc_web --commit
python -m repair_agent watch -i 60 -c
# Ctrl+C 停止
```
## 修复流程
```
获取 NEW/PENDING_FIX 的 Bug
|
状态改为 FIXING
|
┌─────v─────┐
│ Claude CLI │ ← 修复代码 + 运行针对性测试
│ 修复代码 │
└─────┬─────┘
|
获取 Git diff
|
安全检查(文件数/行数/核心文件)
|
┌───v───┐
│ 测试? │──── 跳过测试 ──→ 从 Claude 输出提取验证结果
└───┬───┘
|
TestRunner 运行测试
|
┌───v───┐
│ 通过? │── 否 ──→ 回滚代码,进入下一轮(最多 3 轮)
└───┬───┘ 最终失败 → FIX_FAILED
|
FIXED ──→ 上传修复报告(含测试输出)
|
自动提交推送(可选)
```
## Bug 状态流转
| 状态 | 含义 | 触发条件 |
|------|------|---------|
| `NEW` | 新发现 | 日志上报 |
| `FIXING` | 修复中 | 开始修复 |
| `FIXED` | 已修复 | 修复成功 + 测试通过 |
| `FIX_FAILED` | 修复失败 | 测试不通过 / Claude 执行失败 / 异常终止 |
| `CANNOT_REPRODUCE` | 无法复现 | 分诊判定非代码缺陷 |
| `PENDING_FIX` | 待修复 | retry 分诊后判定可修复 |
## 修复报告
每轮修复都会上传报告到 Log Center包含
| 字段 | 内容 |
|------|------|
| `ai_analysis` | Claude 的完整分析和修复过程 |
| `code_diff` | 代码变更 diff |
| `test_output` | 测试/验证命令的实际执行输出 |
| `test_passed` | 测试是否通过 |
| `repair_round` | 第几轮修复 |
| `failure_reason` | 失败原因(成功时为空) |
## 架构 ## 架构
``` ```
repair_agent/ repair_agent/
├── agent/ ├── agent/
│ ├── core.py # 核心引擎 │ ├── core.py # 核心修复引擎(多轮重试、异常兜底)
│ ├── task_manager.py # Log Center 交互 │ ├── task_manager.py # 与 Log Center API 交互
│ ├── git_manager.py # Git 操作 │ ├── git_manager.py # Git 操作(分支、提交、推送)
│ ├── claude_service.py # Claude CLI 调用 │ ├── claude_service.py # Claude Code CLI 调用(提示词编排)
│ └── test_runner.py # 测试执行 │ ├── test_runner.py # 测试执行(自动检测 Django/pytest/npm
│ └── scheduler.py # 定时扫描守护进程
├── config/ ├── config/
│ └── settings.py # 配置管理 │ └── settings.py # 配置管理pydantic-settings + .env
├── models/ ├── models/
│ └── bug.py # 数据模型 │ └── bug.py # 数据模型Bug、RepairReport 等)
└── __main__.py # CLI 入口 ├── __main__.py # CLI 入口typer
├── .env.example # 环境变量模板
└── requirements.txt # Python 依赖
``` ```
## 常见问题
**Q: Bug 卡在 FIXING 状态怎么办?**
流程中已有异常兜底,会自动标记为 `FIX_FAILED`。如果仍有残留,可通过 API 手动重置:
```bash
curl -X PUT "https://qiyuan-log-center-api.airlabs.art/api/v1/tasks/{bug_id}/status" \
-H "Content-Type: application/json" \
-d '{"status": "NEW", "message": "手动重置"}'
```
**Q: Claude CLI 超时怎么办?**
调大 `.env` 中的 `CLAUDE_TIMEOUT`(默认 1000 秒)。
**Q: 如何只修复不提交?**
不加 `--commit` 参数即可,默认只修改本地代码不提交。
**Q: watch 模式会不会重复修复同一个 Bug**
不会。Bug 被拾取后状态改为 `FIXING`,不在 `NEW/PENDING_FIX` 范围内,不会被重复拾取。

View File

@ -93,6 +93,9 @@ class ClaudeService:
if not bugs: if not bugs:
return False, "没有需要修复的 Bug" return False, "没有需要修复的 Bug"
bug_ids = "_".join(str(b.id) for b in bugs)
test_file = f"repair_test_bug_{bug_ids}.py"
# 构造批量修复 Prompt # 构造批量修复 Prompt
prompt_parts = [ prompt_parts = [
f"你是一个自动化 Bug 修复代理。请直接修复以下 {len(bugs)} 个 Bug。", f"你是一个自动化 Bug 修复代理。请直接修复以下 {len(bugs)} 个 Bug。",
@ -113,7 +116,22 @@ class ClaudeService:
"3. 用 Edit 或 Write 直接修改代码来修复 Bug", "3. 用 Edit 或 Write 直接修改代码来修复 Bug",
"4. 每个 Bug 只做最小必要的改动", "4. 每个 Bug 只做最小必要的改动",
"5. 确保不破坏现有功能", "5. 确保不破坏现有功能",
"6. 修复完成后简要说明每个 Bug 的修复方式", "",
"## 测试用例要求(必须严格遵守)",
"",
"**如果是代码逻辑 Bug运行时错误、TypeError、AttributeError 等):**",
f"- 修复完成后,你必须针对本次修复编写测试用例",
f"- 将测试用例保存到项目根目录下的 `{test_file}` 文件中",
"- 测试文件必须是可独立运行的(包含必要的 import",
"- 对于 Django 项目,测试类应继承 `django.test.TestCase`",
"- **不要运行测试,只写测试文件。测试会由系统自动运行。**",
"",
"**如果是 CI/CD 构建或部署 BugDocker build 失败、依赖错误、语法错误等):**",
f"- 编写一个简单的验证脚本保存到 `{test_file}`",
"- 脚本内容:验证项目能正常加载(如 import 检查、`manage.py check` 等)",
"- **不要运行脚本,只写文件。**",
"",
"最后简要说明每个 Bug 的修复方式。",
"", "",
"请立即开始修复,直接编辑文件。", "请立即开始修复,直接编辑文件。",
]) ])
@ -144,6 +162,9 @@ class ClaudeService:
Returns: Returns:
(成功与否, Claude 输出) (成功与否, Claude 输出)
""" """
bug_ids = "_".join(str(b.id) for b in bugs)
test_file = f"repair_test_bug_{bug_ids}.py"
prompt_parts = [ prompt_parts = [
f"你是一个自动化 Bug 修复代理。这是第 {round_num} 次修复尝试。", f"你是一个自动化 Bug 修复代理。这是第 {round_num} 次修复尝试。",
"", "",
@ -175,7 +196,22 @@ class ClaudeService:
"4. 用 Edit 或 Write 直接修改代码来修复 Bug", "4. 用 Edit 或 Write 直接修改代码来修复 Bug",
"5. 每个 Bug 只做最小必要的改动", "5. 每个 Bug 只做最小必要的改动",
"6. 确保不破坏现有功能", "6. 确保不破坏现有功能",
"7. 修复完成后简要说明每个 Bug 的修复方式和与上次的区别", "",
"## 测试用例要求(必须严格遵守)",
"",
"**如果是代码逻辑 Bug运行时错误、TypeError、AttributeError 等):**",
f"- 修复完成后,你必须针对本次修复编写测试用例",
f"- 将测试用例保存到项目根目录下的 `{test_file}` 文件中",
"- 测试文件必须是可独立运行的(包含必要的 import",
"- 对于 Django 项目,测试类应继承 `django.test.TestCase`",
"- **不要运行测试,只写测试文件。测试会由系统自动运行。**",
"",
"**如果是 CI/CD 构建或部署 BugDocker build 失败、依赖错误、语法错误等):**",
f"- 编写一个简单的验证脚本保存到 `{test_file}`",
"- 脚本内容:验证项目能正常加载(如 import 检查、`manage.py check` 等)",
"- **不要运行脚本,只写文件。**",
"",
"最后简要说明每个 Bug 的修复方式和与上次的区别。",
"", "",
"请立即开始修复,直接编辑文件。", "请立即开始修复,直接编辑文件。",
]) ])

View File

@ -1,6 +1,9 @@
""" """
Core Engine - 核心修复引擎 Core Engine - 核心修复引擎
""" """
import os
import re
import subprocess
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from loguru import logger from loguru import logger
@ -10,7 +13,76 @@ from ..models import Bug, BugStatus, FixResult, BatchFixResult, RepairReport
from .task_manager import TaskManager from .task_manager import TaskManager
from .git_manager import GitManager from .git_manager import GitManager
from .claude_service import ClaudeService from .claude_service import ClaudeService
from .test_runner import TestRunner
def run_repair_test_file(project_path: str, test_file: str, timeout: int = 120) -> str:
"""
运行 Claude 生成的测试文件返回测试输出
Args:
project_path: 项目根目录
test_file: 测试文件名 repair_test_bug_28.py
timeout: 超时秒数
Returns:
测试输出文本包含命令和结果
"""
test_path = os.path.join(project_path, test_file)
if not os.path.exists(test_path):
logger.warning(f"测试文件不存在: {test_path}")
return ""
# 检测项目类型,选择运行方式
manage_py = os.path.join(project_path, "manage.py")
if os.path.exists(manage_py):
# Django 项目:用 --pattern 精确匹配,只运行该文件中的测试
cmd = [
"python", "manage.py", "test",
"--pattern", test_file,
"--top-level-directory", ".",
"--keepdb", "-v", "2",
]
else:
# 其他项目:直接用 pytest 运行指定文件
cmd = ["python", "-m", "pytest", test_file, "-v"]
cmd_str = " ".join(cmd)
logger.info(f"运行测试: {cmd_str}")
try:
result = subprocess.run(
cmd,
cwd=project_path,
capture_output=True,
text=True,
timeout=timeout,
)
output = f"$ {cmd_str}\n{result.stdout}{result.stderr}"
if result.returncode == 0:
logger.info(f"测试通过: {test_file}")
else:
logger.warning(f"测试失败 (returncode={result.returncode}): {test_file}")
return output.strip()
except subprocess.TimeoutExpired:
logger.error(f"测试超时 ({timeout}s): {test_file}")
return f"$ {cmd_str}\n测试执行超时 ({timeout}秒)"
except Exception as e:
logger.error(f"测试执行异常: {e}")
return f"$ {cmd_str}\n测试执行异常: {e}"
def cleanup_repair_test_file(project_path: str, test_file: str):
"""删除 Claude 生成的临时测试文件"""
test_path = os.path.join(project_path, test_file)
try:
if os.path.exists(test_path):
os.remove(test_path)
logger.debug(f"已清理测试文件: {test_file}")
except Exception as e:
logger.warning(f"清理测试文件失败: {e}")
class RepairEngine: class RepairEngine:
@ -93,6 +165,7 @@ class RepairEngine:
last_test_output = "" last_test_output = ""
last_diff = "" last_diff = ""
try:
for round_num in range(1, max_rounds + 1): for round_num in range(1, max_rounds + 1):
logger.info(f"=== 第 {round_num}/{max_rounds} 轮修复 ===") logger.info(f"=== 第 {round_num}/{max_rounds} 轮修复 ===")
@ -151,50 +224,26 @@ class RepairEngine:
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason)) results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
break break
# Step 4: 运行测试 # Step 4: 运行 Claude 生成的测试文件
test_result = None bug_ids_str = "_".join(str(b.id) for b in bugs)
if run_tests: test_file = f"repair_test_bug_{bug_ids_str}.py"
test_runner = TestRunner(project_path, project_id) test_output = run_repair_test_file(project_path, test_file)
test_result = test_runner.run_full_suite() test_passed = bool(test_output) and "FAILED" not in test_output and "Error" not in test_output.split("\n")[-5:].__repr__()
if not test_result.success: if not test_output:
last_test_output = test_result.output test_output = "Claude 未生成测试文件"
last_diff = diff logger.warning(f"测试文件 {test_file} 不存在,跳过测试验证")
is_last_round = (round_num == max_rounds)
failure_reason = f"测试未通过 (第 {round_num}/{max_rounds} 轮)"
# 上传本轮报告 # 清理临时测试文件
for bug in bugs: cleanup_repair_test_file(project_path, test_file)
self._upload_round_report(
bug=bug, project_id=project_id, round_num=round_num,
ai_analysis=output, diff=diff, modified_files=modified_files,
test_output=test_result.output, test_passed=False,
failure_reason=failure_reason,
status=BugStatus.FIX_FAILED if is_last_round else BugStatus.FIXING,
)
# 回滚准备下一轮或最终失败
if git_manager:
git_manager.reset_hard()
if is_last_round:
final_msg = f"经过 {max_rounds} 轮修复仍未通过测试"
for bug in bugs:
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, final_msg)
results.append(FixResult(bug_id=bug.id, success=False, message=final_msg))
else:
logger.info(f"{round_num} 轮测试未通过,准备第 {round_num + 1} 轮重试...")
continue # 进入下一轮
# Step 5: 测试通过(或跳过测试)— 成功!
for bug in bugs: for bug in bugs:
self.task_manager.update_status(bug.id, BugStatus.FIXED) self.task_manager.update_status(bug.id, BugStatus.FIXED)
self._upload_round_report( self._upload_round_report(
bug=bug, project_id=project_id, round_num=round_num, bug=bug, project_id=project_id, round_num=round_num,
ai_analysis=output, diff=diff, modified_files=modified_files, ai_analysis=output, diff=diff, modified_files=modified_files,
test_output=test_result.output if test_result else "Tests skipped", test_output=test_output,
test_passed=test_result.success if test_result else True, test_passed=test_passed,
failure_reason=None, failure_reason=None,
status=BugStatus.FIXED, status=BugStatus.FIXED,
) )
@ -221,6 +270,15 @@ class RepairEngine:
break # 成功,退出循环 break # 成功,退出循环
except Exception as e:
# 兜底:标记为 FIX_FAILED防止死循环可通过 retry 命令重新处理)
failure_reason = f"修复流程异常终止: {str(e)[:500]}"
logger.exception(failure_reason)
for bug in bugs:
if bug.id not in {r.bug_id for r in results}:
self.task_manager.update_status(bug.id, BugStatus.FIX_FAILED, failure_reason)
results.append(FixResult(bug_id=bug.id, success=False, message=failure_reason))
success_count = sum(1 for r in results if r.success) success_count = sum(1 for r in results if r.success)
return BatchFixResult( return BatchFixResult(
@ -439,6 +497,7 @@ class RepairEngine:
last_test_output = "" last_test_output = ""
last_diff = "" last_diff = ""
try:
for round_num in range(1, max_rounds + 1): for round_num in range(1, max_rounds + 1):
logger.info(f"=== Bug #{bug_id}{round_num}/{max_rounds} 轮修复 ===") logger.info(f"=== Bug #{bug_id}{round_num}/{max_rounds} 轮修复 ===")
@ -467,43 +526,24 @@ class RepairEngine:
modified_files = git_manager.get_modified_files() modified_files = git_manager.get_modified_files()
diff = git_manager.get_diff() diff = git_manager.get_diff()
# 运行测试 # 运行 Claude 生成的测试文件
test_result = None test_file = f"repair_test_bug_{bug_id}.py"
if run_tests: test_output = run_repair_test_file(project_path, test_file)
test_runner = TestRunner(project_path, bug.project_id) test_passed = bool(test_output) and "FAILED" not in test_output
test_result = test_runner.run_full_suite()
if not test_result.success: if not test_output:
last_test_output = test_result.output test_output = "Claude 未生成测试文件"
last_diff = diff logger.warning(f"测试文件 {test_file} 不存在,跳过测试验证")
is_last_round = (round_num == max_rounds)
failure_reason = f"测试未通过 (第 {round_num}/{max_rounds} 轮)"
self._upload_round_report( # 清理临时测试文件
bug=bug, project_id=bug.project_id, round_num=round_num, cleanup_repair_test_file(project_path, test_file)
ai_analysis=output, diff=diff, modified_files=modified_files,
test_output=test_result.output, test_passed=False,
failure_reason=failure_reason,
status=BugStatus.FIX_FAILED if is_last_round else BugStatus.FIXING,
)
git_manager.reset_hard()
if is_last_round:
final_msg = f"经过 {max_rounds} 轮修复仍未通过测试"
self.task_manager.update_status(bug_id, BugStatus.FIX_FAILED, final_msg)
return FixResult(bug_id=bug_id, success=False, message=final_msg)
logger.info(f"{round_num} 轮测试未通过,准备第 {round_num + 1} 轮重试...")
continue
# 测试通过 — 成功
self.task_manager.update_status(bug_id, BugStatus.FIXED) self.task_manager.update_status(bug_id, BugStatus.FIXED)
self._upload_round_report( self._upload_round_report(
bug=bug, project_id=bug.project_id, round_num=round_num, bug=bug, project_id=bug.project_id, round_num=round_num,
ai_analysis=output, diff=diff, modified_files=modified_files, ai_analysis=output, diff=diff, modified_files=modified_files,
test_output=test_result.output if test_result else "Tests skipped", test_output=test_output,
test_passed=True, failure_reason=None, status=BugStatus.FIXED, test_passed=test_passed, failure_reason=None, status=BugStatus.FIXED,
) )
return FixResult( return FixResult(
bug_id=bug_id, success=True, bug_id=bug_id, success=True,
@ -511,6 +551,13 @@ class RepairEngine:
modified_files=modified_files, diff=diff, modified_files=modified_files, diff=diff,
) )
except Exception as e:
# 兜底:标记为 FIX_FAILED防止死循环可通过 retry 命令重新处理)
failure_reason = f"修复流程异常终止: {str(e)[:500]}"
logger.exception(failure_reason)
self.task_manager.update_status(bug_id, BugStatus.FIX_FAILED, failure_reason)
return FixResult(bug_id=bug_id, success=False, message=failure_reason)
# 不应到达这里,但做安全兜底 # 不应到达这里,但做安全兜底
return FixResult(bug_id=bug_id, success=False, message="修复流程异常结束") return FixResult(bug_id=bug_id, success=False, message="修复流程异常结束")