dev #5

Merged
dangzerong merged 2 commits from dev into main 2026-03-11 21:18:57 +08:00
7 changed files with 1241 additions and 23 deletions
Showing only changes of commit 459a8cb295 - Show all commits

292
app.py
View File

@@ -4,11 +4,12 @@
import os
import logging
from typing import Dict, Tuple, Any
import json
os.environ.setdefault('FLASK_RUN_HOST', '0.0.0.0')
from flask import Flask, request, jsonify
from flask import Flask, request, jsonify, send_from_directory
import yaml
from webhook.handler import GiteaWebhookHandler
from scanner.python_scanner import PythonScanner
@@ -17,6 +18,8 @@ from scanner.security_scanner import SecurityScanner
from scanner.ai_reviewer import AIReviewer
from report.generator import ReportGenerator
from notify.feishu import FeishuNotifier
from gitea_client import GiteaClient
from db import PRScanDB
# 配置日志
logging.basicConfig(
@@ -47,6 +50,7 @@ security_scanner = SecurityScanner(config.get('scanner', {}))
ai_reviewer = AIReviewer(config.get('ai', {}))
report_generator = ReportGenerator(config.get('report', {}))
feishu_notifier = FeishuNotifier(config['feishu'])
gitea_client = GiteaClient(config['gitea'])
@app.route('/')
@@ -171,12 +175,6 @@ def handle_gitea_webhook():
def handle_pull_request(payload: Dict[str, Any]) -> Tuple[Dict, int]:
"""
处理 Pull Request 事件
Args:
payload: Webhook payload
Returns:
JSON 响应和状态码
"""
try:
# 解析 PR 事件
@@ -244,12 +242,25 @@ def handle_pull_request(payload: Dict[str, Any]) -> Tuple[Dict, int]:
author=author,
scan_results=scan_results,
pr_url=pr_url,
target_branch=target_branch
target_branch=target_branch,
pr_number=pr_number
)
# 发送飞书通知
feishu_notifier.send_report(report)
# 保存扫描结果到数据库
pr_info_for_db = {
'repo_name': repo_name,
'pr_number': pr_number,
'pr_title': pr_title,
'pr_url': pr_url,
'source_branch': source_branch,
'target_branch': target_branch,
'author': author
}
PRScanDB.save_pr_scan(pr_info_for_db, scan_results, report.get('file_path'))
logger.info(f'PR #{pr_number} 扫描完成')
except Exception as e:
@@ -306,10 +317,273 @@ def manual_scan():
}), 200
except Exception as e:
logger.error(f'手动扫描失败: {str(e)}', exc_info=True)
logger.error(f'手动扫描失败: {str(e)}')
return jsonify({'error': str(e)}), 500
@app.route('/feishu/card_action', methods=['POST'])
def handle_feishu_card_action():
"""处理飞书卡片按钮点击事件"""
try:
payload = request.json
logger.info(f'收到飞书卡片回调: {payload}')
# 处理 URL 验证请求
challenge = payload.get('challenge')
if challenge:
logger.info('处理 URL 验证请求')
return jsonify({'challenge': challenge}), 200
# 解析回调数据
action_data = payload.get('action', {})
if not action_data:
action_data = payload.get('value', {})
action_type = action_data.get('action')
owner = action_data.get('owner')
repo = action_data.get('repo')
pr_number = action_data.get('pr_number')
pr_url = action_data.get('pr_url')
if not all([action_type, owner, repo, pr_number]):
logger.error('卡片回调数据不完整')
return jsonify({'error': 'Missing required parameters'}), 400
logger.info(f'执行操作: {action_type}, PR: {owner}/{repo}#{pr_number}')
# 执行对应操作
if action_type == 'merge':
success = gitea_client.merge_pull_request(
owner=owner,
repo=repo,
pr_number=int(pr_number),
merge_message=f'通过飞书机器人合并 PR #{pr_number}'
)
result_message = '✅ **已合并 PR**' if success else '❌ **合并失败**'
elif action_type == 'close':
success = gitea_client.close_pull_request(
owner=owner,
repo=repo,
pr_number=int(pr_number)
)
result_message = '✅ **已关闭 PR取消合并**' if success else '❌ **关闭失败**'
else:
result_message = f'⚠️ **未知操作: {action_type}**'
# 发送操作结果到飞书
result_text = f"{result_message}\n\n**PR:** {owner}/{repo}#{pr_number}\n**链接:** [查看PR]({pr_url})"
feishu_notifier.send_simple_message('PR 操作结果', result_text)
return jsonify({'status': 'ok', 'message': result_message}), 200
except Exception as e:
logger.error(f'处理飞书卡片回调失败: {str(e)}', exc_info=True)
return jsonify({'error': str(e)}), 500
@app.route('/feishu/webhook', methods=['POST'])
def handle_feishu_webhook():
"""处理飞书开放平台的验证回调"""
try:
payload = request.json
# 处理验证请求
challenge = payload.get('challenge')
if challenge:
return jsonify({'challenge': challenge}), 200
# 处理消息事件
event_type = payload.get('type')
if event_type == 'url_verification':
return jsonify({'challenge': payload.get('challenge')}), 200
logger.info(f'收到飞书事件: {event_type}')
return jsonify({'status': 'ok'}), 200
except Exception as e:
logger.error(f'处理飞书 Webhook 失败: {str(e)}')
return jsonify({'error': str(e)}), 500
# ============================================
# 扫描管理平台 API
# ============================================
@app.route('/api/prs')
def api_get_prs():
"""获取所有 PR 列表"""
try:
state = request.args.get('state')
prs = PRScanDB.get_all_prs(state=state)
# 转换 scan_result 字符串为对象
for pr in prs:
if pr.get('scan_result') and isinstance(pr['scan_result'], str):
try:
pr['scan_result'] = json.loads(pr['scan_result'])
except:
pass
if pr.get('ai_review') and isinstance(pr['ai_review'], str):
try:
pr['ai_review'] = json.loads(pr['ai_review'])
except:
pass
return jsonify(prs)
except Exception as e:
logger.error(f'获取 PR 列表失败: {str(e)}')
return jsonify({'error': str(e)}), 500
@app.route('/api/prs/<int:pr_id>')
def api_get_pr(pr_id):
"""获取单个 PR 详情"""
try:
pr = PRScanDB.get_pr_by_id(pr_id)
if not pr:
return jsonify({'error': 'PR not found'}), 404
# 转换 JSON 字段
if pr.get('scan_result') and isinstance(pr['scan_result'], str):
try:
pr['scan_result'] = json.loads(pr['scan_result'])
except:
pass
if pr.get('ai_review') and isinstance(pr['ai_review'], str):
try:
pr['ai_review'] = json.loads(pr['ai_review'])
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>/merge', methods=['POST'])
def api_merge_pr(pr_id):
"""合并 PR"""
try:
pr = PRScanDB.get_pr_by_id(pr_id)
if not pr:
return jsonify({'success': False, 'message': 'PR not found'}), 404
logger.info(f"合并 PR - 数据库记录: {pr}")
if pr['state'] != 'open':
return jsonify({'success': False, 'message': 'PR 状态不是 open'}), 400
# 解析仓库名
repo_name = pr['repo_name']
logger.info(f"仓库名称: {repo_name}")
if '/' in repo_name:
owner, repo = repo_name.split('/')
else:
owner = ''
repo = repo_name
logger.info(f"owner: {owner}, repo: {repo}, pr_number: {pr['pr_number']}")
# 先检查 PR 状态
pr_info = gitea_client.get_pull_request(owner, repo, pr['pr_number'])
if not pr_info:
return jsonify({'success': False, 'message': '无法获取 PR 信息,请检查仓库名称是否正确'}), 400
logger.info(f"PR 信息: state={pr_info.get('state')}, mergeable={pr_info.get('mergeable')}")
if pr_info.get('state') != 'open':
return jsonify({'success': False, 'message': f'PR 状态是 {pr_info.get("state")}, 不是 open'}), 400
# 调用 Gitea API 合并
success = gitea_client.merge_pull_request(
owner=owner,
repo=repo,
pr_number=pr['pr_number'],
merge_message=f'通过管理平台合并 PR #{pr["pr_number"]}'
)
if success:
# 更新数据库状态
PRScanDB.update_pr_state(pr_id, 'merged', merged_by='admin')
# 发送飞书通知
result_text = f"✅ **PR 已通过管理平台合并**\n\n**PR:** {repo_name}#{pr['pr_number']}\n**标题:** {pr['pr_title']}\n**合并人:** 管理员"
feishu_notifier.send_simple_message('PR 合并', result_text)
return jsonify({'success': True, 'message': 'PR 已合并'})
else:
return jsonify({'success': False, 'message': '合并失败'}), 500
except Exception as e:
logger.error(f'合并 PR 失败: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/prs/<int:pr_id>/close', methods=['POST'])
def api_close_pr(pr_id):
"""关闭 PR"""
try:
pr = PRScanDB.get_pr_by_id(pr_id)
if not pr:
return jsonify({'success': False, 'message': 'PR not found'}), 404
if pr['state'] != 'open':
return jsonify({'success': False, 'message': 'PR 状态不是 open'}), 400
# 解析仓库名
repo_name = pr['repo_name']
if '/' in repo_name:
owner, repo = repo_name.split('/')
else:
owner = ''
repo = repo_name
# 调用 Gitea API 关闭
success = gitea_client.close_pull_request(
owner=owner,
repo=repo,
pr_number=pr['pr_number']
)
if success:
# 更新数据库状态
PRScanDB.update_pr_state(pr_id, 'closed')
# 发送飞书通知
result_text = f"❌ **PR 已被管理平台拒绝**\n\n**PR:** {repo_name}#{pr['pr_number']}\n**标题:** {pr['pr_title']}"
feishu_notifier.send_simple_message('PR 拒绝', result_text)
return jsonify({'success': True, 'message': 'PR 已关闭'})
else:
return jsonify({'success': False, 'message': '关闭失败'}), 500
except Exception as e:
logger.error(f'关闭 PR 失败: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
# ============================================
# 扫描管理平台页面
# ============================================
# 获取 web 目录的绝对路径
WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web')
@app.route('/dashboard')
def dashboard():
"""扫描管理平台首页"""
return send_from_directory(WEB_DIR, 'index.html')
@app.route('/web/<path:filename>')
def serve_static(filename):
"""提供静态文件服务"""
return send_from_directory(WEB_DIR, filename)
if __name__ == '__main__':
# 强制监听所有网络接口
host = "0.0.0.0"

View File

@@ -5,9 +5,11 @@ server:
gitea:
# Gitea 服务器地址(根据实际情况修改)
base_url: "http://154.9.253.114:3000"
base_url: "https://code.deep-pilot.chat"
# Gitea Webhook 签名密钥,需要与 Gitea 配置一致
webhook_secret: "BoschScan_2026_xxx"
# Gitea API Token用于合并/关闭PR
api_token: "8e223093b069a2e25f485360bd820e4dc255defc"
feishu:
# 飞书机器人 Webhook 地址(替换为你的实际地址)

272
db.py Normal file
View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据库模型
存储 PR 扫描结果和管理状态
"""
import sqlite3
import json
import os
from datetime import datetime
from typing import List, Dict, Any, Optional
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'pr_scans.db')
def get_db_connection():
"""获取数据库连接"""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""初始化数据库表"""
conn = get_db_connection()
cursor = conn.cursor()
# PR 扫描结果表
cursor.execute('''
CREATE TABLE IF NOT EXISTS pr_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pr_number INTEGER NOT NULL,
repo_name TEXT NOT NULL,
pr_title TEXT,
pr_url TEXT,
source_branch TEXT,
target_branch TEXT,
author TEXT,
state TEXT DEFAULT 'pending',
scan_status TEXT DEFAULT 'pending',
scan_result TEXT,
issues_count INTEGER DEFAULT 0,
security_issues INTEGER DEFAULT 0,
ai_review TEXT,
report_path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
merged_at TIMESTAMP,
merged_by TEXT,
UNIQUE(repo_name, pr_number)
)
''')
# 扫描记录详情表
cursor.execute('''
CREATE TABLE IF NOT EXISTS scan_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pr_scan_id INTEGER NOT NULL,
scan_type TEXT NOT NULL,
scan_data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pr_scan_id) REFERENCES pr_scans(id)
)
''')
conn.commit()
conn.close()
class PRScanDB:
"""PR 扫描结果数据库操作类"""
@staticmethod
def save_pr_scan(pr_info: Dict[str, Any], scan_results: Dict[str, Any],
report_path: str = None) -> int:
"""
保存 PR 扫描结果
Args:
pr_info: PR 信息
scan_results: 扫描结果
report_path: 报告文件路径
Returns:
扫描记录 ID
"""
conn = get_db_connection()
cursor = conn.cursor()
# 统计问题数量
issues_count = 0
security_issues = 0
for scan_type, result in scan_results.items():
if isinstance(result, dict):
if 'issues' in result:
issues_count += len(result.get('issues', []))
if 'vulnerabilities' in result:
security_issues += len(result.get('vulnerabilities', []))
# 检查是否已存在
cursor.execute(
'SELECT id FROM pr_scans WHERE repo_name = ? AND pr_number = ?',
(pr_info.get('repo_name'), pr_info.get('pr_number'))
)
existing = cursor.fetchone()
if existing:
# 更新现有记录
cursor.execute('''
UPDATE pr_scans SET
pr_title = ?,
source_branch = ?,
target_branch = ?,
author = ?,
scan_status = ?,
scan_result = ?,
issues_count = ?,
security_issues = ?,
ai_review = ?,
report_path = ?,
updated_at = CURRENT_TIMESTAMP
WHERE repo_name = ? AND pr_number = ?
''', (
pr_info.get('pr_title'),
pr_info.get('source_branch'),
pr_info.get('target_branch'),
pr_info.get('author'),
'completed',
json.dumps(scan_results, ensure_ascii=False),
issues_count,
security_issues,
json.dumps(scan_results.get('ai', {}), ensure_ascii=False),
report_path,
pr_info.get('repo_name'),
pr_info.get('pr_number')
))
scan_id = existing['id']
else:
# 插入新记录
cursor.execute('''
INSERT INTO pr_scans (
pr_number, repo_name, pr_title, pr_url,
source_branch, target_branch, author,
state, scan_status, scan_result,
issues_count, security_issues, ai_review, report_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
pr_info.get('pr_number'),
pr_info.get('repo_name'),
pr_info.get('pr_title'),
pr_info.get('pr_url'),
pr_info.get('source_branch'),
pr_info.get('target_branch'),
pr_info.get('author'),
'open',
'completed',
json.dumps(scan_results, ensure_ascii=False),
issues_count,
security_issues,
json.dumps(scan_results.get('ai', {}), ensure_ascii=False),
report_path
))
scan_id = cursor.lastrowid
conn.commit()
conn.close()
return scan_id
@staticmethod
def get_all_prs(status: str = None, state: str = None) -> List[Dict[str, Any]]:
"""
获取所有 PR 扫描记录
Args:
status: 扫描状态 (pending/completed)
state: PR 状态 (open/merged/closed)
Returns:
PR 列表
"""
conn = get_db_connection()
cursor = conn.cursor()
query = 'SELECT * FROM pr_scans WHERE 1=1'
params = []
if status:
query += ' AND scan_status = ?'
params.append(status)
if state:
query += ' AND state = ?'
params.append(state)
query += ' ORDER BY updated_at DESC'
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
@staticmethod
def get_pr_by_id(scan_id: int) -> Optional[Dict[str, Any]]:
"""根据 ID 获取 PR 扫描记录"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM pr_scans WHERE id = ?', (scan_id,))
row = cursor.fetchone()
conn.close()
return dict(row) if row else None
@staticmethod
def get_pr_by_number(repo_name: str, pr_number: int) -> Optional[Dict[str, Any]]:
"""根据仓库名和 PR 号获取扫描记录"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
'SELECT * FROM pr_scans WHERE repo_name = ? AND pr_number = ?',
(repo_name, pr_number)
)
row = cursor.fetchone()
conn.close()
return dict(row) if row else None
@staticmethod
def update_pr_state(scan_id: int, state: str, merged_by: str = None):
"""更新 PR 状态"""
conn = get_db_connection()
cursor = conn.cursor()
if state == 'merged':
cursor.execute('''
UPDATE pr_scans SET
state = ?,
merged_at = CURRENT_TIMESTAMP,
merged_by = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (state, merged_by, scan_id))
else:
cursor.execute('''
UPDATE pr_scans SET
state = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (state, scan_id))
conn.commit()
conn.close()
@staticmethod
def delete_pr(scan_id: int):
"""删除 PR 扫描记录"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('DELETE FROM scan_details WHERE pr_scan_id = ?', (scan_id,))
cursor.execute('DELETE FROM pr_scans WHERE id = ?', (scan_id,))
conn.commit()
conn.close()
# 初始化数据库
init_db()

169
gitea_client.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Gitea API 客户端
用于操作 PR合并、关闭等
"""
import logging
import requests
from typing import Dict, Any, Optional
logger = logging.getLogger(__name__)
class GiteaClient:
"""Gitea API 客户端"""
def __init__(self, config: Dict[str, Any]):
"""
初始化 Gitea 客户端
Args:
config: Gitea 配置,包含 base_url 和 api_token
"""
self.base_url = config.get('base_url', '').rstrip('/')
self.api_token = config.get('api_token', '')
if not self.base_url:
raise ValueError("Gitea base_url 未配置")
if not self.api_token:
raise ValueError("Gitea api_token 未配置")
def _get_headers(self) -> Dict[str, str]:
"""获取 API 请求头"""
return {
'Authorization': f'token {self.api_token}',
'Content-Type': 'application/json',
'Accept': 'application/json'
}
def merge_pull_request(self, owner: str, repo: str, pr_number: int,
merge_message: str = "",
merge_commit_id: str = None) -> bool:
"""
合并 Pull Request
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}/merge"
logger.info(f"合并 PR URL: {url}")
# Gitea API 需要 do 参数merge, rebase, squash
payload = {
"do": "merge",
"merge_commit_message": merge_message or f"Merge PR #{pr_number}"
}
if merge_commit_id:
payload["merge_commit_id"] = merge_commit_id
try:
response = requests.post(
url,
headers=self._get_headers(),
json=payload,
timeout=30
)
logger.info(f"合并响应状态码: {response.status_code}")
logger.info(f"合并响应内容: {response.text[:500]}")
if response.status_code == 200:
logger.info(f"成功合并 PR #{pr_number}")
return True
elif response.status_code == 405:
logger.error(f"PR #{pr_number} 无法合并: {response.json().get('message', '未知原因')}")
return False
elif response.status_code == 422:
logger.error(f"PR #{pr_number} 合并失败: {response.json().get('message', '参数错误')}")
return False
else:
logger.error(f"合并 PR #{pr_number} 失败: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"合并 PR #{pr_number} 异常: {str(e)}")
return False
def close_pull_request(self, owner: str, repo: str, pr_number: int) -> bool:
"""
关闭 Pull Request
Args:
owner: 仓库所有者
repo: 仓库名称
pr_number: PR 编号
Returns:
是否关闭成功
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}"
payload = {
"state": "closed"
}
try:
response = requests.patch(
url,
headers=self._get_headers(),
json=payload,
timeout=30
)
if response.status_code == 200:
logger.info(f"成功关闭 PR #{pr_number}")
return True
else:
logger.error(f"关闭 PR #{pr_number} 失败: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"关闭 PR #{pr_number} 异常: {str(e)}")
return False
def get_pull_request(self, owner: str, repo: str, pr_number: int) -> Optional[Dict[str, Any]]:
"""
获取 Pull Request 信息
Args:
owner: 仓库所有者
repo: 仓库名称
pr_number: PR 编号
Returns:
PR 信息字典,失败返回 None
"""
url = f"{self.base_url}/api/v1/repos/{owner}/{repo}/pulls/{pr_number}"
try:
response = requests.get(
url,
headers=self._get_headers(),
timeout=30
)
if response.status_code == 200:
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 can_merge(self, owner: str, repo: str, pr_number: int) -> bool:
"""
检查 PR 是否可以合并
Args:
owner: 仓库所有者
repo: 仓库名称
pr_number: PR 编号
Returns:
是否可以合并
"""
pr_info = self.get_pull_request(owner, repo, pr_number)
if pr_info:
return pr_info.get('mergeable', False) and pr_info.get('state') == 'open'
return False

View File

@@ -84,7 +84,7 @@ class FeishuNotifier:
def _upload_file(self, file_path: str, file_name: str) -> Optional[str]:
"""
上传文件到飞书(用于消息中发送)
上传文件到飞书
Args:
file_path: 文件本地路径
@@ -99,8 +99,7 @@ class FeishuNotifier:
return None
try:
# 使用 im API 上传文件
url = "https://open.feishu.cn/open-apis/im/v1/files"
url = "https://open.feishu.cn/open-apis/drive/v1/files/upload_all"
headers = {
"Authorization": f"Bearer {token}"
}
@@ -108,29 +107,25 @@ class FeishuNotifier:
# 读取文件
with open(file_path, 'rb') as f:
file_content = f.read()
# 获取文件类型
file_ext = os.path.splitext(file_name)[1].lower()
file_type = 'stream' # 默认
# 构建 multipart 请求
files = {
'file': (file_name, file_content, 'application/octet-stream')
}
data = {
'file_name': file_name,
'file_type': file_type
'parent_node': 'root' # 根目录
}
response = requests.post(url, headers=headers, files=files, data=data, timeout=60)
result = response.json()
if result.get("code") == 0:
file_key = result.get("data", {}).get("file_key")
logger.info(f"文件上传成功: {file_name}, file_key: {file_key}")
file_key = result.get("data", {}).get("file", {}).get("token")
logger.info(f"文件上传成功: {file_name}")
return file_key
else:
logger.error(f"文件上传失败: {result.get('msg')}, code: {result.get('code')}")
logger.error(f"文件上传失败: {result.get('msg')}")
return None
except Exception as e:
@@ -489,6 +484,61 @@ class FeishuNotifier:
}
})
# 添加 PR 操作按钮(仅 PR 扫描且扫描通过时显示)
if pr_url and target_branch and status == 'pass':
card["elements"].append({
"tag": "div",
"text": {
"tag": "lark_md",
"content": "**请选择操作:**"
}
})
# 解析仓库信息用于按钮回调
repo_full_name = report.get('repo_name', '')
if '/' in repo_full_name:
owner, repo = repo_full_name.split('/', 1)
else:
owner, repo = '', repo_full_name
pr_number = report.get('pr_number', 0)
card["elements"].append({
"tag": "action",
"actions": [
{
"tag": "button",
"text": {
"tag": "plain_text",
"content": "✅ 同意合并"
},
"type": "primary",
"value": {
"action": "merge",
"owner": owner,
"repo": repo,
"pr_number": pr_number,
"pr_url": pr_url
}
},
{
"tag": "button",
"text": {
"tag": "plain_text",
"content": "❌ 取消合并"
},
"type": "danger",
"value": {
"action": "close",
"owner": owner,
"repo": repo,
"pr_number": pr_number,
"pr_url": pr_url
}
}
]
})
# 添加报告文件附件
if file_key:
card["elements"].append({

View File

@@ -39,7 +39,8 @@ class ReportGenerator:
author: str,
scan_results: Dict[str, Any],
pr_url: str = None,
target_branch: str = None
target_branch: str = None,
pr_number: int = None
) -> Dict[str, Any]:
"""
生成扫描报告
@@ -101,6 +102,7 @@ class ReportGenerator:
'scan_results': scan_results,
'pr_url': pr_url,
'target_branch': target_branch,
'pr_number': pr_number,
'markdown': self._generate_markdown(
repo_name, branch, commit_id, commit_message, author, scan_results, status, status_text, pr_url, target_branch
)

449
web/index.html Normal file
View File

@@ -0,0 +1,449 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PR 扫描管理平台</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background-color: #f5f7fa; }
.sidebar {
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
color: white;
}
.sidebar .nav-link { color: rgba(255,255,255,0.7); }
.sidebar .nav-link:hover, .sidebar .nav-link.active {
color: white;
background: rgba(255,255,255,0.1);
}
.stat-card {
border: none;
border-radius: 12px;
transition: transform 0.2s;
}
.stat-card:hover { transform: translateY(-5px); }
.pr-card {
border: none;
border-radius: 10px;
transition: box-shadow 0.2s;
}
.pr-card:hover { box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.status-badge {
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
}
.status-open { background: #e3f2fd; color: #1976d2; }
.status-merged { background: #e8f5e9; color: #388e3c; }
.status-closed { background: #ffebee; color: #d32f2f; }
.status-pending { background: #fff3e0; color: #f57c00; }
.status-completed { background: #e8f5e9; color: #388e3c; }
.issue-high { color: #d32f2f; }
.issue-medium { color: #f57c00; }
.issue-low { color: #388e3c; }
</style>
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- 侧边栏 -->
<div class="col-md-2 sidebar p-3">
<h4 class="mb-4"><i class="bi bi-shield-check"></i> 扫描管理</h4>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link active" href="#" onclick="showPage('dashboard')">
<i class="bi bi-speedometer2 me-2"></i>概览
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="showPage('prs')">
<i class="bi bi-git me-2"></i>PR 列表
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="showPage('settings')">
<i class="bi bi-gear me-2"></i>设置
</a>
</li>
</ul>
<div class="mt-5 p-3" style="background: rgba(255,255,255,0.1); border-radius: 8px;">
<small>系统状态</small>
<div class="mt-2">
<span class="text-success"><i class="bi bi-check-circle-fill"></i> 服务正常</span>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="col-md-10 p-4">
<!-- 概览页面 -->
<div id="page-dashboard">
<h2 class="mb-4">概览</h2>
<div class="row mb-4">
<div class="col-md-3">
<div class="card stat-card bg-primary text-white">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-inbox"></i> 待处理</h6>
<h2 class="mb-0" id="stat-pending">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card bg-success text-white">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-check-circle"></i> 已通过</h6>
<h2 class="mb-0" id="stat-merged">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card bg-danger text-white">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-x-circle"></i> 已拒绝</h6>
<h2 class="mb-0" id="stat-closed">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card bg-warning text-white">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-exclamation-triangle"></i> 问题数</h6>
<h2 class="mb-0" id="stat-issues">-</h2>
</div>
</div>
</div>
</div>
<h5 class="mb-3">最近 PR</h5>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="recent-prs-table">
<thead>
<tr>
<th>PR</th>
<th>标题</th>
<th>作者</th>
<th>分支</th>
<th>状态</th>
<th>问题</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- PR 列表页面 -->
<div id="page-prs" style="display:none;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>PR 列表</h2>
<div>
<select class="form-select form-select-sm d-inline-block w-auto" id="filter-state" onchange="loadPRs()">
<option value="">全部状态</option>
<option value="open">待处理</option>
<option value="merged">已合并</option>
<option value="closed">已关闭</option>
</select>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="prs-table">
<thead>
<tr>
<th>PR</th>
<th>标题</th>
<th>仓库</th>
<th>作者</th>
<th>分支</th>
<th>扫描状态</th>
<th>状态</th>
<th>问题</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 设置页面 -->
<div id="page-settings" style="display:none;">
<h2 class="mb-4">设置</h2>
<div class="card">
<div class="card-body">
<h5>API 端点</h5>
<div class="mb-3">
<label class="form-label">Webhook 地址</label>
<input type="text" class="form-control" value="/webhook/gitea" readonly>
<small class="text-muted">在 Gitea 中配置为此地址: http://your-server:5000/webhook/gitea</small>
</div>
<div class="mb-3">
<label class="form-label">飞书回调地址</label>
<input type="text" class="form-control" value="/feishu/card_action" readonly>
</div>
<hr>
<h5>操作说明</h5>
<ul>
<li>点击「查看」查看 PR 扫描详情</li>
<li>点击「同意合并」合并 PR</li>
<li>点击「拒绝」关闭 PR</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- PR 详情模态框 -->
<div class="modal fade" id="prDetailModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="detail-pr-title">PR 详情</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<p><strong>仓库:</strong> <span id="detail-repo"></span></p>
<p><strong>作者:</strong> <span id="detail-author"></span></p>
<p><strong>分支:</strong> <span id="detail-branch"></span></p>
</div>
<div class="col-md-6">
<p><strong>扫描状态:</strong> <span id="detail-scan-status"></span></p>
<p><strong>问题数:</strong> <span id="detail-issues"></span></p>
<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>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button type="button" class="btn btn-danger" id="btn-reject" onclick="rejectPR()">
<i class="bi bi-x-circle"></i> 拒绝
</button>
<button type="button" class="btn btn-success" id="btn-merge" onclick="mergePR()">
<i class="bi bi-check-circle"></i> 同意合并
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let currentPRId = null;
let currentPRInfo = null;
// 页面切换
function showPage(page) {
document.querySelectorAll('[id^="page-"]').forEach(el => el.style.display = 'none');
document.getElementById('page-' + page).style.display = 'block';
document.querySelectorAll('.sidebar .nav-link').forEach(el => el.classList.remove('active'));
event.target.closest('.nav-link')?.classList.add('active');
if (page === 'dashboard') loadDashboard();
if (page === 'prs') loadPRs();
}
// 加载概览数据
async function loadDashboard() {
try {
const response = await fetch('/api/prs');
const prs = await response.json();
// 统计数据
const pending = prs.filter(p => p.state === 'open').length;
const merged = prs.filter(p => p.state === 'merged').length;
const closed = prs.filter(p => p.state === 'closed').length;
const totalIssues = prs.reduce((sum, p) => sum + (p.issues_count || 0), 0);
document.getElementById('stat-pending').textContent = pending;
document.getElementById('stat-merged').textContent = merged;
document.getElementById('stat-closed').textContent = closed;
document.getElementById('stat-issues').textContent = totalIssues;
// 最近 PR
const recentPRs = prs.slice(0, 5);
const tbody = document.querySelector('#recent-prs-table tbody');
tbody.innerHTML = recentPRs.map(pr => createPRRow(pr)).join('');
} catch (e) {
console.error('加载数据失败:', e);
}
}
// 加载 PR 列表
async function loadPRs() {
try {
const state = document.getElementById('filter-state').value;
const url = state ? '/api/prs?state=' + state : '/api/prs';
const response = await fetch(url);
const prs = await response.json();
const tbody = document.querySelector('#prs-table tbody');
tbody.innerHTML = prs.map(pr => createPRRow(pr)).join('');
} catch (e) {
console.error('加载数据失败:', e);
}
}
// 创建 PR 行
function createPRRow(pr) {
const stateClass = 'status-' + pr.state;
const scanClass = 'status-' + pr.scan_status;
const issuesClass = (pr.issues_count || 0) > 0 ? 'issue-high' : 'issue-low';
const securityClass = (pr.security_issues || 0) > 0 ? 'issue-high' : 'issue-low';
return `
<tr>
<td><a href="${pr.pr_url || '#'}" target="_blank">#${pr.pr_number}</a></td>
<td>${pr.pr_title || '-'}</td>
<td><small>${pr.repo_name}</small></td>
<td>${pr.author || '-'}</td>
<td><small>${pr.source_branch}${pr.target_branch}</small></td>
<td><span class="status-badge ${scanClass}">${pr.scan_status === 'completed' ? '已完成' : '待扫描'}</span></td>
<td><span class="status-badge ${stateClass}">${pr.state === 'open' ? '待处理' : pr.state === 'merged' ? '已合并' : '已关闭'}</span></td>
<td><span class="${issuesClass}">${pr.issues_count || 0}</span> / <span class="${securityClass}">${pr.security_issues || 0}</span></td>
<td><small>${formatDate(pr.updated_at)}</small></td>
<td>
<button class="btn btn-sm btn-primary" onclick="viewPR(${pr.id})">查看</button>
</td>
</tr>
`;
}
// 查看 PR 详情
async function viewPR(id) {
try {
const response = await fetch('/api/prs/' + id);
const pr = await response.json();
currentPRId = id;
currentPRInfo = pr;
document.getElementById('detail-pr-title').textContent = 'PR #' + pr.pr_number + ': ' + (pr.pr_title || '');
document.getElementById('detail-repo').textContent = pr.repo_name;
document.getElementById('detail-author').textContent = pr.author || '-';
document.getElementById('detail-branch').textContent = pr.source_branch + ' → ' + pr.target_branch;
document.getElementById('detail-scan-status').innerHTML = pr.scan_status === 'completed' ? '<span class="text-success">已完成</span>' : '<span class="text-warning">待扫描</span>';
document.getElementById('detail-issues').textContent = pr.issues_count || 0;
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();
} catch (e) {
alert('加载 PR 详情失败: ' + e.message);
}
}
// 合并 PR
async function mergePR() {
if (!currentPRInfo) return;
if (!confirm('确定要合并 PR #' + currentPRInfo.pr_number + ' 吗?')) return;
try {
const response = await fetch('/api/prs/' + currentPRId + '/merge', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
alert('PR 已成功合并!');
bootstrap.Modal.getInstance(document.getElementById('prDetailModal')).hide();
loadPRs();
loadDashboard();
} else {
alert('合并失败: ' + result.message);
}
} catch (e) {
alert('操作失败: ' + e.message);
}
}
// 拒绝 PR
async function rejectPR() {
if (!currentPRInfo) return;
if (!confirm('确定要拒绝并关闭 PR #' + currentPRInfo.pr_number + ' 吗?')) return;
try {
const response = await fetch('/api/prs/' + currentPRId + '/close', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
alert('PR 已关闭!');
bootstrap.Modal.getInstance(document.getElementById('prDetailModal')).hide();
loadPRs();
loadDashboard();
} else {
alert('操作失败: ' + result.message);
}
} catch (e) {
alert('操作失败: ' + e.message);
}
}
// 格式化日期
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
}
// 初始化
loadDashboard();
</script>
</body>
</html>