From 625e53dc44ab1c0055f97d391c80cc46da42be42 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Tue, 24 Feb 2026 11:18:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(project-mgmt):=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20+=20=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=20+=20=E5=89=8D=E7=AB=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Project 模型(repo_url, local_path, name, description) - 项目 CRUD API(GET/PUT /api/v1/projects) - 日志上报自动 upsert Project 记录 - ErrorLog 增加 failure_reason 字段 - update_task_status / create_repair_report 写入失败原因 - Repair Agent 优先从 API 获取项目配置,回退 .env - 新增 Web 端「项目管理」页面(表格 + 行内编辑) - BugList/BugDetail/RepairList 展示失败原因 - 更新接入指南文档 Co-Authored-By: Claude Opus 4.6 --- app/database.py | 2 + app/main.py | 62 ++++- app/models.py | 37 ++- docs/integration_guide.md | 216 ++++++++++++++--- docs/project-management-repair-plan.md | 317 +++++++++++++++++++++++++ repair_agent/agent/core.py | 27 ++- repair_agent/agent/task_manager.py | 10 + web/src/App.tsx | 13 +- web/src/api.ts | 20 +- web/src/index.css | 18 ++ web/src/pages/BugDetail.tsx | 12 +- web/src/pages/BugList.tsx | 17 +- web/src/pages/ProjectList.tsx | 258 ++++++++++++++++++++ web/src/pages/RepairList.tsx | 16 +- 14 files changed, 953 insertions(+), 72 deletions(-) create mode 100644 docs/project-management-repair-plan.md create mode 100644 web/src/pages/ProjectList.tsx diff --git a/app/database.py b/app/database.py index aeb047f..978df94 100644 --- a/app/database.py +++ b/app/database.py @@ -32,6 +32,8 @@ async def init_db(): "ALTER TABLE errorlog ALTER COLUMN file_path DROP NOT NULL", "ALTER TABLE errorlog ALTER COLUMN line_number DROP NOT NULL", "CREATE INDEX IF NOT EXISTS ix_errorlog_source ON errorlog (source)", + # ErrorLog failure_reason + "ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS failure_reason TEXT", ] for sql in migrations: try: diff --git a/app/main.py b/app/main.py index 90f36a6..f876a0b 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel import select, func from .database import init_db, get_session -from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate +from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate from datetime import datetime, timedelta from typing import Optional, List import hashlib @@ -61,6 +61,17 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g await session.refresh(existing_log) return {"message": "Regression detected, reopened", "id": existing_log.id} + # Upsert Project record + proj_stmt = select(Project).where(Project.project_id == log_data.project_id) + proj_result = await session.exec(proj_stmt) + project = proj_result.first() + if not project: + project = Project(project_id=log_data.project_id) + if log_data.repo_url: + project.repo_url = log_data.repo_url + project.updated_at = datetime.utcnow() + session.add(project) + # Create new new_log = ErrorLog( project_id=log_data.project_id, @@ -78,11 +89,11 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g fingerprint=fingerprint, timestamp=log_data.timestamp or datetime.utcnow() ) - + session.add(new_log) await session.commit() await session.refresh(new_log) - + return {"message": "Log reported", "id": new_log.id} # ==================== Agent Tasks ==================== @@ -111,8 +122,9 @@ async def update_task_status( raise HTTPException(status_code=404, detail="Task not found") task.status = status_update.status - # We could log the message to a history table if needed - + if status_update.message and status_update.status == LogStatus.FIX_FAILED: + task.failure_reason = status_update.message + session.add(task) await session.commit() await session.refresh(task) @@ -129,13 +141,15 @@ async def create_repair_report(report: RepairTaskCreate, session: AsyncSession = repair_task = RepairTask.from_orm(report) session.add(repair_task) - # 2. Update error log status (optional, but good for consistency) + # 2. Update error log status and failure_reason 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 + if report.failure_reason and report.status == LogStatus.FIX_FAILED: + error_log.failure_reason = report.failure_reason session.add(error_log) await session.commit() @@ -298,14 +312,44 @@ async def get_bug_detail(bug_id: int, session: AsyncSession = Depends(get_sessio return bug -@app.get("/api/v1/projects", tags=["Dashboard"]) +# ==================== Project Management ==================== +@app.get("/api/v1/projects", tags=["Projects"]) async def get_projects(session: AsyncSession = Depends(get_session)): - """Get list of all unique project IDs""" - query = select(ErrorLog.project_id).distinct() + """Get list of all projects with full info""" + query = select(Project).order_by(Project.updated_at.desc()) results = await session.exec(query) projects = results.all() return {"projects": projects} +@app.get("/api/v1/projects/{project_id}", tags=["Projects"]) +async def get_project_detail(project_id: str, session: AsyncSession = Depends(get_session)): + """Get single project detail""" + statement = select(Project).where(Project.project_id == project_id) + results = await session.exec(statement) + project = results.first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + +@app.put("/api/v1/projects/{project_id}", tags=["Projects"]) +async def update_project(project_id: str, data: ProjectUpdate, session: AsyncSession = Depends(get_session)): + """Update project config (repo_url, local_path, name, description)""" + statement = select(Project).where(Project.project_id == project_id) + results = await session.exec(statement) + project = results.first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(project, key, value) + project.updated_at = datetime.utcnow() + + session.add(project) + await session.commit() + await session.refresh(project) + return project + @app.get("/", tags=["Health"]) async def health_check(): return {"status": "ok"} diff --git a/app/models.py b/app/models.py index cbdb6db..65ee4f3 100644 --- a/app/models.py +++ b/app/models.py @@ -19,12 +19,30 @@ class LogStatus(str, Enum): DEPLOYED = "DEPLOYED" FIX_FAILED = "FIX_FAILED" +class Project(SQLModel, table=True): + """项目信息""" + id: Optional[int] = Field(default=None, primary_key=True) + project_id: str = Field(unique=True, index=True) + name: Optional[str] = Field(default=None) + repo_url: Optional[str] = Field(default=None) + local_path: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + +class ProjectUpdate(SQLModel): + """项目编辑 schema""" + name: Optional[str] = None + repo_url: Optional[str] = None + local_path: Optional[str] = None + description: Optional[str] = None + class ErrorLog(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) project_id: str = Field(index=True) environment: str level: str - + # Source source: str = Field(default="runtime", index=True) @@ -34,24 +52,24 @@ class ErrorLog(SQLModel, table=True): file_path: Optional[str] = None line_number: Optional[int] = None stack_trace: str = Field(sa_column=Column(JSON)) # Store full stack trace - + # Context context: Dict = Field(default={}, sa_column=Column(JSON)) - + # Versioning version: Optional[str] = None commit_hash: Optional[str] = None - + # Meta timestamp: datetime = Field(default_factory=datetime.utcnow) fingerprint: str = Field(unique=True, index=True) # project_id + error_type + file_path + line_number - + # Status Tracking status: LogStatus = Field(default=LogStatus.NEW) retry_count: int = Field(default=0) - - # Relationships could be added here (e.g., to a RepairTask table if 1:N) - # For simplicity, we track status on the Log itself for now. + + # Repair Tracking + failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) # Pydantic Models for API class ErrorLogCreate(SQLModel): @@ -61,7 +79,8 @@ class ErrorLogCreate(SQLModel): timestamp: Optional[datetime] = None version: Optional[str] = None commit_hash: Optional[str] = None - + repo_url: Optional[str] = None + source: str = "runtime" error: Dict # {type, message, file_path, line_number, stack_trace} context: Optional[Dict] = {} diff --git a/docs/integration_guide.md b/docs/integration_guide.md index 54c0c46..03ec46a 100644 --- a/docs/integration_guide.md +++ b/docs/integration_guide.md @@ -2,7 +2,13 @@ ## 概述 -Log Center 是一个集中式错误日志收集平台,提供 REST API 供各项目接入,实现运行时错误的统一收集、去重、追踪和分析。 +Log Center 是一个集中式错误日志收集与 AI 自动修复平台,提供 REST API 供各项目接入,实现运行时错误的统一收集、去重、追踪、分析和自动修复。 + +接入流程: + +1. 项目首次上报日志时自动注册到 Log Center +2. 在 Web 管理端配置项目的**仓库地址**和**本地路径** +3. Repair Agent 根据配置自动拉取代码并修复 Bug --- @@ -13,7 +19,7 @@ Log Center 是一个集中式错误日志收集平台,提供 REST API 供各 | 环境 | API 地址 | 仪表盘 | |------|----------|--------| | 本地开发 | `http://localhost:8002` | `http://localhost:8003` | -| 生产环境 | `https://log.yourcompany.com` | `https://log.yourcompany.com` | +| 生产环境 | `https://qiyuan-log-center-api.airlabs.art` | `https://qiyuan-log-center.airlabs.art` | --- @@ -33,6 +39,7 @@ Log Center 是一个集中式错误日志收集平台,提供 REST API 供各 "timestamp": "2026-01-30T10:30:00Z", "version": "1.2.3", "commit_hash": "abc1234", + "repo_url": "https://gitea.example.com/team/rtc_backend.git", "error": { "type": "ValueError", "message": "invalid literal for int() with base 10: 'abc'", @@ -58,43 +65,99 @@ Log Center 是一个集中式错误日志收集平台,提供 REST API 供各 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `project_id` | string | ✅ | 项目标识,如 `rtc_backend`, `rtc_web` | +| `project_id` | string | ✅ | 项目标识,如 `rtc_backend`, `rtc_web`, `airhub_app` | | `environment` | string | ✅ | 环境:`development`, `staging`, `production` | | `level` | string | ✅ | 日志级别:`ERROR`, `WARNING`, `CRITICAL` | +| `source` | string | ❌ | 来源:`runtime`(默认), `cicd`, `deployment` | | `timestamp` | string | ❌ | ISO 8601 格式,不传则使用服务器时间 | | `version` | string | ❌ | 应用版本号 | | `commit_hash` | string | ❌ | Git commit hash | +| `repo_url` | string | ❌ | 项目仓库地址,首次上报时传入可自动关联到项目 | | `error.type` | string | ✅ | 异常类型,如 `ValueError`, `TypeError` | | `error.message` | string | ✅ | 错误消息 | -| `error.file_path` | string | ✅ | 出错文件路径 | -| `error.line_number` | int | ✅ | 出错行号 | +| `error.file_path` | string | ❌ | 出错文件路径(runtime 必填,cicd/deployment 可选) | +| `error.line_number` | int | ❌ | 出错行号(runtime 必填,cicd/deployment 可选) | | `error.stack_trace` | array | ✅ | 堆栈信息(数组或字符串) | | `context` | object | ❌ | 额外上下文信息(URL、用户ID等) | +> **项目自动注册**: 首次上报日志时,系统会根据 `project_id` 自动创建项目记录。如果同时传入 `repo_url`,会自动关联仓库地址,供 Repair Agent 使用。 + #### 响应 **成功 (200)** ```json { - "status": "ok", - "id": 123, - "fingerprint": "a1b2c3d4e5f6", - "is_new": true + "message": "Log reported", + "id": 123 } ``` **已存在 (200)** - 重复错误自动去重 ```json { - "status": "duplicate", + "message": "Log deduplicated", "id": 123, - "fingerprint": "a1b2c3d4e5f6", - "is_new": false + "status": "NEW" } ``` --- +### 项目管理 API + +项目在首次日志上报时自动创建,之后可通过 API 或 Web 管理端编辑配置。 + +#### 获取项目列表 + +**GET** `/api/v1/projects` + +```json +{ + "projects": [ + { + "id": 1, + "project_id": "rtc_backend", + "name": "RTC 后端", + "repo_url": "https://gitea.example.com/team/rtc_backend.git", + "local_path": "/home/dev/projects/rtc_backend", + "description": "Django 后端服务", + "created_at": "2026-01-15T08:00:00", + "updated_at": "2026-02-20T10:30:00" + } + ] +} +``` + +#### 获取项目详情 + +**GET** `/api/v1/projects/{project_id}` + +返回单个项目的完整信息。 + +#### 编辑项目配置 + +**PUT** `/api/v1/projects/{project_id}` + +```json +{ + "name": "RTC 后端", + "repo_url": "https://gitea.example.com/team/rtc_backend.git", + "local_path": "/home/dev/projects/rtc_backend", + "description": "Django 后端服务" +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `name` | string | 项目显示名称 | +| `repo_url` | string | Git 仓库地址(Repair Agent 克隆/推送代码用) | +| `local_path` | string | 本地项目路径(Repair Agent 在此目录执行修复) | +| `description` | string | 项目描述 | + +> **注意**: `repo_url` 和 `local_path` 是 Repair Agent 正常工作的关键配置。未配置时 Agent 将无法执行 Git 操作或定位项目代码。可在 Web 管理端的「项目管理」页面中配置。 + +--- + ## 接入示例 ### Python (Django / FastAPI) @@ -110,11 +173,12 @@ def report_error(exc, context=None): """上报错误到 Log Center""" tb = traceback.extract_tb(exc.__traceback__) last_frame = tb[-1] if tb else None - + payload = { "project_id": "rtc_backend", "environment": os.getenv("ENVIRONMENT", "development"), "level": "ERROR", + "repo_url": os.getenv("REPO_URL", ""), # 可选:关联仓库地址 "error": { "type": type(exc).__name__, "message": str(exc), @@ -124,7 +188,7 @@ def report_error(exc, context=None): }, "context": context or {} } - + try: requests.post( f"{LOG_CENTER_URL}/api/v1/logs/report", @@ -146,7 +210,7 @@ def custom_exception_handler(exc, context): "view": str(context.get("view")), "request_path": context.get("request").path if context.get("request") else None, }) - + # ... 原有逻辑不变 ... ``` @@ -161,6 +225,7 @@ interface ErrorPayload { project_id: string; environment: string; level: string; + repo_url?: string; error: { type: string; message: string; @@ -175,7 +240,7 @@ export function reportError(error: Error, context?: Record) { // 解析堆栈信息 const stackLines = error.stack?.split('\n') || []; const match = stackLines[1]?.match(/at\s+.*\s+\((.+):(\d+):\d+\)/); - + const payload: ErrorPayload = { project_id: 'rtc_web', environment: import.meta.env.MODE, @@ -225,7 +290,7 @@ request.interceptors.response.use( method: error.config?.method, status: error.response?.status, }); - + // ... 原有逻辑不变 ... } ); @@ -233,15 +298,78 @@ request.interceptors.response.use( --- +### Flutter (Dart) + +```dart +import 'dart:convert'; +import 'package:http/http.dart' as http; + +const logCenterUrl = String.fromEnvironment( + 'LOG_CENTER_URL', + defaultValue: 'http://localhost:8002', +); + +Future reportError(dynamic error, StackTrace stackTrace, {Map? context}) async { + final stackLines = stackTrace.toString().split('\n'); + // 解析第一行获取文件和行号 + final match = RegExp(r'#0\s+.*\((.+):(\d+):\d+\)').firstMatch(stackLines.first); + + final payload = { + 'project_id': 'airhub_app', + 'environment': const String.fromEnvironment('ENVIRONMENT', defaultValue: 'development'), + 'level': 'ERROR', + 'repo_url': 'https://gitea.example.com/team/airhub_app.git', + 'error': { + 'type': error.runtimeType.toString(), + 'message': error.toString(), + 'file_path': match?.group(1) ?? 'unknown', + 'line_number': int.tryParse(match?.group(2) ?? '0') ?? 0, + 'stack_trace': stackLines.take(20).toList(), + }, + 'context': context ?? {}, + }; + + try { + await http.post( + Uri.parse('$logCenterUrl/api/v1/logs/report'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ).timeout(const Duration(seconds: 3)); + } catch (_) { + // 静默失败 + } +} +``` + +在 `main.dart` 中全局捕获: + +```dart +void main() { + FlutterError.onError = (details) { + reportError(details.exception, details.stack ?? StackTrace.current); + }; + + runZonedGuarded(() { + runApp(const MyApp()); + }, (error, stack) { + reportError(error, stack); + }); +} +``` + +--- + ## 错误去重机制 -Log Center 使用 **指纹(fingerprint)** 对错误进行去重: +Log Center 使用 **指纹(fingerprint)** 对错误进行去重,按来源使用不同的指纹策略: -``` -fingerprint = MD5(project_id + error_type + file_path + line_number) -``` +| 来源 | 指纹组成 | +|------|----------| +| `runtime` | `MD5(project_id \| error_type \| file_path \| line_number)` | +| `cicd` | `MD5(project_id \| cicd \| error_type \| job_name \| step_name)` | +| `deployment` | `MD5(project_id \| deployment \| error_type \| namespace \| deployment_name)` | -相同指纹的错误只会记录一次,后续只更新计数和最后出现时间。 +相同指纹的错误只会记录一次。如果已修复的错误再次出现,系统会自动重新打开(回归检测)。 --- @@ -259,21 +387,43 @@ NEW → VERIFYING → PENDING_FIX → FIXING → FIXED → VERIFIED → DEPLOYED | `VERIFYING` | 正在验证复现 | | `CANNOT_REPRODUCE` | 无法复现 | | `PENDING_FIX` | 等待修复 | -| `FIXING` | 正在修复中 | +| `FIXING` | AI Agent 正在修复中 | | `FIXED` | 已修复,待验证 | | `VERIFIED` | 已验证修复 | | `DEPLOYED` | 已部署上线 | -| `FIX_FAILED` | 修复失败 | +| `FIX_FAILED` | 修复失败(失败原因会记录到数据库并在 Web 端展示) | + +--- + +## Web 管理端 + +### 项目管理 + +访问 Web 管理端的「项目管理」页面,可以: + +- 查看所有已注册项目及其配置状态 +- 编辑项目的**仓库地址**(`repo_url`)和**本地路径**(`local_path`) +- 未配置的字段会标红提示 + +> Repair Agent 依赖这两个配置来定位项目代码和执行 Git 操作。请确保在接入后及时配置。 + +### 缺陷追踪 + +- **缺陷列表**: 按项目、来源、状态筛选,修复失败的缺陷会直接显示失败原因 +- **缺陷详情**: 查看完整错误信息、堆栈、上下文,以及修复历史记录 +- **修复报告**: 查看每轮 AI 修复的详细过程(分析、代码变更、测试结果、失败原因) --- ## 最佳实践 -1. **设置超时**: 上报请求设置 3 秒超时,避免影响主业务 -2. **静默失败**: 上报失败不应影响用户体验 -3. **异步上报**: 使用异步方式上报,不阻塞主流程 -4. **添加上下文**: 尽量添加有用的上下文信息(用户ID、请求URL等) -5. **环境区分**: 正确设置 `environment` 字段区分开发/生产 +1. **首次接入时传入 `repo_url`**: 在日志上报中包含仓库地址,省去手动配置步骤 +2. **设置超时**: 上报请求设置 3 秒超时,避免影响主业务 +3. **静默失败**: 上报失败不应影响用户体验 +4. **异步上报**: 使用异步方式上报,不阻塞主流程 +5. **添加上下文**: 尽量添加有用的上下文信息(用户ID、请求URL等) +6. **环境区分**: 正确设置 `environment` 字段区分开发/生产 +7. **配置本地路径**: 接入后在 Web 端配置 `local_path`,使 Repair Agent 能正确定位代码 --- @@ -284,6 +434,7 @@ NEW → VERIFYING → PENDING_FIX → FIXING → FIXED → VERIFIED → DEPLOYED # .env LOG_CENTER_URL=http://localhost:8002 ENVIRONMENT=development +REPO_URL=https://gitea.example.com/team/rtc_backend.git # 可选 ``` ### JavaScript 项目 @@ -292,6 +443,13 @@ ENVIRONMENT=development VITE_LOG_CENTER_URL=http://localhost:8002 ``` +### Flutter 项目 +```bash +# 编译时传入 +flutter run --dart-define=LOG_CENTER_URL=http://localhost:8002 +flutter run --dart-define=ENVIRONMENT=development +``` + --- ## API 文档 diff --git a/docs/project-management-repair-plan.md b/docs/project-management-repair-plan.md new file mode 100644 index 0000000..01c5160 --- /dev/null +++ b/docs/project-management-repair-plan.md @@ -0,0 +1,317 @@ +# Log Center 改进方案:项目管理 + 修复失败追踪 + +## 背景 + +Log Center 当前存在三个核心问题: + +1. **Repair Agent 无法识别项目仓库和本地路径** — 仓库地址(`repo_url`)和本地项目路径(`local_path`)都硬编码在 `repair_agent/.env`,需手动配置,且当前全部为空。应在接入日志收集时自动注册项目信息,并可在 Web 端统一管理。 +2. **修复失败原因未上报** — `ErrorLog` 表没有 `failure_reason` 字段;`update_task_status` API 接收了 `message` 参数但直接丢弃(`main.py:113`);`upload_report` 失败时原因静默丢失。 +3. **前端未展示失败原因** — BugList / BugDetail 页面无法看到修复失败的具体原因,用户必须逐一查看 RepairTask 记录才能找到。 + +--- + +## 第一部分:项目管理(仓库地址 + 本地路径 + Web 管理页) + +### 1.1 新增 `Project` 数据模型 + +**文件**: `app/models.py` + +```python +class Project(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + project_id: str = Field(unique=True, index=True) # "rtc_backend" + name: Optional[str] = Field(default=None) # 显示名称 + repo_url: Optional[str] = Field(default=None) # GitHub/Gitea 仓库地址 + local_path: Optional[str] = Field(default=None) # 本地项目路径(Agent 修复时使用) + description: Optional[str] = Field(default=None) # 项目描述 + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) +``` + +新增 `ProjectUpdate` schema 用于编辑: + +```python +class ProjectUpdate(SQLModel): + name: Optional[str] = None + repo_url: Optional[str] = None + local_path: Optional[str] = None + description: Optional[str] = None +``` + +### 1.2 日志上报自动注册项目 + +**文件**: `app/models.py` — `ErrorLogCreate` 增加字段 + +```python +class ErrorLogCreate(SQLModel): + # ... 现有字段 ... + repo_url: Optional[str] = None # 可选:上报时附带仓库地址 +``` + +**文件**: `app/main.py` — `report_log()` 增加 upsert 逻辑 + +收到日志上报时: +1. 根据 `project_id` 查找 Project 记录 +2. 不存在 → 自动创建(`project_id` + `repo_url`) +3. 已存在且 `repo_url` 不为空 → 更新 `repo_url` + +> 向后兼容:不传 `repo_url` 时仍正常创建 Project 记录(`repo_url` 为空),后续可在 Web 端手动补充。 + +### 1.3 项目管理 API + +**文件**: `app/main.py` + +| 端点 | 方法 | 用途 | +|------|------|------| +| `GET /api/v1/projects` | GET | 项目列表(改造现有端点,从 Project 表查询,返回完整信息) | +| `GET /api/v1/projects/{project_id}` | GET | 获取单个项目详情(含 repo_url、local_path) | +| `PUT /api/v1/projects/{project_id}` | PUT | 编辑项目信息(repo_url、local_path、name、description) | + +#### GET /api/v1/projects 响应示例 + +```json +{ + "projects": [ + { + "id": 1, + "project_id": "rtc_backend", + "name": "RTC 后端", + "repo_url": "https://gitea.example.com/team/rtc_backend.git", + "local_path": "/Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend", + "description": "Django 后端服务", + "created_at": "2026-01-15T08:00:00", + "updated_at": "2026-02-20T10:30:00" + } + ] +} +``` + +#### PUT /api/v1/projects/{project_id} 请求示例 + +```json +{ + "name": "RTC 后端", + "repo_url": "https://gitea.example.com/team/rtc_backend.git", + "local_path": "/Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend", + "description": "Django 后端服务" +} +``` + +### 1.4 Repair Agent 适配 + +#### task_manager.py — 新增 `get_project_info()` + +```python +def get_project_info(self, project_id: str) -> Optional[dict]: + """从 Log Center API 获取项目配置(repo_url + local_path)""" + response = self.client.get(f"{self.base_url}/api/v1/projects/{project_id}") + return response.json() # { repo_url, local_path, ... } +``` + +#### core.py — 修改 `fix_project()` 和 `fix_single_bug()` + +优先级链: +- **local_path**:API 返回值 → `settings.get_project_path()` (.env) → 报错退出 +- **repo_url**:API 返回值 → `settings.get_github_repo()` (.env) → 跳过 Git 操作 + +```python +# 获取项目配置(API 优先) +project_info = self.task_manager.get_project_info(project_id) + +project_path = (project_info and project_info.get("local_path")) or settings.get_project_path(project_id) +github_repo = (project_info and project_info.get("repo_url")) or settings.get_github_repo(project_id) +``` + +### 1.5 Web 端项目管理页面 + +**新增文件**: `web/src/pages/ProjectList.tsx` + +功能: +- 表格展示所有项目:project_id、名称、仓库地址、本地路径、描述、更新时间 +- 行内编辑:点击「编辑」按钮进入编辑模式,可修改所有可编辑字段 +- 状态指示:`repo_url` / `local_path` 为空时标红显示「未配置」 +- 移动端适配:卡片式布局 + +**修改文件**: `web/src/App.tsx` +- 侧边栏新增「项目管理」入口(使用 `FolderGit2` 图标) +- 路由新增 `/projects` → `ProjectList` + +**修改文件**: `web/src/api.ts` + +```typescript +export interface Project { + id: number; + project_id: string; + name: string | null; + repo_url: string | null; + local_path: string | null; + description: string | null; + created_at: string; + updated_at: string; +} + +export const getProjectList = () => + api.get<{ projects: Project[] }>('/api/v1/projects'); + +export const getProjectDetail = (projectId: string) => + api.get(`/api/v1/projects/${projectId}`); + +export const updateProject = (projectId: string, data: Partial) => + api.put(`/api/v1/projects/${projectId}`, data); +``` + +### 1.6 数据库变更 + +```sql +-- 新建 Project 表 +CREATE TABLE project ( + id SERIAL PRIMARY KEY, + project_id VARCHAR UNIQUE NOT NULL, + name VARCHAR, + repo_url VARCHAR, + local_path VARCHAR, + description VARCHAR, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE UNIQUE INDEX ix_project_project_id ON project (project_id); +``` + +--- + +## 第二部分:修复失败原因录入数据库 + +### 2.1 ErrorLog 增加 `failure_reason` 字段 + +**文件**: `app/models.py` + +```python +class ErrorLog(SQLModel, table=True): + # ... 现有字段 ... + failure_reason: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True)) +``` + +### 2.2 `update_task_status` API 存储 message + +**文件**: `app/main.py` — `update_task_status()` + +当前代码(第 113 行): +```python +task.status = status_update.status +# We could log the message to a history table if needed ← message 被丢弃了 +``` + +修改为: +```python +task.status = status_update.status +if status_update.message and status_update.status == LogStatus.FIX_FAILED: + task.failure_reason = status_update.message +``` + +### 2.3 `create_repair_report` API 同步更新 ErrorLog + +**文件**: `app/main.py` — `create_repair_report()` + +当前仅在 `FIXED` / `FIX_FAILED` 时更新 ErrorLog 的 status。增加逻辑: + +```python +if error_log: + error_log.status = report.status + if report.failure_reason and report.status == LogStatus.FIX_FAILED: + error_log.failure_reason = report.failure_reason + session.add(error_log) +``` + +### 2.4 数据库变更 + +```sql +ALTER TABLE errorlog ADD COLUMN failure_reason TEXT; +``` + +--- + +## 第三部分:前端展示失败原因 + +### 3.1 TypeScript 类型更新 + +**文件**: `web/src/api.ts` + +```typescript +export interface ErrorLog { + // ... 现有字段 ... + failure_reason: string | null; +} +``` + +### 3.2 BugList 页面 + +**文件**: `web/src/pages/BugList.tsx` + +当 bug 状态为 `FIX_FAILED` 时,在状态列旁或下方显示 `failure_reason` 摘要(超长截断)。 + +### 3.3 BugDetail 页面 + +**文件**: `web/src/pages/BugDetail.tsx` + +当状态为 `FIX_FAILED` 且 `failure_reason` 不为空时,增加醒目的失败原因卡片: + +```tsx +{bug.failure_reason && ( +
+

