33c8de02da
## dev-workflow - Spec-Driven 开发流程 - 从需求生成 spec.md - 创建功能分支 - 调用子代理开发 - 自动创建 Gitea PR ## pr-review-gate - 3 审查者并行审查(安全/质量/逻辑) - 自动合并逻辑 - 修复循环(最多3次) ## .planning - PROJECT.md: 项目定义 - REQUIREMENTS.md: 需求列表 - ROADMAP.md: 开发路线图 - STATE.md: 当前状态
334 lines
10 KiB
Python
334 lines
10 KiB
Python
#!/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()
|