diff --git a/README.md b/README.md new file mode 100644 index 0000000..4731168 --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +# AI Code Quality Scanner - 飞书通知版 + +一个自动化代码质量扫描系统,在代码提交时自动扫描并发送报告到飞书。 + +## 功能特性 + +- 🤖 自动监听 Gitea 代码提交事件 +- 🔍 多维度代码质量扫描(语法、风格、安全) +- 📊 生成 Markdown 格式扫描报告 +- 📱 实时推送飞书机器人通知 + +## 系统架构 + +``` +┌─────────────┐ Webhook ┌──────────────────┐ +│ Gitea │ ───────────────► │ Webhook Server │ +│ 代码仓库 │ │ (Flask) │ +└─────────────┘ └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Code Scanner │ + │ - ESLint │ + │ - Pylint │ + │ - SonarQube │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Report Generator│ + │ - Markdown │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Feishu Bot │ + │ - Webhook │ + └──────────────────┘ +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 2. 配置飞书机器人 + +1. 打开飞书群聊 → 设置 → 群机器人 +2. 添加机器人 → 选择"自定义机器人" +3. 获取 Webhook 地址 +4. 配置 `config.yaml` + +### 3. 配置 Gitea Webhook + +#### 方式一:Push 时扫描(原有方式) + +1. 进入 Gitea 仓库 → 设置 → Webhooks +2. 添加 Webhook: + - 目标 URL: `http://你的服务器IP:5000/webhook/gitea` + - 触发事件: Push + - 密钥: 配置 `config.yaml` 中的 secret + +#### 方式二:PR 创建时扫描(推荐) + +1. 进入 Gitea 仓库 → 设置 → Webhooks +2. 添加 Webhook: + - 目标 URL: `http://你的服务器IP:5000/webhook/gitea` + - 触发事件: Pull Request + - 密钥: 配置 `config.yaml` 中的 secret + +**支持的 PR 事件:** +- `opened` - 创建新 PR +- `reopened` - 重新打开 PR +- `synchronize` - PR 中的提交有更新 +- `ready_for_review` - PR 标记为准备好审查 + +### 4. 运行服务 + +```bash +python app.py +``` + +## 配置说明 + +所有配置在 `config.yaml` 中: + +```yaml +server: + host: "0.0.0.0" + port: 5000 + debug: true + +gitea: + base_url: "http://localhost:3000" + # Webhook 签名密钥 + webhook_secret: "your_webhook_secret" + +feishu: + # 飞书机器人 Webhook 地址 + webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + # 消息推送 secret(可选,用于签名) + secret: "your_feishu_secret" + +scanner: + # 支持的语言 + languages: + - python + - javascript + - typescript + # 扫描阈值 + max_issues: 10 + # 是否启用详细扫描 + detailed: true + +report: + # 报告保存路径 + output_dir: "./reports" + # 是否保留报告文件 + keep_files: true +``` + +## 项目结构 + +``` +code-scanner/ +├── app.py # 主应用入口 +├── config.yaml # 配置文件 +├── requirements.txt # Python 依赖 +├── README.md # 项目说明 +├── scanner/ +│ ├── __init__.py +│ ├── base.py # 扫描器基类 +│ ├── python_scanner.py # Python 代码扫描 +│ ├── js_scanner.py # JavaScript/TypeScript 扫描 +│ └── security_scanner.py # 安全扫描 +├── report/ +│ ├── __init__.py +│ └── generator.py # Markdown 报告生成 +├── notify/ +│ ├── __init__.py +│ └── feishu.py # 飞书通知 +├── webhook/ +│ ├── __init__.py +│ └── handler.py # Webhook 处理 +└── reports/ # 报告输出目录 +``` + +## 支持的扫描工具 + +### Python +- **Pylint** - 代码风格和错误检查 +- **Flake8** - Python 代码检查 +- **Bandit** - 安全漏洞扫描 + +### JavaScript/TypeScript +- **ESLint** - JavaScript/TypeScript 检查 +- **Prettier** - 代码格式化 + +## 飞书消息效果 + +扫描完成后,将收到类似以下消息: + +### Push 扫描消息 + +``` +📊 代码质量扫描报告 + +仓库: my-project +分支: main +提交: abc1234 +提交者: developer@example.com + +✅ 扫描通过 (0 issues) +或 +⚠️ 发现问题 (5 issues) +``` + +### PR 扫描消息 + +``` +📊 PR 代码质量扫描报告 + +仓库: my-project +源分支: feature-xxx → 目标分支: main +PR链接: https://gitea.example.com/user/project/pulls/123 +提交: abc1234 +提交者: developer@example.com + +✅ 扫描通过 (0 issues) +或 +⚠️ 发现问题 (5 issues) +``` + +## Docker 部署 + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . +EXPOSE 5000 + +CMD ["python", "app.py"] +``` + +## 环境变量 + +也可以通过环境变量配置: + +```bash +export FEISHU_WEBHOOK_URL="https://open.feishu.cn/..." +export GITEA_WEBHOOK_SECRET="secret" +export SCANNER_MAX_ISSUES=10 +``` + +## 许可证 + +MIT License diff --git a/app.py b/app.py index 354af0d..699c21d 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,7 @@ from scanner.python_scanner import PythonScanner from scanner.js_scanner import JavaScriptScanner from scanner.security_scanner import SecurityScanner from scanner.ai_reviewer import AIReviewer +from scanner.diff_parser import merge_issues_with_code from report.generator import ReportGenerator from notify.feishu import FeishuNotifier from gitea_client import GiteaClient @@ -232,6 +233,23 @@ def handle_pull_request(payload: Dict[str, Any]) -> Tuple[Dict, int]: clone_url, source_sha, source_branch ) + # 获取 PR 的代码差异,用于将问题与代码片段关联 + pr_diff = None + if '/' in repo_name: + repo_owner, repo_name_only = repo_name.split('/', 1) + else: + repo_owner = 'Bosch_Demo' + repo_name_only = repo_name + + try: + pr_diff = gitea_client.get_pull_request_diff(repo_owner, repo_name_only, pr_number) + logger.info(f"已获取 PR #{pr_number} 的 diff,长度: {len(pr_diff) if pr_diff else 0}") + except Exception as e: + logger.warning(f"获取 PR diff 失败: {e}") + + # 将问题与代码片段关联 + scan_details_with_code = merge_issues_with_code(scan_results, pr_diff or '') + # 生成报告 commit_message = f'PR #{pr_number}: {pr_title}' report = report_generator.generate( @@ -259,7 +277,7 @@ def handle_pull_request(payload: Dict[str, Any]) -> Tuple[Dict, int]: 'target_branch': target_branch, 'author': author } - PRScanDB.save_pr_scan(pr_info_for_db, scan_results, report.get('file_path')) + PRScanDB.save_pr_scan(pr_info_for_db, scan_results, report.get('file_path'), scan_details_with_code) logger.info(f'PR #{pr_number} 扫描完成') @@ -455,12 +473,146 @@ def api_get_pr(pr_id): except: pass + # 返回带代码片段的扫描详情 + if pr.get('scan_details_with_code') and isinstance(pr['scan_details_with_code'], str): + try: + pr['scan_details_with_code'] = json.loads(pr['scan_details_with_code']) + except: + pass + return jsonify(pr) except Exception as e: logger.error(f'获取 PR 详情失败: {str(e)}') return jsonify({'error': str(e)}), 500 +@app.route('/api/prs//diff') +def api_get_pr_diff(pr_id): + """获取 PR 的代码差异""" + try: + pr = PRScanDB.get_pr_by_id(pr_id) + if not pr: + return jsonify({'error': 'PR not found'}), 404 + + repo_name = pr.get('repo_name', '') + pr_number = pr.get('pr_number', 0) + + if not repo_name or not pr_number: + return jsonify({'error': 'PR 信息不完整'}), 400 + + # 解析 owner 和 repo + if '/' in repo_name: + owner, repo = repo_name.split('/', 1) + else: + owner = 'Bosch_Demo' # 默认 + repo = repo_name + + logger.info(f"获取 PR #{pr_number} ({owner}/{repo}) 的 diff") + + # 获取 diff + diff = gitea_client.get_pull_request_diff(owner, repo, pr_number) + if diff is None: + return jsonify({'error': '获取 diff 失败'}), 500 + + return jsonify({ + 'diff': diff, + 'pr_number': pr_number, + 'repo_name': repo_name + }) + + except Exception as e: + logger.error(f'获取 PR diff 失败: {str(e)}') + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/prs//files') +def api_get_pr_files(pr_id): + """获取 PR 变更文件列表(用于左侧树状展示)""" + try: + pr = PRScanDB.get_pr_by_id(pr_id) + if not pr: + return jsonify({'error': 'PR not found'}), 404 + repo_name = pr.get('repo_name', '') + pr_number = pr.get('pr_number', 0) + if not repo_name or not pr_number: + return jsonify({'error': 'PR 信息不完整'}), 400 + if '/' in repo_name: + owner, repo = repo_name.split('/', 1) + else: + owner, repo = 'Bosch_Demo', repo_name + files = gitea_client.get_pull_request_files(owner, repo, pr_number) + if files is None: + return jsonify({'error': '获取文件列表失败'}), 500 + return jsonify({'files': files, 'repo_name': repo_name}) + except Exception as e: + logger.error(f'获取 PR 文件列表失败: {str(e)}') + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/prs//file') +def api_get_pr_file_content(pr_id): + """获取 PR 中某文件在源分支上的完整内容""" + try: + path = request.args.get('path') + if not path: + return jsonify({'error': '缺少 path 参数'}), 400 + pr = PRScanDB.get_pr_by_id(pr_id) + if not pr: + return jsonify({'error': 'PR not found'}), 404 + repo_name = pr.get('repo_name', '') + pr_number = pr.get('pr_number', 0) + if not repo_name or not pr_number: + return jsonify({'error': 'PR 信息不完整'}), 400 + if '/' in repo_name: + owner, repo = repo_name.split('/', 1) + else: + owner, repo = 'Bosch_Demo', repo_name + pr_info = gitea_client.get_pull_request(owner, repo, pr_number) + if not pr_info: + return jsonify({'error': '获取 PR 信息失败'}), 500 + head_ref = pr_info.get('head', {}).get('ref') or pr_info.get('head_branch') or pr.get('source_branch') + if not head_ref: + return jsonify({'error': '无法确定源分支'}), 400 + content = gitea_client.get_file_contents(owner, repo, path, head_ref) + if content is None: + return jsonify({'error': '文件不存在或无法读取'}), 404 + + # 获取该文件的扫描问题(PR 创建时已扫描并存入 scan_details_with_code) + scan_issues = [] + path_norm = path.replace('\\', '/').strip() + scan_details = pr.get('scan_details_with_code') + if isinstance(scan_details, str): + try: + scan_details = json.loads(scan_details) + except Exception: + scan_details = None + if scan_details and scan_details.get('scanners'): + for scanner in scan_details['scanners']: + for issue in scanner.get('issues', []): + issue_file = (issue.get('file') or '').replace('\\', '/').strip() + if not issue_file: + continue + # 匹配:精确相等或一端包含另一端(兼容 basename 或完整路径) + if path_norm == issue_file or path_norm.endswith(issue_file) or issue_file.endswith(path_norm): + sev = (issue.get('severity') or 'info') + if isinstance(sev, str): + sev = sev.lower() + scanner_name = scanner.get('name', '') + scanner_display = {'python': 'Python', 'javascript': 'JavaScript', 'security': 'Security'}.get(scanner_name, scanner_name) + scan_issues.append({ + 'scanner': scanner_display, + 'severity': sev, + 'line': int(issue.get('line') or 0), + 'message': (issue.get('message') or issue.get('description') or '').strip(), + 'code_context': issue.get('code_context') + }) + + return jsonify({'path': path, 'content': content, 'scan_issues': scan_issues}) + except Exception as e: + logger.error(f'获取文件内容失败: {str(e)}') + return jsonify({'error': str(e)}), 500 + + @app.route('/api/prs//merge', methods=['POST']) def api_merge_pr(pr_id): """合并 PR""" diff --git a/db.py b/db.py index 772b82c..249b724 100644 --- a/db.py +++ b/db.py @@ -40,6 +40,7 @@ def init_db(): state TEXT DEFAULT 'pending', scan_status TEXT DEFAULT 'pending', scan_result TEXT, + scan_details_with_code TEXT, issues_count INTEGER DEFAULT 0, security_issues INTEGER DEFAULT 0, ai_review TEXT, @@ -72,16 +73,17 @@ class PRScanDB: """PR 扫描结果数据库操作类""" @staticmethod - def save_pr_scan(pr_info: Dict[str, Any], scan_results: Dict[str, Any], - report_path: str = None) -> int: + def save_pr_scan(pr_info: Dict[str, Any], scan_results: Dict[str, Any], + report_path: str = None, scan_details_with_code: Dict = None) -> int: """ 保存 PR 扫描结果 - + Args: pr_info: PR 信息 scan_results: 扫描结果 report_path: 报告文件路径 - + scan_details_with_code: 带代码片段的扫描详情 + Returns: 扫描记录 ID """ @@ -116,6 +118,7 @@ class PRScanDB: author = ?, scan_status = ?, scan_result = ?, + scan_details_with_code = ?, issues_count = ?, security_issues = ?, ai_review = ?, @@ -129,6 +132,7 @@ class PRScanDB: pr_info.get('author'), 'completed', json.dumps(scan_results, ensure_ascii=False), + json.dumps(scan_details_with_code, ensure_ascii=False) if scan_details_with_code else None, issues_count, security_issues, json.dumps(scan_results.get('ai', {}), ensure_ascii=False), @@ -143,9 +147,9 @@ class PRScanDB: INSERT INTO pr_scans ( pr_number, repo_name, pr_title, pr_url, source_branch, target_branch, author, - state, scan_status, scan_result, + state, scan_status, scan_result, scan_details_with_code, issues_count, security_issues, ai_review, report_path - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( pr_info.get('pr_number'), pr_info.get('repo_name'), @@ -157,6 +161,7 @@ class PRScanDB: 'open', 'completed', json.dumps(scan_results, ensure_ascii=False), + json.dumps(scan_details_with_code, ensure_ascii=False) if scan_details_with_code else None, issues_count, security_issues, json.dumps(scan_results.get('ai', {}), ensure_ascii=False), diff --git a/gitea_client.py b/gitea_client.py index ae1f3a7..d17a933 100644 --- a/gitea_client.py +++ b/gitea_client.py @@ -6,7 +6,7 @@ Gitea API 客户端 """ import logging import requests -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List logger = logging.getLogger(__name__) @@ -167,3 +167,101 @@ class GiteaClient: if pr_info: return pr_info.get('mergeable', False) and pr_info.get('state') == 'open' return False + + def get_pull_request_diff(self, owner: str, repo: str, pr_number: int) -> Optional[str]: + """ + 获取 Pull Request 的代码差异 + + Args: + owner: 仓库所有者 + repo: 仓库名称 + pr_number: PR 编号 + + Returns: + diff 文本,失败返回 None + """ + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}.diff" + + try: + response = requests.get( + url, + headers=self._get_headers(), + timeout=30 + ) + + if response.status_code == 200: + logger.info(f"成功获取 PR #{pr_number} 的 diff") + return response.text + else: + logger.error(f"获取 PR #{pr_number} diff 失败: {response.status_code}") + return None + + except Exception as e: + logger.error(f"获取 PR #{pr_number} diff 异常: {str(e)}") + return None + + def get_pull_request_files(self, owner: str, repo: str, pr_number: int) -> Optional[List[Dict[str, Any]]]: + """ + 获取 PR 中修改的文件列表 + + Args: + owner: 仓库所有者 + repo: 仓库名称 + pr_number: PR 编号 + + Returns: + 文件列表,失败返回 None + """ + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/files" + + try: + response = requests.get( + url, + headers=self._get_headers(), + timeout=30 + ) + + if response.status_code == 200: + logger.info(f"成功获取 PR #{pr_number} 的文件列表") + return response.json() + else: + logger.error(f"获取 PR #{pr_number} 文件列表失败: {response.status_code}") + return None + + except Exception as e: + logger.error(f"获取 PR #{pr_number} 文件列表异常: {str(e)}") + return None + + def get_file_contents(self, owner: str, repo: str, filepath: str, ref: str) -> Optional[str]: + """ + 获取仓库中指定文件在给定 ref(分支/commit)下的内容 + + Args: + owner: 仓库所有者 + repo: 仓库名称 + filepath: 文件路径 + ref: 分支名或 commit SHA + + Returns: + 文件内容文本,失败返回 None + """ + import base64 + import urllib.parse + encoded_path = urllib.parse.quote(filepath, safe='') + url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/contents/{encoded_path}?ref={urllib.parse.quote(ref)}" + try: + response = requests.get( + url, + headers=self._get_headers(), + timeout=30 + ) + if response.status_code == 200: + data = response.json() + if data.get('encoding') == 'base64' and data.get('content'): + return base64.b64decode(data['content']).decode('utf-8', errors='replace') + return None + logger.error(f"获取文件 {filepath} 失败: {response.status_code}") + return None + except Exception as e: + logger.error(f"获取文件内容异常: {str(e)}") + return None diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..8dbdf1a --- /dev/null +++ b/install.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# AI Code Quality Scanner 安装脚本 + +echo "=========================================" +echo " AI Code Quality Scanner 安装脚本" +echo "=========================================" + +# 检查 Python 版本 +if ! command -v python3 &> /dev/null; then + echo "❌ 错误: 未找到 Python3,请先安装 Python 3.8+" + exit 1 +fi + +PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))') +echo "✅ Python 版本: $PYTHON_VERSION" + +# 创建虚拟环境(可选) +if [ ! -d "venv" ]; then + echo "📦 创建虚拟环境..." + python3 -m venv venv +fi + +# 激活虚拟环境 +source venv/bin/activate + +# 安装依赖 +echo "📦 安装 Python 依赖..." +pip install --upgrade pip +pip install -r requirements.txt + +# 创建必要的目录 +echo "📁 创建必要的目录..." +mkdir -p reports +mkdir -p /tmp/code_scanner_clones + +# 检查并安装代码扫描工具(可选) +echo "🛠️ 检查代码扫描工具..." + +# Pylint (Python) +if command -v pylint &> /dev/null || python -m pylint --version &> /dev/null; then + echo " ✅ Pylint 已安装" +else + echo " ⚠️ Pylint 未安装 (pip install pylint)" +fi + +# Flake8 (Python) +if command -v flake8 &> /dev/null || python -m flake8 --version &> /dev/null; then + echo " ✅ Flake8 已安装" +else + echo " ⚠️ Flake8 未安装 (pip install flake8)" +fi + +# Bandit (Python 安全扫描) +if command -v bandit &> /dev/null || python -m bandit --version &> /dev/null; then + echo " ✅ Bandit 已安装" +else + echo " ⚠️ Bandit 未安装 (pip install bandit)" +fi + +# Node.js 和 npm (JavaScript 扫描) +if command -v node &> /dev/null; then + NODE_VERSION=$(node --version) + echo " ✅ Node.js 版本: $NODE_VERSION" +else + echo " ⚠️ Node.js 未安装 (JavaScript 扫描需要)" +fi + +echo "" +echo "=========================================" +echo " 安装完成!" +echo "=========================================" +echo "" +echo "下一步操作:" +echo "1. 编辑 config.yaml 配置飞书机器人和 Gitea" +echo "2. 运行: python app.py" +echo "3. 在 Gitea 中配置 Webhook" +echo "" diff --git a/scanner/diff_parser.py b/scanner/diff_parser.py new file mode 100644 index 0000000..e989e85 --- /dev/null +++ b/scanner/diff_parser.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Diff 解析器 - 将扫描问题与代码片段关联 +""" +import re +import logging +from typing import Dict, List, Any, Optional +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class CodeChunk: + """代码块""" + file_path: str + old_content: str = "" + new_content: str = "" + old_start: int = 0 + new_start: int = 0 + hunks: List[Dict] = field(default_factory=list) + + +class DiffParser: + """Diff 解析器""" + + def __init__(self, diff_text: str): + self.diff_text = diff_text + self.files: Dict[str, CodeChunk] = {} + self._parse() + + def _parse(self): + """解析 diff 文本""" + if not self.diff_text: + return + + current_chunk = None + lines = self.diff_text.split('\n') + for line in lines: + diff_match = re.match(r'diff --git a/(.+) b/(.+)', line) + if diff_match: + file_path = diff_match.group(1) + current_chunk = CodeChunk(file_path=file_path) + self.files[file_path] = current_chunk + continue + + hunk_match = re.match(r'@@ -(\d+),?\d* \+(\d+),?\d* @@', line) + if hunk_match and current_chunk: + current_chunk.old_start = int(hunk_match.group(1)) + current_chunk.new_start = int(hunk_match.group(2)) + continue + + if current_chunk and line: + if line.startswith('+') and not line.startswith('+++'): + current_chunk.new_content += line[1:] + '\n' + elif line.startswith('-') and not line.startswith('---'): + current_chunk.old_content += line[1:] + '\n' + elif line.startswith(' '): + current_chunk.old_content += line[1:] + '\n' + current_chunk.new_content += line[1:] + '\n' + + def get_file_content(self, file_path: str) -> Optional[CodeChunk]: + return self.files.get(file_path) + + def get_line_context(self, file_path: str, line_number: int, context_lines: int = 3) -> Optional[Dict[str, Any]]: + chunk = self.files.get(file_path) + if not chunk: + return None + + new_lines = chunk.new_content.split('\n') + if line_number > len(new_lines): + return None + + start = max(0, line_number - context_lines - 1) + end = min(len(new_lines), line_number + context_lines) + + context = [] + for i in range(start, end): + code = new_lines[i].rstrip('\n') + is_current_line = (i == line_number - 1) + context.append({ + 'line_number': chunk.new_start + i, + 'code': code, + 'is_issue_line': is_current_line + }) + + return { + 'file': file_path, + 'line': line_number, + 'context': context + } + + +def merge_issues_with_code(scan_results: Dict[str, Any], diff: str) -> Dict[str, Any]: + """将扫描问题与代码片段关联""" + if not diff: + return scan_results + + parser = DiffParser(diff) + enriched_results = { + 'scanners': [], + 'summary': scan_results.get('summary', {}), + 'total_issues': scan_results.get('total_issues', 0) + } + + for scanner_name, scanner_data in scan_results.items(): + if scanner_name in ['summary', 'total_issues', 'ai']: + continue + + if isinstance(scanner_data, dict): + enriched_scanner = { + 'name': scanner_name, + 'issues': [], + 'file_count': scanner_data.get('file_count', 0), + 'total_issues': scanner_data.get('total_issues', 0) + } + + issues = scanner_data.get('issues', []) + for issue in issues: + enriched_issue = enrich_issue_with_code(issue, parser) + enriched_scanner['issues'].append(enriched_issue) + + enriched_results['scanners'].append(enriched_scanner) + + if 'ai' in scan_results: + enriched_results['ai'] = scan_results['ai'] + + return enriched_results + + +def enrich_issue_with_code(issue: Dict[str, Any], parser: DiffParser) -> Dict[str, Any]: + """为单个问题添加代码片段""" + enriched = issue.copy() + + file_path = issue.get('file', '') + line_number = issue.get('line', 0) + + if not file_path: + return enriched + + if not line_number: + desc = issue.get('description', '') or issue.get('message', '') + line_match = re.search(r'line[:#]?\s*(\d+)', desc, re.IGNORECASE) + if line_match: + line_number = int(line_match.group(1)) + + matched_path = None + for path in parser.files.keys(): + if file_path.endswith(path) or path.endswith(file_path) or file_path in path: + matched_path = path + break + + if matched_path: + enriched['file'] = matched_path + if matched_path and line_number: + context = parser.get_line_context(matched_path, line_number) + if context: + enriched['code_context'] = context + + if 'code_context' not in enriched and matched_path: + chunk = parser.get_file_content(matched_path) + if chunk and chunk.new_content: + lines = chunk.new_content.split('\n')[:10] + enriched['code_context'] = { + 'file': matched_path, + 'line': line_number or 1, + 'preview': '\n'.join(lines), + 'has_more': len(chunk.new_content.split('\n')) > 10 + } + + return enriched diff --git a/web/index.html b/web/index.html index 9a8d39a..78bb2ce 100644 --- a/web/index.html +++ b/web/index.html @@ -8,6 +8,11 @@ @@ -208,9 +245,9 @@ - +