修复失败原因

+
{bug.failure_reason}
+
+)} +``` + +### 3.4 RepairList 页面 + +**文件**: `web/src/pages/RepairList.tsx` + +表格新增「失败原因」列,显示截断文本(`text-overflow: ellipsis`)。 + +--- + +## 文件变更清单 + +### 后端 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `app/models.py` | 修改 | 新增 Project + ProjectUpdate;ErrorLog 增加 failure_reason | +| `app/main.py` | 修改 | 项目 CRUD API;修改 report_log / update_task_status / create_repair_report | +| `app/database.py` | 修改 | 追加迁移 SQL(Project 表 + failure_reason 列) | +| `repair_agent/agent/task_manager.py` | 修改 | 新增 get_project_info() | +| `repair_agent/agent/core.py` | 修改 | fix_project / fix_single_bug 优先使用 API 配置 | + +### 前端 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `web/src/api.ts` | 修改 | Project 类型 + API 函数;ErrorLog 增加 failure_reason | +| `web/src/App.tsx` | 修改 | 路由 + 侧边栏增加「项目管理」 | +| `web/src/pages/ProjectList.tsx` | **新增** | 项目列表 + 行内编辑 | +| `web/src/pages/BugList.tsx` | 修改 | 展示 failure_reason | +| `web/src/pages/BugDetail.tsx` | 修改 | 突出展示 failure_reason | +| `web/src/pages/RepairList.tsx` | 修改 | 增加失败原因列 | + +--- + +## 实施顺序 + +1. **后端模型** — 新增 Project 模型 + ErrorLog 增加 failure_reason + 数据库迁移 +2. **后端 API** — 项目 CRUD + 修改 report_log / update_task_status / create_repair_report +3. **Repair Agent** — task_manager 获取项目配置 + core.py 优先使用 API 配置 +4. **前端 API 层** — api.ts 类型和函数更新 +5. **前端页面** — ProjectList 新页面 + BugList / BugDetail / RepairList 展示 failure_reason +6. **路由 / 侧边栏** — App.tsx 接入项目管理入口 + +--- + +## 验证方式 + +1. 调用 `POST /api/v1/logs/report` 带 `repo_url` → 检查 Project 表自动创建 +2. Web 端「项目管理」页 → 编辑 `repo_url` / `local_path` → 验证保存成功 +3. Repair Agent `status` 命令 → 验证读取到 API 配置的 `repo_url` + `local_path` +4. 触发一次修复失败 → 检查 `ErrorLog.failure_reason` 已写入 +5. Web 端 BugList / BugDetail / RepairList → 确认失败原因正确展示 diff --git a/repair_agent/agent/core.py b/repair_agent/agent/core.py index 5b58639..038e807 100644 --- a/repair_agent/agent/core.py +++ b/repair_agent/agent/core.py @@ -38,9 +38,12 @@ class RepairEngine: BatchFixResult """ logger.info(f"开始修复项目: {project_id}") - - # 获取项目路径 - project_path = settings.get_project_path(project_id) + + # 从 API 获取项目配置(优先),回退到 .env + project_info = self.task_manager.get_project_info(project_id) + project_path = (project_info and project_info.get("local_path")) or settings.get_project_path(project_id) + api_repo_url = (project_info and project_info.get("repo_url")) or "" + if not project_path: logger.error(f"未找到项目路径配置: {project_id}") return BatchFixResult( @@ -50,7 +53,7 @@ class RepairEngine: failed_count=0, results=[], ) - + # 获取待修复的 Bug bugs = self.task_manager.fetch_pending_bugs(project_id) if not bugs: @@ -65,12 +68,12 @@ class RepairEngine: logger.info(f"获取到 {len(bugs)} 个待修复 Bug") - # 检查是否启用 Git - git_enabled = settings.is_git_enabled(project_id) + # 检查是否启用 Git(API 优先,回退 .env) + github_repo = api_repo_url or settings.get_github_repo(project_id) + git_enabled = bool(github_repo) git_manager = None if git_enabled: - github_repo = settings.get_github_repo(project_id) git_manager = GitManager(project_path, github_repo=github_repo) git_manager.pull() @@ -78,7 +81,7 @@ class RepairEngine: if auto_commit: git_manager.create_branch(branch_name) else: - logger.info(f"项目 {project_id} 未配置 GitHub 仓库,跳过 Git 操作") + logger.info(f"项目 {project_id} 未配置仓库地址,跳过 Git 操作") # 更新所有 Bug 状态为 FIXING for bug in bugs: @@ -302,7 +305,11 @@ class RepairEngine: message="Bug 不存在", ) - project_path = settings.get_project_path(bug.project_id) + # 从 API 获取项目配置(优先),回退到 .env + project_info = self.task_manager.get_project_info(bug.project_id) + project_path = (project_info and project_info.get("local_path")) or settings.get_project_path(bug.project_id) + github_repo = (project_info and project_info.get("repo_url")) or settings.get_github_repo(bug.project_id) + if not project_path: return FixResult( bug_id=bug_id, @@ -313,7 +320,7 @@ class RepairEngine: self.task_manager.update_status(bug_id, BugStatus.FIXING) max_rounds = settings.max_retry_count - git_manager = GitManager(project_path) + git_manager = GitManager(project_path, github_repo=github_repo) if github_repo else GitManager(project_path) last_test_output = "" last_diff = "" diff --git a/repair_agent/agent/task_manager.py b/repair_agent/agent/task_manager.py index efb860c..1ba26d8 100644 --- a/repair_agent/agent/task_manager.py +++ b/repair_agent/agent/task_manager.py @@ -149,6 +149,16 @@ class TaskManager: logger.error(f"上传修复报告失败: {e}") return False + def get_project_info(self, project_id: str) -> Optional[dict]: + """从 Log Center API 获取项目配置(repo_url + local_path)""" + try: + response = self.client.get(f"{self.base_url}/api/v1/projects/{project_id}") + response.raise_for_status() + return response.json() + except httpx.HTTPError as e: + logger.warning(f"获取项目 {project_id} 配置失败: {e}") + return None + def close(self): """关闭连接""" self.client.close() diff --git a/web/src/App.tsx b/web/src/App.tsx index 5dc6893..35fbb6b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route, NavLink, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Bug, Wrench, Shield, Menu, X } from 'lucide-react'; +import { LayoutDashboard, Bug, Wrench, FolderGit2, Shield, Menu, X } from 'lucide-react'; 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 ProjectList from './pages/ProjectList'; import './index.css'; function AppLayout() { @@ -76,6 +77,15 @@ function AppLayout() { 修复报告 +
  • + `nav-link ${isActive ? 'active' : ''}`} + > + + 项目管理 + +
  • @@ -87,6 +97,7 @@ function AppLayout() { } /> } /> } /> + } /> diff --git a/web/src/api.ts b/web/src/api.ts index 2aa9ccd..d549f4b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -26,6 +26,18 @@ export interface ErrorLog { fingerprint: string; status: string; retry_count: number; + failure_reason: string | null; +} + +export interface Project { + id: number; + project_id: string; + name: string | null; + repo_url: string | null; + local_path: string | null; + description: string | null; + created_at: string; + updated_at: string; } export interface DashboardStats { @@ -73,7 +85,13 @@ export const getBugs = (params: { export const getBugDetail = (id: number) => api.get(`/api/v1/bugs/${id}`); -export const getProjects = () => api.get<{ projects: string[] }>('/api/v1/projects'); +export const getProjects = () => api.get<{ projects: Project[] }>('/api/v1/projects'); + +export const getProjectDetail = (projectId: string) => + api.get(`/api/v1/projects/${projectId}`); + +export const updateProject = (projectId: string, data: Partial) => + api.put(`/api/v1/projects/${projectId}`, data); export const updateTaskStatus = (taskId: number, status: string, message?: string) => api.put(`/api/v1/tasks/${taskId}/status`, { status, message }); diff --git a/web/src/index.css b/web/src/index.css index 29a2eb4..2168880 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -792,6 +792,24 @@ td a:hover { text-decoration: underline; } +/* ============ Edit Input ============ */ + +.edit-input { + width: 100%; + padding: 6px 10px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + outline: none; + transition: border-color 0.15s ease; +} + +.edit-input:focus { + border-color: var(--accent); +} + /* ============ Trigger Repair Button ============ */ .trigger-repair-btn { diff --git a/web/src/pages/BugDetail.tsx b/web/src/pages/BugDetail.tsx index cd709c3..8e61a15 100644 --- a/web/src/pages/BugDetail.tsx +++ b/web/src/pages/BugDetail.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useParams, Link, useLocation } from 'react-router-dom'; -import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History } from 'lucide-react'; +import { ArrowLeft, Play, Loader2, FileCode, GitCommit, History, AlertTriangle } from 'lucide-react'; import { getBugDetail, triggerRepair, getRepairReportsByBug, type ErrorLog, type RepairReport } from '../api'; const SOURCE_LABELS: Record = { @@ -206,6 +206,16 @@ export default function BugDetail() { + {bug.failure_reason && ( +
    +
    + + 修复失败原因 +
    +
    {bug.failure_reason}
    +
    + )} +
    元数据
    diff --git a/web/src/pages/BugList.tsx b/web/src/pages/BugList.tsx index 6c02b5e..831a877 100644 --- a/web/src/pages/BugList.tsx +++ b/web/src/pages/BugList.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; -import { getBugs, getProjects, type ErrorLog } from '../api'; +import { getBugs, getProjects, type ErrorLog, type Project } from '../api'; const STATUSES = [ 'NEW', 'VERIFYING', 'CANNOT_REPRODUCE', 'PENDING_FIX', @@ -31,7 +31,7 @@ export default function BugList() { const [bugs, setBugs] = useState([]); const [loading, setLoading] = useState(true); const [totalPages, setTotalPages] = useState(1); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const currentProject = searchParams.get('project') || ''; const currentStatus = searchParams.get('status') ?? 'NEW'; @@ -106,11 +106,11 @@ export default function BugList() { {projects.map(p => ( ))} @@ -210,6 +210,11 @@ export default function BugList() { {STATUS_LABELS[bug.status] || bug.status} + {bug.status === 'FIX_FAILED' && bug.failure_reason && ( +
    + {bug.failure_reason} +
    + )}
    + @@ -131,6 +132,9 @@ export default function RepairList() { {STATUS_LABELS[report.status] || report.status} +
    {new Date(bug.timestamp).toLocaleString()} diff --git a/web/src/pages/ProjectList.tsx b/web/src/pages/ProjectList.tsx new file mode 100644 index 0000000..b6ba6e1 --- /dev/null +++ b/web/src/pages/ProjectList.tsx @@ -0,0 +1,258 @@ +import { useState, useEffect } from 'react'; +import { getProjects, updateProject, type Project } from '../api'; +import { Save, X, Pencil } from 'lucide-react'; + +export default function ProjectList() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' }); + const [saving, setSaving] = useState(false); + + const fetchProjects = async () => { + setLoading(true); + try { + const res = await getProjects(); + setProjects(res.data.projects); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchProjects(); }, []); + + const startEdit = (p: Project) => { + setEditingId(p.id); + setEditForm({ + name: p.name || '', + repo_url: p.repo_url || '', + local_path: p.local_path || '', + description: p.description || '', + }); + }; + + const cancelEdit = () => { + setEditingId(null); + }; + + const saveEdit = async (projectId: string) => { + setSaving(true); + try { + await updateProject(projectId, editForm); + setEditingId(null); + await fetchProjects(); + } catch (err) { + console.error('保存失败:', err); + } finally { + setSaving(false); + } + }; + + const ConfigBadge = ({ value }: { value: string | null }) => ( + value ? ( + {value} + ) : ( + 未配置 + ) + ); + + if (loading) { + return
    ; + } + + return ( +
    +
    +

    项目管理

    +

    管理项目的仓库地址和本地路径,供 Repair Agent 使用

    +
    + + {projects.length === 0 ? ( +
    暂无项目,首次上报日志后会自动创建
    + ) : ( + <> + {/* Desktop table */} +
    +
    + + + + + + + + + + + + + + {projects.map((p) => ( + + {editingId === p.id ? ( + <> + + + + + + + + + ) : ( + <> + + + + + + + + + )} + + ))} + +
    项目 ID名称仓库地址本地路径描述更新时间操作
    {p.project_id} + setEditForm({ ...editForm, name: e.target.value })} + placeholder="项目名称" + className="edit-input" + /> + + setEditForm({ ...editForm, repo_url: e.target.value })} + placeholder="https://gitea.example.com/..." + className="edit-input" + /> + + setEditForm({ ...editForm, local_path: e.target.value })} + placeholder="/home/user/projects/..." + className="edit-input" + /> + + setEditForm({ ...editForm, description: e.target.value })} + placeholder="项目描述" + className="edit-input" + /> + + {new Date(p.updated_at).toLocaleString()} + +
    + + +
    +
    {p.project_id}{p.name || -}{p.description || '-'} + {new Date(p.updated_at).toLocaleString()} + + +
    +
    + + {/* Mobile card list */} +
    + {projects.map((p) => ( +
    + {editingId === p.id ? ( +
    + {p.project_id} + setEditForm({ ...editForm, name: e.target.value })} + placeholder="项目名称" + className="edit-input" + /> + setEditForm({ ...editForm, repo_url: e.target.value })} + placeholder="仓库地址" + className="edit-input" + /> + setEditForm({ ...editForm, local_path: e.target.value })} + placeholder="本地路径" + className="edit-input" + /> + setEditForm({ ...editForm, description: e.target.value })} + placeholder="描述" + className="edit-input" + /> +
    + + +
    +
    + ) : ( + <> +
    + {p.project_id} + +
    + {p.name &&
    {p.name}
    } +
    +
    仓库:
    +
    路径:
    +
    + {p.description && ( +
    {p.description}
    + )} + + )} +
    + ))} +
    +
    + + )} +
    + ); +} diff --git a/web/src/pages/RepairList.tsx b/web/src/pages/RepairList.tsx index 6fc82df..67a9e49 100644 --- a/web/src/pages/RepairList.tsx +++ b/web/src/pages/RepairList.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { getRepairReports, type RepairReport, getProjects } from '../api'; +import { getRepairReports, type RepairReport, getProjects, type Project } from '../api'; const STATUS_LABELS: Record = { NEW: '新发现', @@ -17,7 +17,7 @@ const STATUS_LABELS: Record = { export default function RepairList() { const [reports, setReports] = useState([]); const [loading, setLoading] = useState(true); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [filters, setFilters] = useState({ project_id: '', page: 1, @@ -75,11 +75,11 @@ export default function RepairList() { {projects.map((p) => ( ))} @@ -105,6 +105,7 @@ export default function RepairList() {
    修改文件数 测试结果 状态失败原因 日期 操作
    + {report.failure_reason || '-'} + {new Date(report.created_at).toLocaleString()}