host: GitHub PR/CI line (github-workflow, pr, pr-checks, pr-merge with checks gate)

This commit is contained in:
host committed 2026-10-26 01:12:04 +08:00
1 parent 2f326f33f8
commit b97c34465f
4 files changed
+458

No files matched your search

+234
View File
@@ -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)