From 459a8cb295a36f04c6191c670596aaca815ffd43 Mon Sep 17 00:00:00 2001 From: Dang Zerong Date: Wed, 11 Mar 2026 12:30:45 +0800 Subject: [PATCH] add web --- app.py | 292 +++++++++++++++++++++++++++- config.yaml | 4 +- db.py | 272 +++++++++++++++++++++++++++ gitea_client.py | 169 +++++++++++++++++ notify/feishu.py | 74 ++++++-- report/generator.py | 4 +- web/index.html | 449 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1241 insertions(+), 23 deletions(-) create mode 100644 db.py create mode 100644 gitea_client.py create mode 100644 web/index.html diff --git a/app.py b/app.py index 10653bc..354af0d 100644 --- a/app.py +++ b/app.py @@ -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/') +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//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//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/') +def serve_static(filename): + """提供静态文件服务""" + return send_from_directory(WEB_DIR, filename) + + if __name__ == '__main__': # 强制监听所有网络接口 host = "0.0.0.0" diff --git a/config.yaml b/config.yaml index 80f1181..7521d1f 100644 --- a/config.yaml +++ b/config.yaml @@ -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 地址(替换为你的实际地址) diff --git a/db.py b/db.py new file mode 100644 index 0000000..772b82c --- /dev/null +++ b/db.py @@ -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() diff --git a/gitea_client.py b/gitea_client.py new file mode 100644 index 0000000..ae1f3a7 --- /dev/null +++ b/gitea_client.py @@ -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 diff --git a/notify/feishu.py b/notify/feishu.py index f974cb5..9287c8b 100644 --- a/notify/feishu.py +++ b/notify/feishu.py @@ -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({ diff --git a/report/generator.py b/report/generator.py index 6b41972..9a0597a 100644 --- a/report/generator.py +++ b/report/generator.py @@ -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 ) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9a8d39a --- /dev/null +++ b/web/index.html @@ -0,0 +1,449 @@ + + + + + + PR 扫描管理平台 + + + + + +
+
+ + + + +
+ +
+

概览

+
+
+
+
+
待处理
+

-

+
+
+
+
+
+
+
已通过
+

-

+
+
+
+
+
+
+
已拒绝
+

-

+
+
+
+
+
+
+
问题数
+

-

+
+
+
+
+ +
最近 PR
+
+
+
+ + + + + + + + + + + + + + +
PR标题作者分支状态问题时间操作
+
+
+
+
+ + + + + + +
+
+
+ + + + + + + +