host: GitHub PR/CI line (github-workflow, pr, pr-checks, pr-merge with checks gate)
This commit is contained in:
1 parent
2f326f33f8
commit
b97c34465f
4 files changed
+458
No files matched your search
@@ -47,6 +47,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -919,6 +920,189 @@ def check_state(state_path, state):
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- GitHub PR / CI ----------------
|
||||
# 这一层只做 host -> GitHub 的桥,不做本地合并:本地合并仍是
|
||||
# merge_task(),PR 合并由 GitHub 完成,两条线互不干扰。
|
||||
# 真实 PR 需要两样外部条件,缺一不可,且都不由本轮代码提供:
|
||||
# 1. 仓库有 git remote origin(本仓目前没有 remote);
|
||||
# 2. gh 已登录(gh auth login,PAT 走 Secure Vault,
|
||||
# 绝不写进文件、state、日志或聊天记录)。
|
||||
# gh 调用全部走 run_gh(),单测注入假 gh,不依赖网络和登录。
|
||||
|
||||
HOST_VERIFY_WORKFLOW = """name: host-verify
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
|
||||
def generate_github_workflow(repo):
|
||||
"""生成 .github/workflows/host-verify.yml(幂等):
|
||||
返回 (changed, path)。文件已存在且内容相同则不改写。"""
|
||||
path = os.path.join(repo, ".github", "workflows", "host-verify.yml")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
if os.path.exists(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
if f.read() == HOST_VERIFY_WORKFLOW:
|
||||
return False, path
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(HOST_VERIFY_WORKFLOW)
|
||||
return True, path
|
||||
|
||||
|
||||
def run_gh(args, cwd=None, runner=None):
|
||||
"""gh 调用唯一入口。runner 供单测注入:runner(args) 返回
|
||||
一个带 returncode/stdout/stderr 的对象;真实路径走 gh CLI,
|
||||
cwd 传给 gh 找仓库上下文(假 runner 忽略 cwd)。"""
|
||||
if runner is not None:
|
||||
return runner(args)
|
||||
return subprocess.run(["gh"] + list(args), cwd=cwd,
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def _github_remote_url(repo):
|
||||
r = git(["remote", "get-url", "origin"], repo)
|
||||
url = (r.stdout or "").strip()
|
||||
return url if r.returncode == 0 and url else None
|
||||
|
||||
|
||||
def build_pr_body(task):
|
||||
"""PR 正文:任务名、提示摘要、未解决评审评论。
|
||||
只含任务本身的信息,不含密钥。"""
|
||||
prompt = (task.get("prompt") or "")[:500]
|
||||
lines = [f"host 任务:{task.get('name')}",
|
||||
"",
|
||||
"任务提示摘要:",
|
||||
prompt or "(无提示)",
|
||||
""]
|
||||
comments = open_review_comments(task)
|
||||
if comments:
|
||||
lines.append("评审评论(未解决):")
|
||||
for c in comments:
|
||||
lines.append(f"- {c['file']}:{c['line']}: {c['comment']}")
|
||||
else:
|
||||
lines.append("评审评论:无未解决评论")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_pr(repo, task, gh_runner=None):
|
||||
"""建 PR:先校验任务,再 push 分支,再 gh pr create。
|
||||
成功返回 {pr_url, pr_number, branch} 并写回任务记录;
|
||||
任何一步失败都抛异常,不编造 URL、不静默失败。
|
||||
调用方负责 save_state。"""
|
||||
branch = task.get("branch")
|
||||
if not branch:
|
||||
raise ValueError(f"任务 {task.get('name')} 没有 branch,"
|
||||
f"无法建 PR(可能是没用 worktree 派发的任务)")
|
||||
if task.get("status") not in ("completed", "merged"):
|
||||
raise ValueError(f"任务 {task.get('name')} 状态是 "
|
||||
f"{task.get('status')},只有 completed/merged "
|
||||
f"才能建 PR")
|
||||
if not _github_remote_url(repo):
|
||||
raise RuntimeError(
|
||||
"仓库没有 git remote origin,无法 push 分支和建 PR:"
|
||||
"请先给仓库加 remote(git remote add origin <url>)"
|
||||
"并用 gh auth login 登录,PAT 走 Secure Vault,"
|
||||
"不要写进文件或聊天记录")
|
||||
push = git(["push", "origin", branch], repo)
|
||||
if push.returncode != 0:
|
||||
raise RuntimeError(f"git push origin {branch} 失败:"
|
||||
f"{push.stderr or push.stdout}")
|
||||
title = f"host: {task.get('name')}"
|
||||
r = run_gh(["pr", "create", "--title", title,
|
||||
"--body", build_pr_body(task), "--head", branch],
|
||||
cwd=repo, runner=gh_runner)
|
||||
out = (r.stdout or "").strip()
|
||||
if r.returncode != 0 or not out:
|
||||
# 失败时打印 gh 的错误原文,不编造 URL。
|
||||
raise RuntimeError(f"gh pr create 失败:{r.stderr or out}")
|
||||
pr_url = out.splitlines()[0].strip()
|
||||
m = re.search(r"/(\d+)\s*$", pr_url)
|
||||
pr_number = int(m.group(1)) if m else None
|
||||
task["pr_url"] = pr_url
|
||||
task["pr_number"] = pr_number
|
||||
return {"pr_url": pr_url, "pr_number": pr_number, "branch": branch}
|
||||
|
||||
|
||||
def parse_pr_checks(output):
|
||||
"""解析 gh pr checks 输出,兼容两种常见格式:
|
||||
制表符格式(name<TAB>state…)和人类可读格式(✓ build pass)。
|
||||
返回 (checks, all_pass):checks 为 [{name, state}],state
|
||||
统一成 pass/fail/pending/skipped/cancelled/neutral;
|
||||
没有任何 check 时 all_pass 为 False(空 PR 不能算全过)。"""
|
||||
checks = []
|
||||
for raw in (output or "").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or "no checks" in line.lower():
|
||||
continue
|
||||
if line.lower().startswith("name"):
|
||||
continue
|
||||
if "\t" in line:
|
||||
parts = [p.strip() for p in line.split("\t") if p.strip()]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name, state_raw = parts[0], parts[1].lower()
|
||||
else:
|
||||
m = re.search(r"\b(pass|passing|fail|failing|failed|pending|"
|
||||
r"queued|waiting|in_progress|skipped|cancelled|"
|
||||
r"neutral)\b", line.lower())
|
||||
if not m:
|
||||
continue
|
||||
state_raw = m.group(1)
|
||||
name = line[:m.start()].strip(" ✓✗●○-").strip()
|
||||
if not name:
|
||||
name = line
|
||||
state = {"passing": "pass", "failing": "fail", "failed": "fail",
|
||||
"queued": "pending", "waiting": "pending",
|
||||
"in_progress": "pending"}.get(state_raw, state_raw)
|
||||
checks.append({"name": name, "state": state})
|
||||
all_pass = bool(checks) and all(c["state"] == "pass" for c in checks)
|
||||
return checks, all_pass
|
||||
|
||||
|
||||
def get_pr_checks(task, gh_runner=None, repo=None):
|
||||
"""查 PR 的 CI 状态。任务须已有 pr_number,否则报错。"""
|
||||
number = task.get("pr_number")
|
||||
if not number:
|
||||
raise ValueError(f"任务 {task.get('name')} 还没有 PR 编号,"
|
||||
f"先跑 pr 建 PR")
|
||||
r = run_gh(["pr", "checks", str(number)], cwd=repo, runner=gh_runner)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"gh pr checks 失败:{r.stderr or r.stdout}")
|
||||
checks, all_pass = parse_pr_checks(r.stdout or "")
|
||||
return {"pr_url": task.get("pr_url"), "checks": checks,
|
||||
"all_pass": all_pass}
|
||||
|
||||
|
||||
def merge_pr(task, gh_runner=None, repo=None):
|
||||
"""PR 合并门:先查 checks,未全过拒绝且不调 merge;
|
||||
全过后调 gh pr merge --merge,成功才把任务标 merged。
|
||||
不动本地主分支——合并由 GitHub 完成,本地分支/主仓
|
||||
不由这条命令改(和本地 merge_task() 是两条线)。"""
|
||||
info = get_pr_checks(task, gh_runner=gh_runner, repo=repo)
|
||||
if not info["all_pass"]:
|
||||
bad = [c for c in info["checks"] if c["state"] != "pass"]
|
||||
return False, (f"PR checks 未全过,拒绝合并:"
|
||||
f"{[(c['name'], c['state']) for c in bad]}")
|
||||
r = run_gh(["pr", "merge", str(task["pr_number"]), "--merge"],
|
||||
cwd=repo, runner=gh_runner)
|
||||
if r.returncode != 0:
|
||||
return False, f"gh pr merge 失败:{r.stderr or r.stdout}"
|
||||
task["status"] = "merged"
|
||||
return True, f"PR #{task['pr_number']} 已合并:{task.get('pr_url')}"
|
||||
|
||||
|
||||
|
||||
|
||||
def copy_tree_excluding_git(src, dst):
|
||||
"""把 src 工作区的文件拷到 dst(不含 .git、__pycache__、.pyc),
|
||||
@@ -1143,6 +1327,13 @@ def main():
|
||||
help="把 run-due 写进当前用户 crontab(幂等,已有则不重复)")
|
||||
p.add_argument("--cron-every-minutes", type=int, default=30)
|
||||
sub.add_parser("run-due")
|
||||
sub.add_parser("github-workflow")
|
||||
p = sub.add_parser("pr"); p.add_argument("--task", required=True)
|
||||
p.add_argument("--repo", default=None)
|
||||
p = sub.add_parser("pr-checks"); p.add_argument("--task", required=True)
|
||||
p.add_argument("--repo", default=None)
|
||||
p = sub.add_parser("pr-merge"); p.add_argument("--task", required=True)
|
||||
p.add_argument("--repo", default=None)
|
||||
|
||||
args = ap.parse_args()
|
||||
# 配置解析:CLI > 配置文件 > 内置默认。配置文件里绝不放密钥,
|
||||
@@ -1235,6 +1426,49 @@ def main():
|
||||
save_state(args.state, state)
|
||||
print(json.dumps(c, indent=2, ensure_ascii=False))
|
||||
return
|
||||
if args.cmd == "github-workflow":
|
||||
repo = cfg.get("repo")
|
||||
if not repo:
|
||||
print("缺少 repo:请在配置里配 repo")
|
||||
raise SystemExit(1)
|
||||
changed, path = generate_github_workflow(repo)
|
||||
print(json.dumps({"path": path,
|
||||
"changed": changed}, indent=2, ensure_ascii=False))
|
||||
return
|
||||
if args.cmd in ("pr", "pr-checks", "pr-merge"):
|
||||
if args.task not in state["tasks"]:
|
||||
print(f"任务不存在:{args.task}")
|
||||
raise SystemExit(1)
|
||||
t = state["tasks"][args.task]
|
||||
repo = args.repo or t.get("repo") or cfg.get("repo")
|
||||
try:
|
||||
if args.cmd == "pr":
|
||||
if not repo:
|
||||
print("缺少 repo:任务和配置里都没有 repo")
|
||||
raise SystemExit(1)
|
||||
info = create_pr(repo, t)
|
||||
save_state(args.state, state)
|
||||
print(json.dumps(info, indent=2, ensure_ascii=False))
|
||||
elif args.cmd == "pr-checks":
|
||||
info = get_pr_checks(t, repo=repo)
|
||||
print(json.dumps(info, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
ok, msg = merge_pr(t, repo=repo)
|
||||
if ok:
|
||||
save_state(args.state, state)
|
||||
print(json.dumps({"merged": True, "message": msg},
|
||||
indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(msg)
|
||||
raise SystemExit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
# 失败时打印错误原文(gh/git 的 stderr 已包含在异常里),
|
||||
# 不编造 PR URL,不静默失败。
|
||||
print(str(e))
|
||||
raise SystemExit(1)
|
||||
return
|
||||
if args.cmd == "health":
|
||||
report = run_health_check(args.state, cfg, args.codex_bin or default_codex_bin(),
|
||||
args.codex_home, args.approval)
|
||||
|
||||
Reference in new issue
Block a user