Files
code_scan/app.py

595 lines
20 KiB
Python
Raw Normal View History

2026-03-07 19:56:28 +08:00
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import logging
2026-03-10 11:18:39 +08:00
from typing import Dict, Tuple, Any
2026-03-11 12:30:45 +08:00
import json
2026-03-07 19:56:28 +08:00
os.environ.setdefault('FLASK_RUN_HOST', '0.0.0.0')
2026-03-11 12:30:45 +08:00
from flask import Flask, request, jsonify, send_from_directory
2026-03-07 19:56:28 +08:00
import yaml
from webhook.handler import GiteaWebhookHandler
from scanner.python_scanner import PythonScanner
from scanner.js_scanner import JavaScriptScanner
from scanner.security_scanner import SecurityScanner
2026-03-10 17:22:07 +08:00
from scanner.ai_reviewer import AIReviewer
2026-03-07 19:56:28 +08:00
from report.generator import ReportGenerator
from notify.feishu import FeishuNotifier
2026-03-11 12:30:45 +08:00
from gitea_client import GiteaClient
from db import PRScanDB
2026-03-07 19:56:28 +08:00
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 加载配置
def load_config():
"""加载配置文件"""
config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')
with open(config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
# 全局配置
config = load_config()
# 初始化应用
app = Flask(__name__)
app.config['SECRET_KEY'] = config.get('server', {}).get('secret_key', 'dev-secret-key')
# 初始化组件
webhook_handler = GiteaWebhookHandler(config['gitea'])
python_scanner = PythonScanner(config.get('scanner', {}))
js_scanner = JavaScriptScanner(config.get('scanner', {}))
security_scanner = SecurityScanner(config.get('scanner', {}))
2026-03-10 17:22:07 +08:00
ai_reviewer = AIReviewer(config.get('ai', {}))
2026-03-07 19:56:28 +08:00
report_generator = ReportGenerator(config.get('report', {}))
feishu_notifier = FeishuNotifier(config['feishu'])
2026-03-11 12:30:45 +08:00
gitea_client = GiteaClient(config['gitea'])
2026-03-07 19:56:28 +08:00
@app.route('/')
def index():
"""健康检查接口"""
return jsonify({
'status': 'ok',
'service': 'AI Code Quality Scanner',
'version': '1.0.0'
})
@app.route('/webhook/gitea', methods=['POST'])
def handle_gitea_webhook():
"""处理 Gitea Webhook 请求"""
try:
# 验证签名
signature = request.headers.get('X-Gitea-Signature')
if signature:
if not webhook_handler.verify_signature(
request.data,
signature,
config['gitea']['webhook_secret']
):
logger.warning('Webhook 签名验证失败')
return jsonify({'error': 'Invalid signature'}), 401
# 解析 Webhook payload
payload = request.json
if not payload:
return jsonify({'error': 'No payload'}), 400
event_type = request.headers.get('X-Gitea-Event', 'push')
logger.info(f'收到 Gitea Webhook 事件: {event_type}')
2026-03-10 11:18:39 +08:00
# 处理 Pull Request 事件
if event_type == 'pull_request':
return handle_pull_request(payload)
# 处理 Push 事件
2026-03-07 19:56:28 +08:00
if event_type != 'push':
return jsonify({'message': 'Event ignored'}), 200
# 提取提交信息
commits = payload.get('commits', [])
if not commits:
return jsonify({'message': 'No commits'}), 200
repo = payload.get('repository', {})
repo_name = repo.get('full_name', 'unknown')
branch = payload.get('ref', '').replace('refs/heads/', '')
pusher = payload.get('pusher', {}).get('name', 'unknown')
logger.info(f'处理仓库 {repo_name}{len(commits)} 个提交')
# 处理每个提交
for commit in commits:
commit_id = commit.get('id', '')[:8]
commit_message = commit.get('message', '')
author = commit.get('author', {}).get('name', 'unknown')
logger.info(f'扫描提交 {commit_id}: {commit_message}')
try:
# 获取仓库 URL
clone_url = repo.get('clone_url')
if not clone_url:
# 尝试从 web_url 构建
web_url = repo.get('web_url', '')
if web_url:
clone_url = web_url.replace('http://', 'http://').replace('https://', 'https://')
clone_url = clone_url.rstrip('/') + '.git'
# 执行代码扫描
scan_results = {}
# Python 扫描
if 'python' in config.get('scanner', {}).get('languages', []):
scan_results['python'] = python_scanner.scan(
clone_url, commit_id, branch
)
# JavaScript/TypeScript 扫描
if any(lang in config.get('scanner', {}).get('languages', [])
for lang in ['javascript', 'typescript']):
scan_results['javascript'] = js_scanner.scan(
clone_url, commit_id, branch
)
# 安全扫描
scan_results['security'] = security_scanner.scan(
clone_url, commit_id, branch
)
# 生成报告
report = report_generator.generate(
repo_name=repo_name,
branch=branch,
commit_id=commit_id,
commit_message=commit_message,
author=author,
scan_results=scan_results
)
# 发送飞书通知
feishu_notifier.send_report(report)
logger.info(f'提交 {commit_id} 扫描完成')
except Exception as e:
logger.error(f'扫描提交 {commit_id} 失败: {str(e)}')
# 继续处理其他提交
continue
return jsonify({'status': 'ok', 'message': 'Scan completed'}), 200
except Exception as e:
logger.error(f'处理 Webhook 失败: {str(e)}', exc_info=True)
return jsonify({'error': str(e)}), 500
2026-03-10 11:18:39 +08:00
def handle_pull_request(payload: Dict[str, Any]) -> Tuple[Dict, int]:
"""
处理 Pull Request 事件
"""
try:
# 解析 PR 事件
pr_info = webhook_handler.parse_pull_request_event(payload)
if not pr_info:
logger.info('PR 事件不需要处理(如关闭、合并等)')
return jsonify({'message': 'PR event ignored'}), 200
repo_name = pr_info['repo_name']
source_branch = pr_info['source_branch']
source_sha = pr_info['source_sha']
pr_number = pr_info['pr_number']
pr_title = pr_info['pr_title']
pr_url = pr_info['pr_url']
target_branch = pr_info['target_branch']
author = pr_info['author']
logger.info(f'处理 PR #{pr_number}: {pr_title} ({source_branch} -> {target_branch})')
logger.info(f'扫描 PR 分支: {source_branch}, commit: {source_sha}')
try:
# 获取仓库 URL
repo = payload.get('repository', {})
clone_url = repo.get('clone_url')
if not clone_url:
web_url = repo.get('web_url', '')
if web_url:
clone_url = web_url.rstrip('/') + '.git'
# 执行代码扫描
scan_results = {}
# Python 扫描
if 'python' in config.get('scanner', {}).get('languages', []):
scan_results['python'] = python_scanner.scan(
clone_url, source_sha, source_branch
)
# JavaScript/TypeScript 扫描
if any(lang in config.get('scanner', {}).get('languages', [])
for lang in ['javascript', 'typescript']):
scan_results['javascript'] = js_scanner.scan(
clone_url, source_sha, source_branch
)
# 安全扫描
scan_results['security'] = security_scanner.scan(
clone_url, source_sha, source_branch
)
2026-03-10 17:22:07 +08:00
# AI 代码审查
if config.get('ai', {}).get('enabled', False):
scan_results['ai'] = ai_reviewer.scan(
clone_url, source_sha, source_branch
)
2026-03-10 11:18:39 +08:00
# 生成报告
commit_message = f'PR #{pr_number}: {pr_title}'
report = report_generator.generate(
repo_name=repo_name,
branch=source_branch,
commit_id=source_sha,
commit_message=commit_message,
author=author,
scan_results=scan_results,
pr_url=pr_url,
2026-03-11 12:30:45 +08:00
target_branch=target_branch,
pr_number=pr_number
2026-03-10 11:18:39 +08:00
)
# 发送飞书通知
feishu_notifier.send_report(report)
2026-03-11 12:30:45 +08:00
# 保存扫描结果到数据库
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'))
2026-03-10 11:18:39 +08:00
logger.info(f'PR #{pr_number} 扫描完成')
except Exception as e:
logger.error(f'扫描 PR #{pr_number} 失败: {str(e)}')
return jsonify({'error': str(e)}), 500
return jsonify({'status': 'ok', 'message': 'PR scan completed'}), 200
except Exception as e:
logger.error(f'处理 PR Webhook 失败: {str(e)}', exc_info=True)
return jsonify({'error': str(e)}), 500
2026-03-07 19:56:28 +08:00
@app.route('/scan/manual', methods=['POST'])
def manual_scan():
"""手动触发扫描接口"""
try:
data = request.json
repo_url = data.get('repo_url')
branch = data.get('branch', 'main')
commit_id = data.get('commit_id')
if not repo_url:
return jsonify({'error': 'repo_url is required'}), 400
# 执行扫描
scan_results = {}
if 'python' in config.get('scanner', {}).get('languages', []):
scan_results['python'] = python_scanner.scan(repo_url, commit_id, branch)
if any(lang in config.get('scanner', {}).get('languages', [])
for lang in ['javascript', 'typescript']):
scan_results['javascript'] = js_scanner.scan(repo_url, commit_id, branch)
scan_results['security'] = security_scanner.scan(repo_url, commit_id, branch)
# 生成报告
report = report_generator.generate(
repo_name=repo_url.split('/')[-1].replace('.git', ''),
branch=branch,
commit_id=commit_id or 'manual',
commit_message='Manual scan',
author='manual',
scan_results=scan_results
)
# 发送飞书通知
feishu_notifier.send_report(report)
return jsonify({
'status': 'ok',
'report': report
}), 200
except Exception as e:
2026-03-11 12:30:45 +08:00
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)}')
2026-03-07 19:56:28 +08:00
return jsonify({'error': str(e)}), 500
2026-03-11 12:30:45 +08:00
# ============================================
# 扫描管理平台 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)
2026-03-07 19:56:28 +08:00
if __name__ == '__main__':
# 强制监听所有网络接口
host = "0.0.0.0"
port = config.get('server', {}).get('port', 5000)
debug = config.get('server', {}).get('debug', True)
logger.info(f'启动服务: {host}:{port}')
app.run(host=host, port=port, debug=debug)