add web
This commit is contained in:
292
app.py
292
app.py
@@ -4,11 +4,12 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Tuple, Any
|
from typing import Dict, Tuple, Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
os.environ.setdefault('FLASK_RUN_HOST', '0.0.0.0')
|
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
|
import yaml
|
||||||
from webhook.handler import GiteaWebhookHandler
|
from webhook.handler import GiteaWebhookHandler
|
||||||
from scanner.python_scanner import PythonScanner
|
from scanner.python_scanner import PythonScanner
|
||||||
@@ -17,6 +18,8 @@ from scanner.security_scanner import SecurityScanner
|
|||||||
from scanner.ai_reviewer import AIReviewer
|
from scanner.ai_reviewer import AIReviewer
|
||||||
from report.generator import ReportGenerator
|
from report.generator import ReportGenerator
|
||||||
from notify.feishu import FeishuNotifier
|
from notify.feishu import FeishuNotifier
|
||||||
|
from gitea_client import GiteaClient
|
||||||
|
from db import PRScanDB
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -47,6 +50,7 @@ security_scanner = SecurityScanner(config.get('scanner', {}))
|
|||||||
ai_reviewer = AIReviewer(config.get('ai', {}))
|
ai_reviewer = AIReviewer(config.get('ai', {}))
|
||||||
report_generator = ReportGenerator(config.get('report', {}))
|
report_generator = ReportGenerator(config.get('report', {}))
|
||||||
feishu_notifier = FeishuNotifier(config['feishu'])
|
feishu_notifier = FeishuNotifier(config['feishu'])
|
||||||
|
gitea_client = GiteaClient(config['gitea'])
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@@ -171,12 +175,6 @@ def handle_gitea_webhook():
|
|||||||
def handle_pull_request(payload: Dict[str, Any]) -> Tuple[Dict, int]:
|
def handle_pull_request(payload: Dict[str, Any]) -> Tuple[Dict, int]:
|
||||||
"""
|
"""
|
||||||
处理 Pull Request 事件
|
处理 Pull Request 事件
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: Webhook payload
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON 响应和状态码
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 解析 PR 事件
|
# 解析 PR 事件
|
||||||
@@ -244,12 +242,25 @@ def handle_pull_request(payload: Dict[str, Any]) -> Tuple[Dict, int]:
|
|||||||
author=author,
|
author=author,
|
||||||
scan_results=scan_results,
|
scan_results=scan_results,
|
||||||
pr_url=pr_url,
|
pr_url=pr_url,
|
||||||
target_branch=target_branch
|
target_branch=target_branch,
|
||||||
|
pr_number=pr_number
|
||||||
)
|
)
|
||||||
|
|
||||||
# 发送飞书通知
|
# 发送飞书通知
|
||||||
feishu_notifier.send_report(report)
|
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} 扫描完成')
|
logger.info(f'PR #{pr_number} 扫描完成')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -306,10 +317,273 @@ def manual_scan():
|
|||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'手动扫描失败: {str(e)}', exc_info=True)
|
logger.error(f'手动扫描失败: {str(e)}')
|
||||||
return jsonify({'error': str(e)}), 500
|
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__':
|
if __name__ == '__main__':
|
||||||
# 强制监听所有网络接口
|
# 强制监听所有网络接口
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ server:
|
|||||||
|
|
||||||
gitea:
|
gitea:
|
||||||
# Gitea 服务器地址(根据实际情况修改)
|
# Gitea 服务器地址(根据实际情况修改)
|
||||||
base_url: "http://154.9.253.114:3000"
|
base_url: "https://code.deep-pilot.chat"
|
||||||
# Gitea Webhook 签名密钥,需要与 Gitea 配置一致
|
# Gitea Webhook 签名密钥,需要与 Gitea 配置一致
|
||||||
webhook_secret: "BoschScan_2026_xxx"
|
webhook_secret: "BoschScan_2026_xxx"
|
||||||
|
# Gitea API Token(用于合并/关闭PR)
|
||||||
|
api_token: "8e223093b069a2e25f485360bd820e4dc255defc"
|
||||||
|
|
||||||
feishu:
|
feishu:
|
||||||
# 飞书机器人 Webhook 地址(替换为你的实际地址)
|
# 飞书机器人 Webhook 地址(替换为你的实际地址)
|
||||||
|
|||||||
272
db.py
Normal file
272
db.py
Normal 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
169
gitea_client.py
Normal 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
|
||||||
@@ -84,7 +84,7 @@ class FeishuNotifier:
|
|||||||
|
|
||||||
def _upload_file(self, file_path: str, file_name: str) -> Optional[str]:
|
def _upload_file(self, file_path: str, file_name: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
上传文件到飞书(用于消息中发送)
|
上传文件到飞书
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: 文件本地路径
|
file_path: 文件本地路径
|
||||||
@@ -99,8 +99,7 @@ class FeishuNotifier:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 使用 im API 上传文件
|
url = "https://open.feishu.cn/open-apis/drive/v1/files/upload_all"
|
||||||
url = "https://open.feishu.cn/open-apis/im/v1/files"
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}"
|
"Authorization": f"Bearer {token}"
|
||||||
}
|
}
|
||||||
@@ -108,29 +107,25 @@ class FeishuNotifier:
|
|||||||
# 读取文件
|
# 读取文件
|
||||||
with open(file_path, 'rb') as f:
|
with open(file_path, 'rb') as f:
|
||||||
file_content = f.read()
|
file_content = f.read()
|
||||||
|
|
||||||
# 获取文件类型
|
|
||||||
file_ext = os.path.splitext(file_name)[1].lower()
|
|
||||||
file_type = 'stream' # 默认
|
|
||||||
|
|
||||||
# 构建 multipart 请求
|
# 构建 multipart 请求
|
||||||
files = {
|
files = {
|
||||||
'file': (file_name, file_content, 'application/octet-stream')
|
'file': (file_name, file_content, 'application/octet-stream')
|
||||||
}
|
}
|
||||||
data = {
|
data = {
|
||||||
'file_name': file_name,
|
'file_name': file_name,
|
||||||
'file_type': file_type
|
'parent_node': 'root' # 根目录
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, files=files, data=data, timeout=60)
|
response = requests.post(url, headers=headers, files=files, data=data, timeout=60)
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
|
||||||
if result.get("code") == 0:
|
if result.get("code") == 0:
|
||||||
file_key = result.get("data", {}).get("file_key")
|
file_key = result.get("data", {}).get("file", {}).get("token")
|
||||||
logger.info(f"文件上传成功: {file_name}, file_key: {file_key}")
|
logger.info(f"文件上传成功: {file_name}")
|
||||||
return file_key
|
return file_key
|
||||||
else:
|
else:
|
||||||
logger.error(f"文件上传失败: {result.get('msg')}, code: {result.get('code')}")
|
logger.error(f"文件上传失败: {result.get('msg')}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
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:
|
if file_key:
|
||||||
card["elements"].append({
|
card["elements"].append({
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ class ReportGenerator:
|
|||||||
author: str,
|
author: str,
|
||||||
scan_results: Dict[str, Any],
|
scan_results: Dict[str, Any],
|
||||||
pr_url: str = None,
|
pr_url: str = None,
|
||||||
target_branch: str = None
|
target_branch: str = None,
|
||||||
|
pr_number: int = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
生成扫描报告
|
生成扫描报告
|
||||||
@@ -101,6 +102,7 @@ class ReportGenerator:
|
|||||||
'scan_results': scan_results,
|
'scan_results': scan_results,
|
||||||
'pr_url': pr_url,
|
'pr_url': pr_url,
|
||||||
'target_branch': target_branch,
|
'target_branch': target_branch,
|
||||||
|
'pr_number': pr_number,
|
||||||
'markdown': self._generate_markdown(
|
'markdown': self._generate_markdown(
|
||||||
repo_name, branch, commit_id, commit_message, author, scan_results, status, status_text, pr_url, target_branch
|
repo_name, branch, commit_id, commit_message, author, scan_results, status, status_text, pr_url, target_branch
|
||||||
)
|
)
|
||||||
|
|||||||
449
web/index.html
Normal file
449
web/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user