add web
This commit is contained in:
224
README.md
Normal file
224
README.md
Normal file
@@ -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
|
||||
154
app.py
154
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/<int:pr_id>/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/<int:pr_id>/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/<int:pr_id>/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/<int:pr_id>/merge', methods=['POST'])
|
||||
def api_merge_pr(pr_id):
|
||||
"""合并 PR"""
|
||||
|
||||
11
db.py
11
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,
|
||||
@@ -73,7 +74,7 @@ class PRScanDB:
|
||||
|
||||
@staticmethod
|
||||
def save_pr_scan(pr_info: Dict[str, Any], scan_results: Dict[str, Any],
|
||||
report_path: str = None) -> int:
|
||||
report_path: str = None, scan_details_with_code: Dict = None) -> int:
|
||||
"""
|
||||
保存 PR 扫描结果
|
||||
|
||||
@@ -81,6 +82,7 @@ class PRScanDB:
|
||||
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),
|
||||
|
||||
100
gitea_client.py
100
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
|
||||
|
||||
77
install.sh
Normal file
77
install.sh
Normal file
@@ -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 ""
|
||||
172
scanner/diff_parser.py
Normal file
172
scanner/diff_parser.py
Normal file
@@ -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
|
||||
622
web/index.html
622
web/index.html
@@ -8,6 +8,11 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background-color: #f5f7fa; }
|
||||
/* Diff 语法高亮 */
|
||||
#detail-code-diff .diff-add { color: #98c379; background: rgba(72, 120, 80, 0.2); }
|
||||
#detail-code-diff .diff-del { color: #e06c75; background: rgba(180, 80, 80, 0.2); }
|
||||
#detail-code-diff .diff-info { color: #61afef; }
|
||||
#detail-code-diff .diff-header { color: #e5c07b; }
|
||||
.sidebar {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
@@ -43,6 +48,38 @@
|
||||
.issue-high { color: #d32f2f; }
|
||||
.issue-medium { color: #f57c00; }
|
||||
.issue-low { color: #388e3c; }
|
||||
/* PR 详情 - Git 风格:左侧文件树 + 右侧文件内容 */
|
||||
.pr-detail-file-layout { display: flex; height: 500px; border: 1px solid #dee2e6; border-radius: 6px; overflow: hidden; }
|
||||
.pr-detail-file-tree { width: 280px; min-width: 280px; overflow-y: auto; background: #f8f9fa; border-right: 1px solid #dee2e6; }
|
||||
.pr-detail-file-tree .tree-root { padding: 8px 0; }
|
||||
.pr-detail-file-tree .tree-node { padding: 2px 8px; font-size: 13px; cursor: pointer; border-radius: 4px; }
|
||||
.pr-detail-file-tree .tree-node:hover { background: #e9ecef; }
|
||||
.pr-detail-file-tree .tree-node.active { background: #0d6efd; color: #fff; }
|
||||
.pr-detail-file-tree .tree-folder { font-weight: 600; color: #495057; }
|
||||
.pr-detail-file-tree .tree-file { padding-left: 12px; }
|
||||
/* 代码在中间,右侧缺陷标注,虚线连接代码行与标注(参考审阅风格) */
|
||||
.pr-detail-file-right { flex: 1; display: flex; overflow: hidden; min-width: 0; }
|
||||
.pr-detail-file-wrapper { width: 100%; height: 100%; min-width: 0; overflow: hidden; }
|
||||
.pr-detail-code-view { width: 100%; height: 100%; overflow: auto; background: #1e1e1e; font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; line-height: 1.5; }
|
||||
.pr-detail-code-view .code-line { display: flex; min-height: 1.5em; align-items: stretch; }
|
||||
.pr-detail-code-view .code-line .line-gutter { width: 48px; min-width: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 8px; color: #6c757d; background: #252526; user-select: none; }
|
||||
.pr-detail-code-view .code-line .line-gutter .line-num { margin-right: 4px; }
|
||||
.pr-detail-code-view .code-line .line-gutter .gutter-icon { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
.pr-detail-code-view .code-line .line-gutter .gutter-icon.icon-error { color: #f14c4c; }
|
||||
.pr-detail-code-view .code-line .line-gutter .gutter-icon.icon-warning { color: #cca700; }
|
||||
.pr-detail-code-view .code-line .line-content { flex: 1; min-width: 0; color: #d4d4d4; padding: 0 12px; white-space: pre-wrap; word-break: break-all; overflow-wrap: break-word; }
|
||||
.pr-detail-code-view .code-line.line-has-issue .line-content { background: rgba(220, 53, 69, 0.1); border-left: 2px solid #dc3545; padding-left: 10px; }
|
||||
/* 虚线连接区:代码与标注之间 */
|
||||
.pr-detail-code-view .code-line .line-connector { width: 20px; min-width: 20px; flex-shrink: 0; position: relative; background: #1e1e1e; }
|
||||
.pr-detail-code-view .code-line.line-has-issue .line-connector::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; margin-top: -1px; height: 0; border-bottom: 1px dashed #f14c4c; }
|
||||
.pr-detail-code-view .code-line .line-annotation { width: 180px; min-width: 180px; flex-shrink: 0; padding: 6px 8px; font-size: 11px; background: #252526; color: #9d9d9d; border-left: 1px solid #3c3c3c; white-space: normal; word-break: break-word; display: flex; align-items: center; font-family: inherit; }
|
||||
.pr-detail-code-view .code-line.line-has-issue .line-annotation { background: #fff8f8; border-left: 2px solid #dc3545; color: #c62828; }
|
||||
.pr-detail-code-view .code-line .line-annotation .anno-text { line-height: 1.4; }
|
||||
.pr-detail-code-view .code-line .line-annotation .anno-icon { color: #dc3545; margin-right: 6px; flex-shrink: 0; }
|
||||
.pr-detail-file-placeholder { color: #6c757d; padding: 24px; text-align: center; width: 100%; }
|
||||
/* PR 详情弹窗加宽:占屏幕大部分宽度,代码区随之扩大 */
|
||||
.modal-dialog-pr-wide { max-width: 92%; width: 92%; margin-left: auto; margin-right: auto; }
|
||||
@media (min-width: 1400px) { .modal-dialog-pr-wide { max-width: 1400px; width: 92%; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -208,9 +245,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PR 详情模态框 -->
|
||||
<!-- PR 详情模态框:加宽整体以扩大代码区域 -->
|
||||
<div class="modal fade" id="prDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-dialog modal-dialog-pr-wide">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="detail-pr-title">PR 详情</h5>
|
||||
@@ -229,16 +266,20 @@
|
||||
<p><strong>安全漏洞:</strong> <span id="detail-security"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav nav-tabs" id="detail-tabs" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-scan-result">扫描结果</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-ai-review">AI 审查</button></li>
|
||||
</ul>
|
||||
<div class="tab-content mt-3">
|
||||
<div class="tab-pane fade show active" id="tab-scan-result">
|
||||
<pre id="detail-scan-result" style="max-height: 400px; overflow: auto; background: #f8f9fa; padding: 15px; border-radius: 5px;"></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-ai-review">
|
||||
<div id="detail-ai-review" style="white-space: pre-wrap; background: #f8f9fa; padding: 15px; border-radius: 5px;"></div>
|
||||
<!-- 仅保留文件 Tab:左侧文件树 + 右侧完整文件内容,最右侧为问题标注 -->
|
||||
<div class="mt-3">
|
||||
<div class="pr-detail-file-layout">
|
||||
<div class="pr-detail-file-tree">
|
||||
<div id="pr-file-tree-loading" class="p-3 text-center text-muted">加载文件列表中...</div>
|
||||
<div id="pr-file-tree" class="tree-root" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="pr-detail-file-right">
|
||||
<div id="pr-file-content-placeholder" class="pr-detail-file-placeholder">点击左侧文件查看完整内容</div>
|
||||
<div id="pr-file-content-wrapper" class="pr-detail-file-wrapper">
|
||||
<div class="pr-detail-code-view" id="pr-file-content"></div>
|
||||
</div>
|
||||
<div id="pr-file-content-error" class="pr-detail-file-placeholder" style="display: none; color: #dc3545;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,6 +300,44 @@
|
||||
<script>
|
||||
let currentPRId = null;
|
||||
let currentPRInfo = null;
|
||||
let currentDiffData = null;
|
||||
let currentAIReview = null;
|
||||
|
||||
// 全局:Markdown 简易格式化
|
||||
function formatMarkdown(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/^### (.+)$/gm, '<h5 class="mt-3 mb-2">$1</h5>')
|
||||
.replace(/^## (.+)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
|
||||
.replace(/^# (.+)$/gm, '<h3 class="mt-3 mb-2">$1</h3>')
|
||||
.replace(/^\*\*(.+):\*\*$/gm, '<strong>$1:</strong>')
|
||||
.replace(/^- /gm, '<br>• ')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// 将 AI 审查对象格式化为 HTML(优点/问题/优化 或 raw_review)
|
||||
function formatReviewObject(review) {
|
||||
if (!review || typeof review !== 'object') return '';
|
||||
if (review.raw_review) return formatMarkdown(review.raw_review);
|
||||
let html = '';
|
||||
if (review['优点'] && review['优点'].length) {
|
||||
html += '<div class="mb-2"><strong class="text-success">优点</strong><ul class="mb-0">';
|
||||
review['优点'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
|
||||
html += '</ul></div>';
|
||||
}
|
||||
if (review['问题'] && review['问题'].length) {
|
||||
html += '<div class="mb-2"><strong class="text-danger">问题</strong><ul class="mb-0">';
|
||||
review['问题'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
|
||||
html += '</ul></div>';
|
||||
}
|
||||
if (review['优化'] && review['优化'].length) {
|
||||
html += '<div class="mb-2"><strong class="text-primary">优化</strong><ul class="mb-0">';
|
||||
review['优化'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
|
||||
html += '</ul></div>';
|
||||
}
|
||||
return html || formatMarkdown(JSON.stringify(review, null, 2));
|
||||
}
|
||||
|
||||
// 页面切换
|
||||
function showPage(page) {
|
||||
@@ -355,36 +434,513 @@
|
||||
document.getElementById('detail-security').innerHTML = (pr.security_issues || 0) > 0 ?
|
||||
'<span class="text-danger">' + pr.security_issues + '</span>' : '0';
|
||||
|
||||
// 扫描结果
|
||||
let scanResult = pr.scan_result;
|
||||
if (typeof scanResult === 'string') {
|
||||
try { scanResult = JSON.parse(scanResult); } catch(e) {}
|
||||
}
|
||||
document.getElementById('detail-scan-result').textContent = JSON.stringify(scanResult, null, 2);
|
||||
|
||||
// AI 审查
|
||||
let aiReview = pr.ai_review;
|
||||
if (typeof aiReview === 'string') {
|
||||
try { aiReview = JSON.parse(aiReview); } catch(e) {}
|
||||
}
|
||||
if (aiReview && aiReview.review) {
|
||||
document.getElementById('detail-ai-review').textContent = aiReview.review;
|
||||
} else {
|
||||
document.getElementById('detail-ai-review').textContent = '无 AI 审查结果';
|
||||
}
|
||||
|
||||
// 根据状态显示/隐藏按钮
|
||||
const canOperate = pr.state === 'open';
|
||||
document.getElementById('btn-merge').style.display = canOperate ? 'inline-block' : 'none';
|
||||
document.getElementById('btn-reject').style.display = canOperate ? 'inline-block' : 'none';
|
||||
|
||||
// 显示模态框
|
||||
new bootstrap.Modal(document.getElementById('prDetailModal')).show();
|
||||
const modal = new bootstrap.Modal(document.getElementById('prDetailModal'));
|
||||
modal.show();
|
||||
|
||||
// 加载文件树(左侧树,点击文件在右侧显示完整内容+标注)
|
||||
loadPRFileTree(id);
|
||||
} catch (e) {
|
||||
alert('加载 PR 详情失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 PR 文件列表并渲染左侧树,点击文件在右侧显示完整内容
|
||||
async function loadPRFileTree(prId) {
|
||||
const loadingEl = document.getElementById('pr-file-tree-loading');
|
||||
const treeEl = document.getElementById('pr-file-tree');
|
||||
const placeholderEl = document.getElementById('pr-file-content-placeholder');
|
||||
const wrapperEl = document.getElementById('pr-file-content-wrapper');
|
||||
const contentEl = document.getElementById('pr-file-content');
|
||||
const errorEl = document.getElementById('pr-file-content-error');
|
||||
|
||||
if (loadingEl) loadingEl.style.display = 'block';
|
||||
if (treeEl) treeEl.style.display = 'none';
|
||||
if (placeholderEl) placeholderEl.style.display = 'block';
|
||||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||||
if (contentEl) contentEl.innerHTML = '';
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/prs/' + prId + '/files');
|
||||
const data = await res.json();
|
||||
if (data.error || !data.files || !data.files.length) {
|
||||
if (treeEl) {
|
||||
treeEl.style.display = 'block';
|
||||
treeEl.innerHTML = '<div class="p-3 text-muted small">' + (data.error || '暂无变更文件') + '</div>';
|
||||
}
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const files = data.files;
|
||||
const tree = buildFileTree(files);
|
||||
if (treeEl) {
|
||||
treeEl.innerHTML = renderFileTreeNodes(tree, prId);
|
||||
treeEl.style.display = 'block';
|
||||
}
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
} catch (e) {
|
||||
if (treeEl) {
|
||||
treeEl.style.display = 'block';
|
||||
treeEl.innerHTML = '<div class="p-3 text-danger small">加载失败: ' + escapeHtml(e.message) + '</div>';
|
||||
}
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 将扁平文件列表转为树结构 { name, children?, path? }
|
||||
function buildFileTree(files) {
|
||||
const root = { name: '', children: {} };
|
||||
for (const f of files) {
|
||||
const path = f.filename || f.path || f;
|
||||
const parts = typeof path === 'string' ? path.split('/') : [String(path)];
|
||||
let cur = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const isFile = i === parts.length - 1;
|
||||
const name = parts[i];
|
||||
if (isFile) {
|
||||
if (!cur.children[name]) cur.children[name] = { name, path, file: true, status: f.status };
|
||||
} else {
|
||||
if (!cur.children[name]) cur.children[name] = { name, children: {} };
|
||||
cur = cur.children[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
// 递归渲染树节点 HTML
|
||||
function renderFileTreeNodes(node, prId) {
|
||||
const entries = Object.keys(node.children || {}).sort((a, b) => {
|
||||
const aa = node.children[a];
|
||||
const ab = node.children[b];
|
||||
const aIsDir = !aa.file;
|
||||
const bIsDir = !ab.file;
|
||||
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
if (entries.length === 0) return '';
|
||||
let html = '';
|
||||
for (const key of entries) {
|
||||
const child = node.children[key];
|
||||
if (child.file) {
|
||||
const status = child.status || '';
|
||||
const statusCls = status === 'added' ? 'text-success' : status === 'removed' ? 'text-danger' : 'text-secondary';
|
||||
html += '<div class="tree-node tree-file ' + statusCls + '" data-path="' + escapeHtml(child.path) + '" data-pr-id="' + prId + '"><i class="bi bi-file-code me-1"></i>' + escapeHtml(key) + '</div>';
|
||||
} else {
|
||||
html += '<div class="tree-folder tree-node">' + escapeHtml(key) + '</div>';
|
||||
html += '<div style="padding-left: 12px;">' + renderFileTreeNodes(child, prId) + '</div>';
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// 点击文件时加载并显示内容
|
||||
document.addEventListener('click', function(ev) {
|
||||
const node = ev.target.closest('.tree-file');
|
||||
if (!node) return;
|
||||
ev.preventDefault();
|
||||
const path = node.getAttribute('data-path');
|
||||
const prId = node.getAttribute('data-pr-id');
|
||||
if (!path || !prId) return;
|
||||
document.querySelectorAll('.pr-detail-file-tree .tree-node.active').forEach(el => el.classList.remove('active'));
|
||||
node.classList.add('active');
|
||||
loadPRFileContent(prId, path);
|
||||
});
|
||||
|
||||
async function loadPRFileContent(prId, path) {
|
||||
const placeholderEl = document.getElementById('pr-file-content-placeholder');
|
||||
const wrapperEl = document.getElementById('pr-file-content-wrapper');
|
||||
const contentEl = document.getElementById('pr-file-content');
|
||||
const errorEl = document.getElementById('pr-file-content-error');
|
||||
|
||||
if (placeholderEl) placeholderEl.style.display = 'none';
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/prs/' + prId + '/file?path=' + encodeURIComponent(path));
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
errorEl.textContent = data.error;
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// 行号 -> 问题列表
|
||||
const lineIssues = {};
|
||||
const issues = data.scan_issues || [];
|
||||
for (const issue of issues) {
|
||||
const line = issue.line || 1;
|
||||
if (!lineIssues[line]) lineIssues[line] = [];
|
||||
lineIssues[line].push(issue);
|
||||
}
|
||||
|
||||
// 代码在中间,右侧缺陷标注,虚线连接代码行与标注
|
||||
const lines = (data.content || '').split('\n');
|
||||
let html = '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNum = i + 1;
|
||||
const rowIssues = lineIssues[lineNum] || [];
|
||||
const hasIssue = rowIssues.length > 0;
|
||||
const rowClass = hasIssue ? 'code-line line-has-issue' : 'code-line';
|
||||
|
||||
const lineText = lines[i];
|
||||
const displayText = lineText === '' ? '\u00A0' : lineText;
|
||||
|
||||
let gutterHtml = '<span class="line-num">' + lineNum + '</span>';
|
||||
if (hasIssue) {
|
||||
const first = rowIssues[0];
|
||||
const sev = first.severity || 'info';
|
||||
const iconClass = (sev === 'error' || sev === 'high') ? 'gutter-icon icon-error' : 'gutter-icon icon-warning';
|
||||
gutterHtml += '<span class="' + iconClass + '"><i class="bi bi-exclamation-circle-fill" style="font-size:12px;"></i></span>';
|
||||
}
|
||||
|
||||
const reasonText = hasIssue ? rowIssues.map(function(iss) {
|
||||
return (iss.scanner ? '[' + iss.scanner + '] ' : '') + (iss.message || '');
|
||||
}).join(';') : '';
|
||||
|
||||
html += '<div class="' + rowClass + '">';
|
||||
html += '<div class="line-gutter">' + gutterHtml + '</div>';
|
||||
html += '<span class="line-content">' + escapeHtml(displayText) + '</span>';
|
||||
html += '<div class="line-connector"></div>';
|
||||
html += '<div class="line-annotation">';
|
||||
if (hasIssue && reasonText) {
|
||||
html += '<span class="anno-icon"><i class="bi bi-exclamation-triangle-fill"></i></span>';
|
||||
html += '<span class="anno-text">' + escapeHtml(reasonText) + '</span>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
contentEl.innerHTML = html;
|
||||
|
||||
if (wrapperEl) wrapperEl.style.display = 'block';
|
||||
} catch (e) {
|
||||
errorEl.textContent = '加载失败: ' + e.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染带代码片段的扫描结果
|
||||
function renderScanDetailsWithCode(scanDetails) {
|
||||
let html = '';
|
||||
|
||||
// 显示汇总信息
|
||||
if (scanDetails.summary) {
|
||||
html += '<div class="alert alert-info mb-3">';
|
||||
html += `<strong>总问题数:</strong> ${scanDetails.total_issues} | `;
|
||||
html += `<strong>扫描器:</strong> ${scanDetails.scanners ? scanDetails.scanners.length : 0}`;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// 遍历每个扫描器
|
||||
if (scanDetails.scanners) {
|
||||
for (const scanner of scanDetails.scanners) {
|
||||
html += `<div class="card mb-3">
|
||||
<div class="card-header bg-light">
|
||||
<strong>${scanner.name}</strong>
|
||||
<span class="badge bg-${scanner.total_issues > 0 ? 'danger' : 'success'}">
|
||||
${scanner.total_issues} 个问题
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">`;
|
||||
|
||||
if (scanner.issues && scanner.issues.length > 0) {
|
||||
for (const issue of scanner.issues) {
|
||||
html += `<div class="issue-item mb-3 p-2 border rounded">`;
|
||||
html += `<div class="fw-bold text-${issue.severity === 'error' ? 'danger' : 'warning'}">`;
|
||||
html += `<i class="bi bi-exclamation-triangle"></i> ${issue.message || issue.description || '问题'}`;
|
||||
html += ` <small class="text-muted">(${issue.file}:${issue.line || '?'})</small>`;
|
||||
html += `</div>`;
|
||||
|
||||
// 显示代码片段
|
||||
if (issue.code_context) {
|
||||
const ctx = issue.code_context;
|
||||
html += `<div class="code-context mt-2" style="background: #1e1e1e; color: #d4d4d4; padding: 10px; border-radius: 4px; font-size: 12px; overflow-x: auto;">`;
|
||||
html += `<div class="text-muted small mb-1">文件: ${ctx.file || issue.file}</div>`;
|
||||
|
||||
if (ctx.context) {
|
||||
// 显示上下文代码
|
||||
for (const line of ctx.context) {
|
||||
const lineClass = line.is_issue_line ? 'background: rgba(255, 200, 0, 0.2);' : '';
|
||||
const prefix = line.is_issue_line ? '👉 ' : ' ';
|
||||
html += `<div style="${lineClass}white-space: pre;">${prefix}${line.line_number}: ${escapeHtml(line.code)}</div>`;
|
||||
}
|
||||
} else if (ctx.preview) {
|
||||
// 显示文件预览
|
||||
html += `<pre style="margin: 0;">${escapeHtml(ctx.preview)}</pre>`;
|
||||
if (ctx.has_more) {
|
||||
html += `<div class="text-muted small">... (更多内容)</div>`;
|
||||
}
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
}
|
||||
} else {
|
||||
html += '<div class="text-muted">没有问题</div>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// HTML 转义
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 加载 PR 代码差异
|
||||
async function loadPRDiff(prId) {
|
||||
try {
|
||||
const response = await fetch('/api/prs/' + prId + '/diff');
|
||||
const data = await response.json();
|
||||
|
||||
const loadingEl = document.getElementById('detail-code-diff-loading');
|
||||
const diffEl = document.getElementById('detail-code-diff');
|
||||
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
|
||||
if (data.error) {
|
||||
if (diffEl) {
|
||||
diffEl.style.display = 'block';
|
||||
diffEl.textContent = '加载失败: ' + data.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 格式化 diff 显示
|
||||
const diff = data.diff || '';
|
||||
if (!diff) {
|
||||
if (diffEl) {
|
||||
diffEl.style.display = 'block';
|
||||
diffEl.textContent = '没有代码差异';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加语法高亮样式
|
||||
const formattedDiff = diff
|
||||
.replace(/^diff --git.*$/gm, match => '\n' + match)
|
||||
.replace(/^@@.*@@$/gm, match => '\n' + match + '\n')
|
||||
.replace(/^\+.*$/gm, match => match)
|
||||
.replace(/^-.*$/gm, match => match);
|
||||
|
||||
if (diffEl) {
|
||||
diffEl.style.display = 'block';
|
||||
diffEl.textContent = diff;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
const loadingEl = document.getElementById('detail-code-diff-loading');
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
if (diffEl) {
|
||||
diffEl.style.display = 'block';
|
||||
diffEl.textContent = '加载失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 AI 审查结果:一行一行展示(每行左侧代码片段 + 右侧审查结果)
|
||||
async function loadAIReview(prId) {
|
||||
try {
|
||||
const prResponse = await fetch('/api/prs/' + prId);
|
||||
const pr = await prResponse.json();
|
||||
if (pr.error) {
|
||||
showAIReviewError('加载失败: ' + pr.error);
|
||||
return;
|
||||
}
|
||||
|
||||
let aiReview = pr.ai_review;
|
||||
if (typeof aiReview === 'string') {
|
||||
try { aiReview = JSON.parse(aiReview); } catch(e) {}
|
||||
}
|
||||
currentAIReview = aiReview;
|
||||
|
||||
const diffResponse = await fetch('/api/prs/' + prId + '/diff');
|
||||
const diffData = await diffResponse.json();
|
||||
currentDiffData = diffData.diff || '';
|
||||
|
||||
const loadingEl = document.getElementById('ai-review-loading');
|
||||
const emptyEl = document.getElementById('ai-review-empty');
|
||||
const contentEl = document.getElementById('ai-review-content');
|
||||
const summaryEl = document.getElementById('ai-review-summary');
|
||||
const rowsEl = document.getElementById('ai-review-rows');
|
||||
|
||||
if (!loadingEl || !emptyEl || !contentEl || !rowsEl) return;
|
||||
|
||||
loadingEl.style.display = 'none';
|
||||
|
||||
// 兼容处理:优先使用 reviews 数组,若无则尝试从 summary 文本中解析文件审查
|
||||
let reviews = [];
|
||||
if (aiReview && Array.isArray(aiReview.reviews)) {
|
||||
reviews = aiReview.reviews;
|
||||
} else if (aiReview && aiReview.summary && typeof aiReview.summary === 'string') {
|
||||
// 尝试从 summary 文本中解析每个文件的审查结果
|
||||
// 格式如: "### 📄 app.py\n\n**优点:**\n- ..."
|
||||
const summaryText = aiReview.summary;
|
||||
// 按 "### 📄 " 分割,提取每个文件的审查
|
||||
const fileBlocks = summaryText.split(/(?=### 📄 )/);
|
||||
for (const block of fileBlocks) {
|
||||
const match = block.match(/### 📄 ([^\n]+)\n([\s\S]*)/);
|
||||
if (match) {
|
||||
const fileName = match[1].trim();
|
||||
const reviewContent = match[2].trim();
|
||||
// 将文本格式转为对象结构
|
||||
const reviewObj = parseReviewTextToObject(reviewContent);
|
||||
reviews.push({ file: fileName, review: reviewObj });
|
||||
}
|
||||
}
|
||||
// 如果解析不出文件,则将整个 summary 作为整体审查
|
||||
if (reviews.length === 0) {
|
||||
reviews = [{ file: '整体审查结果', review: { 'raw_review': summaryText } }];
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:将审查文本转为对象结构
|
||||
function parseReviewTextToObject(text) {
|
||||
const obj = {};
|
||||
// 匹配 "**优点:**" / "**需要改进:**" / "**优化建议:**" 等
|
||||
const sections = { '优点': [], '问题': [], '优化': [] };
|
||||
let currentSection = null; // '优点' | '问题' | '优化'
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
let found = false;
|
||||
if (/优点/.test(line) && /\*\*/.test(line)) {
|
||||
currentSection = '优点';
|
||||
found = true;
|
||||
} else if (/(需要改进|问题)/.test(line) && /\*\*/.test(line)) {
|
||||
currentSection = '问题';
|
||||
found = true;
|
||||
} else if (/(优化建议|优化)/.test(line) && /\*\*/.test(line)) {
|
||||
currentSection = '优化';
|
||||
found = true;
|
||||
}
|
||||
if (!found && currentSection) {
|
||||
const m = line.match(/^- (.+)/);
|
||||
if (m) {
|
||||
sections[currentSection].push(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 清理空数组
|
||||
if (sections['优点'].length) obj['优点'] = sections['优点'];
|
||||
if (sections['问题'].length) obj['问题'] = sections['问题'];
|
||||
if (sections['优化'].length) obj['优化'] = sections['优化'];
|
||||
// 如果解析不出结构,直接返回原始文本
|
||||
return Object.keys(obj).length ? obj : { 'raw_review': text };
|
||||
}
|
||||
|
||||
if (reviews.length === 0) {
|
||||
emptyEl.style.display = 'block';
|
||||
contentEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.style.display = 'none';
|
||||
contentEl.style.display = 'block';
|
||||
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = aiReview.summary || ('已审查 ' + reviews.length + ' 个文件');
|
||||
}
|
||||
|
||||
let rowsHtml = '';
|
||||
const diff = currentDiffData || '';
|
||||
|
||||
for (const item of reviews) {
|
||||
const fileDisplay = item.file || (item.path ? item.path.replace(/^.*[/\\]/, '') : '');
|
||||
const fileForDiff = item.file || fileDisplay;
|
||||
const codeSnippet = diff ? extractFileFromDiff(diff, fileForDiff) : '';
|
||||
const codeDisplay = codeSnippet || '(该文件无 diff 或未在 PR 中修改)';
|
||||
const reviewHtml = formatReviewObject(item.review);
|
||||
|
||||
rowsHtml += `
|
||||
<div class="card mb-3 border">
|
||||
<div class="card-header py-2 bg-light d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-file-code me-1"></i> ${escapeHtml(fileDisplay)}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 border-end">
|
||||
<div class="p-2 bg-dark text-light">
|
||||
<pre class="mb-0 p-2" style="background:#1e1e1e;color:#d4d4d4;font-size:12px;max-height:280px;overflow:auto;white-space:pre;">${escapeHtml(codeDisplay)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3" style="min-height:120px;">${reviewHtml || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
rowsEl.innerHTML = rowsHtml;
|
||||
} catch (e) {
|
||||
showAIReviewError('加载失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示 AI 审查错误信息
|
||||
function showAIReviewError(message) {
|
||||
const loadingEl = document.getElementById('ai-review-loading');
|
||||
const emptyEl = document.getElementById('ai-review-empty');
|
||||
const contentEl = document.getElementById('ai-review-content');
|
||||
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
if (contentEl) contentEl.style.display = 'none';
|
||||
if (emptyEl) {
|
||||
emptyEl.style.display = 'block';
|
||||
emptyEl.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
// 从 diff 中提取指定文件的代码
|
||||
function extractFileFromDiff(diff, targetFile) {
|
||||
const lines = diff.split('\n');
|
||||
let inTargetFile = false;
|
||||
let result = [];
|
||||
let currentFile = '';
|
||||
|
||||
for (const line of lines) {
|
||||
// 检测新文件开始
|
||||
const diffMatch = line.match(/diff --git a\/(.+) b\/(.+)/);
|
||||
if (diffMatch) {
|
||||
currentFile = diffMatch[1];
|
||||
inTargetFile = currentFile.includes(targetFile) || targetFile.includes(currentFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果在目标文件中,收集代码行
|
||||
if (inTargetFile) {
|
||||
// 遇到新文件,停止
|
||||
if (line.startsWith('diff --git')) break;
|
||||
// 跳过 diff 和 hunk 头部
|
||||
if (line.startsWith('@@')) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
// 添加实际代码(去掉 +, -, 前缀,但保留内容)
|
||||
if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
|
||||
result.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
// 监听 AI 审查标签点击
|
||||
// 合并 PR
|
||||
async function mergePR() {
|
||||
if (!currentPRInfo) return;
|
||||
@@ -443,6 +999,10 @@
|
||||
}
|
||||
|
||||
// 初始化
|
||||
window.onerror = function(msg, url, line, col, error) {
|
||||
console.error('Global error:', msg, 'at line', line);
|
||||
return false;
|
||||
};
|
||||
loadDashboard();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
230
快速开始指南.md
Normal file
230
快速开始指南.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 快速开始指南
|
||||
|
||||
本文档将帮助你快速部署 AI Code Quality Scanner 并配置 Gitea Webhook 和飞书通知。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python 3.8+
|
||||
- Git
|
||||
- Node.js 和 npm(用于 JavaScript/TypeScript 扫描,可选)
|
||||
- Docker 和 Docker Compose(可选,用于容器化部署)
|
||||
|
||||
## 步骤 1:配置修改
|
||||
|
||||
### 修改 `config.yaml`
|
||||
|
||||
首先编辑 `config.yaml` 文件,配置以下内容:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 5000 # Webhook 服务端口
|
||||
|
||||
gitea:
|
||||
base_url: "http://服务器IP:3000" # 你的 Gitea 地址
|
||||
webhook_secret: "your_secret_key" # Webhook 签名密钥
|
||||
|
||||
feishu:
|
||||
webhook_url: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # 飞书 Webhook 地址
|
||||
secret: "" # 飞书签名密钥(可选)
|
||||
```
|
||||
|
||||
### 获取飞书 Webhook 地址
|
||||
|
||||
1. 打开飞书群聊
|
||||
2. 点击右上角「...」→「设置」→「群机器人」
|
||||
3. 点击「添加机器人」→「自定义机器人」
|
||||
4. 设置机器人名称,点击「添加」
|
||||
5. 复制 Webhook 地址
|
||||
6. (可选)开启「签名校验」,复制 secret
|
||||
|
||||
### 获取 Gitea Webhook 密钥
|
||||
|
||||
1. 在 Gitea 仓库页面,点击「仓库设置」→「Webhooks」
|
||||
2. 点击「添加 Webhook」→「Gitea」
|
||||
3. 填写以下信息:
|
||||
- 目标 URL: `http://你的服务器IP:5000/webhook/gitea`
|
||||
- 密钥: 自定义一个密钥(如 `my_secret_key`),需要与 config.yaml 中的 `webhook_secret` 一致
|
||||
4. 点击「添加 Webhook」
|
||||
|
||||
## 步骤 2:安装依赖
|
||||
|
||||
### 方式 A:本地安装(Windows/Mac/Linux)
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
install.bat
|
||||
|
||||
# Mac/Linux
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 方式 B:Docker 部署
|
||||
|
||||
```bash
|
||||
# 构建并运行
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## 步骤 3:启动服务
|
||||
|
||||
```bash
|
||||
# 激活虚拟环境(如果使用虚拟环境)
|
||||
# Windows
|
||||
call venv\Scripts\activate.bat
|
||||
|
||||
# Mac/Linux
|
||||
source venv/bin/activate
|
||||
|
||||
# 启动服务
|
||||
python app.py
|
||||
```
|
||||
|
||||
服务启动后,访问 `http://localhost:5000` 可以看到健康检查响应。
|
||||
|
||||
## 步骤 4:测试
|
||||
|
||||
### 测试 Webhook
|
||||
|
||||
在 Gitea 仓库中进行一次代码提交,应该能看到:
|
||||
|
||||
1. 服务端日志显示收到 Webhook 请求
|
||||
2. 代码被克隆到临时目录
|
||||
3. 扫描工具运行
|
||||
4. 飞书群聊收到通知
|
||||
|
||||
### 测试手动扫描
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/scan/manual \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repo_url": "https://github.com/username/repo.git", "branch": "main"}'
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 扫描工具说明
|
||||
|
||||
| 工具 | 语言 | 功能 |
|
||||
|------|------|------|
|
||||
| Pylint | Python | 代码风格和错误检查 |
|
||||
| Flake8 | Python | Python 代码检查 |
|
||||
| Bandit | Python | 安全漏洞扫描 |
|
||||
| ESLint | JavaScript/TypeScript | JS/TS 代码检查 |
|
||||
|
||||
### 配置文件选项
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0" # 监听地址
|
||||
port: 5000 # 监听端口
|
||||
debug: true # 调试模式
|
||||
|
||||
gitea:
|
||||
base_url: "http://localhost:3000" # Gitea 地址
|
||||
webhook_secret: "secret" # Webhook 签名密钥
|
||||
|
||||
feishu:
|
||||
webhook_url: "https://..." # 飞书 Webhook
|
||||
secret: "" # 飞书签名密钥
|
||||
|
||||
scanner:
|
||||
languages:
|
||||
- python
|
||||
- javascript
|
||||
- typescript
|
||||
max_issues: 10 # 最大问题数量
|
||||
detailed: true # 详细扫描模式
|
||||
temp_clone_dir: "/tmp/code_scanner_clones" # 临时目录
|
||||
|
||||
report:
|
||||
output_dir: "./reports" # 报告保存目录
|
||||
keep_files: true # 是否保留报告文件
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 扫描时间很长怎么办?
|
||||
|
||||
A: 系统会浅克隆仓库(只获取最新提交),首次扫描后会有缓存。如果仍需优化,可以:
|
||||
- 减少扫描的文件类型
|
||||
- 调整 `max_issues` 参数
|
||||
|
||||
### Q: 飞书消息发送失败?
|
||||
|
||||
A: 检查:
|
||||
1. Webhook 地址是否正确
|
||||
2. 是否开启了签名校验(如果开启了,需要配置 secret)
|
||||
3. 网络是否可达
|
||||
|
||||
### Q: 扫描不到代码?
|
||||
|
||||
A: 检查:
|
||||
1. 仓库 URL 是否可公开访问
|
||||
2. 私有仓库需要配置 Git 凭证
|
||||
3. 确认分支名称正确
|
||||
|
||||
### Q: 如何访问 Gitea 私有仓库?
|
||||
|
||||
A: 在环境变量中配置 Git 凭证:
|
||||
```bash
|
||||
export GIT_USERNAME=your_username
|
||||
export GIT_PASSWORD=your_password
|
||||
```
|
||||
|
||||
或者在 Git 克隆 URL 中包含凭证:
|
||||
```
|
||||
http://username:password@gitea-server.com/user/repo.git
|
||||
```
|
||||
|
||||
## 系统架构图
|
||||
|
||||
```
|
||||
用户提交代码
|
||||
│
|
||||
▼
|
||||
Gitea Webhook ──────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
Webhook 服务 │
|
||||
(Flask :5000) │
|
||||
│ │
|
||||
├──────────┬──────────┬─────────┘
|
||||
▼ ▼ ▼
|
||||
Python JS/TS Security
|
||||
Scanner Scanner Scanner
|
||||
│ │ │
|
||||
└──────────┴──────────┘
|
||||
│
|
||||
▼
|
||||
Report Generator
|
||||
(Markdown 报告)
|
||||
│
|
||||
▼
|
||||
Feishu Bot
|
||||
(发送通知)
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
code-scanner/
|
||||
├── app.py # 主应用
|
||||
├── config.yaml # 配置文件
|
||||
├── requirements.txt # 依赖
|
||||
├── Dockerfile # Docker 镜像
|
||||
├── docker-compose.yml # Docker Compose
|
||||
├── install.bat # Windows 安装脚本
|
||||
├── install.sh # Linux 安装脚本
|
||||
├── README.md # 项目说明
|
||||
├── 快速开始指南.md # 本文档
|
||||
├── webhook/ # Webhook 处理
|
||||
├── scanner/ # 代码扫描器
|
||||
├── report/ # 报告生成
|
||||
├── notify/ # 飞书通知
|
||||
└── reports/ # 报告输出
|
||||
```
|
||||
Reference in New Issue
Block a user