#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ pr_gate.py - PR 审查门禁辅助工具 用法: python3 pr_gate.py --get-pr --number 123 python3 pr_gate.py --review --number 123 python3 pr_gate.py --check-results --number 123 python3 pr_gate.py --merge-pr --number 123 python3 pr_gate.py --full-pipeline --number 123 """ import os import sys import json import argparse import subprocess import time from datetime import datetime from pathlib import Path from typing import Dict, List, Any, Optional # 配置 GITEA_URL = os.environ.get('GITEA_URL', 'http://192.168.1.22:8418') GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '') GITEA_REPO = os.environ.get('GITEA_REPO', 'starmen1265/kb-skills') REPO_PATH = Path.home() / 'work' / 'kb-skills' PLANNING_DIR = REPO_PATH / '.planning' MAX_FIX_ATTEMPTS = 3 def run_cmd(cmd: str, cwd=None, capture=True) -> subprocess.CompletedProcess: """执行 shell 命令""" return subprocess.run( cmd, shell=True, cwd=cwd or REPO_PATH, capture_output=capture, text=True ) def gitea_api(method: str, endpoint: str, data: dict = None) -> dict: """调用 Gitea API""" import urllib.request import urllib.error url = f"{GITEA_URL}/api/v1{endpoint}" headers = { 'Authorization': f'token {GITEA_TOKEN}', 'Content-Type': 'application/json' } if data: body = json.dumps(data).encode('utf-8') else: body = None req = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as response: if response.status == 204: return {"success": True} return json.loads(response.read().decode('utf-8')) except urllib.error.HTTPError as e: error_body = e.read().decode('utf-8') if e.fp else str(e) return {"success": False, "error": f"HTTP {e.code}: {error_body}"} except Exception as e: return {"success": False, "error": str(e)} def get_pr(number: int) -> dict: """获取 PR 信息""" result = gitea_api('GET', f'/repos/{GITEA_REPO}/pulls/{number}') if 'error' in result: return {"success": False, "error": result['error']} # 获取变更文件 files_result = gitea_api('GET', f'/repos/{GITEA_REPO}/pulls/{number}/files') # 获取 CI 状态 commits_result = gitea_api('GET', f'/repos/{GITEA_REPO}/pulls/{number}/commits') return { "success": True, "pr": { "number": result.get("number"), "title": result.get("title"), "body": result.get("body"), "state": result.get("state"), "head": result.get("head", {}).get("ref"), "base": result.get("base", {}).get("ref"), "mergeable": result.get("mergeable"), "user": result.get("user", {}).get("login"), "created_at": result.get("created_at"), "url": result.get("html_url") }, "files": files_result if isinstance(files_result, list) else [], "ci_status": _extract_ci_status(commits_result) } def _extract_ci_status(commits: list) -> dict: """从 commits 提取 CI 状态""" # 简化实现,实际需要检查 commit status return { "status": "unknown", "checks": [] } def start_review(number: int) -> dict: """启动 3 个审查子代理""" # 获取 PR 信息 pr_info = get_pr(number) if not pr_info.get("success"): return pr_info # 这里应该使用 sessions_spawn 启动子代理 # 由于当前环境限制,返回模拟结果 return { "success": True, "message": f"已启动 3 个审查子代理审查 PR #{number}", "reviewers": ["security", "quality", "logic"], "pr": pr_info["pr"], "note": "请在 OpenClaw 环境中使用 sessions_spawn 启动实际子代理" } def check_results(number: int) -> dict: """检查审查结果""" results_file = PLANNING_DIR / f'review-pr-{number}.json' if not results_file.exists(): return { "success": True, "status": "pending", "message": "审查尚未完成" } results = json.loads(results_file.read_text(encoding='utf-8')) # 统计结果 approve_count = sum(1 for r in results.get("reviews", []) if r.get("result") == "approve") reject_count = sum(1 for r in results.get("reviews", []) if r.get("result") == "reject") all_approved = approve_count == 3 any_rejected = reject_count > 0 return { "success": True, "status": "complete", "all_approved": all_approved, "any_rejected": any_rejected, "results": results } def merge_pr(number: int) -> dict: """合并 PR""" # 检查审查结果 review_status = check_results(number) if not review_status.get("all_approved"): return { "success": False, "error": "审查未全部通过", "review_status": review_status } # 调用 Gitea API 合并 result = gitea_api('POST', f'/repos/{GITEA_REPO}/pulls/{number}/merge', { "Do": "merge", "merge_title": f"Merge PR #{number}", "merge_message": "" }) if result.get("success"): return { "success": True, "message": f"PR #{number} 已合并到 main", "pr_number": number } else: return result def fix_loop(number: int, attempt: int = 1) -> dict: """修复循环""" if attempt > MAX_FIX_ATTEMPTS: return { "success": False, "error": f"已达到最大修复次数 ({MAX_FIX_ATTEMPTS})", "final_failure": True } # 获取需要修复的问题 review_status = check_results(number) issues = [] for review in review_status.get("results", {}).get("reviews", []): if review.get("result") == "reject": issues.extend(review.get("issues", [])) if not issues: return { "success": True, "message": "没有需要修复的问题" } return { "success": True, "attempt": attempt, "max_attempts": MAX_FIX_ATTEMPTS, "issues_to_fix": issues, "message": f"修复尝试 {attempt}/{MAX_FIX_ATTEMPTS}", "note": "请在 OpenClaw 环境中启动修复子代理" } def full_pipeline(number: int) -> dict: """完整审查流程""" steps = [] # Step 1: 获取 PR pr_info = get_pr(number) steps.append({"step": "get_pr", "success": pr_info.get("success", False)}) if not pr_info.get("success"): return {"success": False, "steps": steps, "error": pr_info.get("error")} # Step 2: 启动审查 review_result = start_review(number) steps.append({"step": "start_review", "success": review_result.get("success", False)}) # Step 3: 等待审查完成 (模拟) # 实际应该轮询 check_results steps.append({"step": "wait_review", "success": True, "note": "审查进行中..."}) # Step 4: 检查结果 results = check_results(number) steps.append({"step": "check_results", "success": True, "data": results}) if results.get("all_approved"): # Step 5a: 合并 merge_result = merge_pr(number) steps.append({"step": "merge", "success": merge_result.get("success", False)}) return {"success": True, "steps": steps, "merged": merge_result.get("success", False)} else: # Step 5b: 修复循环 fix_result = fix_loop(number, attempt=1) steps.append({"step": "fix_loop", "success": False, "data": fix_result}) return {"success": False, "steps": steps, "needs_fix": True} def save_review_results(number: int, reviews: List[dict]) -> dict: """保存审查结果""" PLANNING_DIR.mkdir(parents=True, exist_ok=True) results_file = PLANNING_DIR / f'review-pr-{number}.json' data = { "pr_number": number, "timestamp": datetime.now().isoformat(), "reviews": reviews } results_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') return {"success": True, "file": str(results_file)} def main(): parser = argparse.ArgumentParser(description='pr-review-gate 辅助工具') subparsers = parser.add_subparsers(dest='command', help='命令') # get-pr get_parser = subparsers.add_parser('get-pr', help='获取 PR 信息') get_parser.add_argument('--number', type=int, required=True, help='PR 编号') # review review_parser = subparsers.add_parser('review', help='启动审查') review_parser.add_argument('--number', type=int, required=True, help='PR 编号') # check-results check_parser = subparsers.add_parser('check-results', help='检查审查结果') check_parser.add_argument('--number', type=int, required=True, help='PR 编号') # merge-pr merge_parser = subparsers.add_parser('merge-pr', help='合并 PR') merge_parser.add_argument('--number', type=int, required=True, help='PR 编号') # fix-loop fix_parser = subparsers.add_parser('fix-loop', help='修复循环') fix_parser.add_argument('--number', type=int, required=True, help='PR 编号') fix_parser.add_argument('--attempt', type=int, default=1, help='尝试次数') # full-pipeline full_parser = subparsers.add_parser('full-pipeline', help='完整流程') full_parser.add_argument('--number', type=int, required=True, help='PR 编号') # save-results save_parser = subparsers.add_parser('save-results', help='保存审查结果') save_parser.add_argument('--number', type=int, required=True, help='PR 编号') save_parser.add_argument('--reviews', type=str, required=True, help='审查结果 JSON') args = parser.parse_args() if args.command == 'get-pr': result = get_pr(args.number) elif args.command == 'review': result = start_review(args.number) elif args.command == 'check-results': result = check_results(args.number) elif args.command == 'merge-pr': result = merge_pr(args.number) elif args.command == 'fix-loop': result = fix_loop(args.number, args.attempt) elif args.command == 'full-pipeline': result = full_pipeline(args.number) elif args.command == 'save-results': result = save_review_results(args.number, json.loads(args.reviews)) else: parser.print_help() return print(json.dumps(result, ensure_ascii=False, indent=2)) if __name__ == '__main__': main()