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
@@ -169,6 +169,7 @@ turn 可以真正并发(不是轮流排队)。
|
|||||||
| 多机器 state 冲突 | 锁只防同一台机器,多机器共享 state 文件会静默覆盖 | 部分修:state 加 version/last_writer/updated_at,保存时磁盘版本比加载时新就记 STATE-CONFLICT 日志并合并保留双方任务,不静默丢;真多机同步仍需中心服务(见下“没对齐”) |
|
| 多机器 state 冲突 | 锁只防同一台机器,多机器共享 state 文件会静默覆盖 | 部分修:state 加 version/last_writer/updated_at,保存时磁盘版本比加载时新就记 STATE-CONFLICT 日志并合并保留双方任务,不静默丢;真多机同步仍需中心服务(见下“没对齐”) |
|
||||||
| 合并时提交缓存文件 | merge 的 git add -A 会把 __pycache__/*.pyc 也提交进主仓,下次合并被 .pyc 改动挡住 | 已修:merge 前清 worktree 的 __pycache__/*.pyc,主仓的 .pyc 改动先还原;单测覆盖 pyc 不入库 |
|
| 合并时提交缓存文件 | merge 的 git add -A 会把 __pycache__/*.pyc 也提交进主仓,下次合并被 .pyc 改动挡住 | 已修:merge 前清 worktree 的 __pycache__/*.pyc,主仓的 .pyc 改动先还原;单测覆盖 pyc 不入库 |
|
||||||
| 守护进程 + 健康检查 + 自动恢复 | host.py 跑完就退出,不常驻;state 坏了静默当空 state;中断任务一直停在 running | 已修:daemon(双 fork 到 PID 1,循环 recover + run-due)、health(不跑模型,查二进制/配置/state/server/daemon)、restore-state(坏了从 .bak 恢复并留底)、recover(超过阈值的 running 标 interrupted,可用 run 续跑,不自动重跑模型回合) |
|
| 守护进程 + 健康检查 + 自动恢复 | host.py 跑完就退出,不常驻;state 坏了静默当空 state;中断任务一直停在 running | 已修:daemon(双 fork 到 PID 1,循环 recover + run-due)、health(不跑模型,查二进制/配置/state/server/daemon)、restore-state(坏了从 .bak 恢复并留底)、recover(超过阈值的 running 标 interrupted,可用 run 续跑,不自动重跑模型回合) |
|
||||||
|
| GitHub PR / CI 一条线 | 本地 merge 完就完,没有 PR、没有 CI 门 | 已修(代码 + 本地可测部分):github-workflow 生成 .github/workflows/host-verify.yml(checkout + python + unittest,幂等);pr(push 分支 + gh pr create,成功后 pr_url/pr_number 写回任务记录);pr-checks(解析 gh pr checks,all_pass 仅全 pass 为真,空 checks 不算过);pr-merge(先查 checks,未全过拒绝且不调 merge,全过才 gh pr merge --merge 并标 merged,不动本地主分支)。gh 调用走 run_gh(),单测用假 gh。**真实 PR 本轮没跑**:本仓没有 git remote,gh 未登录,见下“GitHub PR/CI 的外部条件”。 |
|
||||||
| 边界:cwd、沙箱、审批、模型 | dispatch / turn 参数里定,见 `config.example.toml` | 已实测 |
|
| 边界:cwd、沙箱、审批、模型 | dispatch / turn 参数里定,见 `config.example.toml` | 已实测 |
|
||||||
| 任务登记 | `host-state.json`:task -> thread/worktree/状态/diff;agent 状态本身仍在 CODEX_HOME,不另存第二份 | 已实测 |
|
| 任务登记 | `host-state.json`:task -> thread/worktree/状态/diff;agent 状态本身仍在 CODEX_HOME,不另存第二份 | 已实测 |
|
||||||
|
|
||||||
@@ -197,6 +198,34 @@ turn 可以真正并发(不是轮流排队)。
|
|||||||
或 crontab;daemon 适合常驻机器,VM 上是“会话内常驻 +
|
或 crontab;daemon 适合常驻机器,VM 上是“会话内常驻 +
|
||||||
回收后重拉”的定位。
|
回收后重拉”的定位。
|
||||||
|
|
||||||
|
## GitHub PR/CI 的外部条件(真实 PR 仍缺这两样)
|
||||||
|
|
||||||
|
PR/CI 这条线本轮只做了代码和本地可测部分,真实 PR 没有跑,
|
||||||
|
原因如实写在这里,不是没做完就含糊过去:
|
||||||
|
|
||||||
|
- **仓库远端**:本仓(codex-app-server)目前没有 git remote
|
||||||
|
origin,`git remote -v` 为空。pr 命令会先查 remote,没有就
|
||||||
|
明确报错,不静默失败。以后给仓库加 remote 再跑:
|
||||||
|
`git remote add origin <url>`。
|
||||||
|
- **gh 登录**:本机有 `gh`,但 `gh auth status` 显示未登录。
|
||||||
|
不要在本轮登录,也不要把 PAT 写进任何文件、state、日志或
|
||||||
|
聊天记录;以后用户给 PAT 时走 Secure Vault,再跑
|
||||||
|
`gh auth login`。没有登录时 pr/pr-checks/pr-merge 会把 gh
|
||||||
|
的错误原文打印出来,不编造 PR URL。
|
||||||
|
- **流程(远端和登录齐了之后)**:
|
||||||
|
1. `host.py github-workflow` 生成 CI workflow(已生成的文件
|
||||||
|
要提交进仓库,GitHub Actions 才会跑它);
|
||||||
|
2. 任务 completed 后 `host.py pr --task <名>`:push 分支 +
|
||||||
|
gh pr create,pr_url/pr_number 写回任务记录;
|
||||||
|
3. `host.py pr-checks --task <名>`:看 CI 全过没有;
|
||||||
|
4. `host.py pr-merge --task <名>`:全过才合并,合并由
|
||||||
|
GitHub 完成,不动本地主分支——和本地 `merge` 命令是两条
|
||||||
|
线,不要混用:本地 merge 是直接合进本地主仓,PR merge
|
||||||
|
是走 GitHub 的 PR 流程。
|
||||||
|
- **两条线不冲突的原因**:本地 merge 用 merge_task()(自动验测
|
||||||
|
+ 回滚),PR merge 用 pr-merge(checks 门 + GitHub 合并);
|
||||||
|
一个任务要么走本地 merge,要么走 PR,不要两边都合。
|
||||||
|
|
||||||
## 开源没给到的(别指望照搬)
|
## 开源没给到的(别指望照搬)
|
||||||
|
|
||||||
- 模型本身、ChatGPT 登录、计费、Codex Cloud 云端沙箱。
|
- 模型本身、ChatGPT 登录、计费、Codex Cloud 云端沙箱。
|
||||||
|
|||||||
@@ -438,3 +438,50 @@ tests/ 标准库 unittest,验收命令就是 `python3 -m unittest discover -s
|
|||||||
`host.py --config <config> health`(无模型成本)和 /
|
`host.py --config <config> health`(无模型成本)和 /
|
||||||
或 `run-due`(到期才跑模型),需用户定间隔和是否允许
|
或 `run-due`(到期才跑模型),需用户定间隔和是否允许
|
||||||
自动跑模型后才装。
|
自动跑模型后才装。
|
||||||
|
|
||||||
|
## GitHub PR/CI 一条线(2026-10-26,用户选“先做 GitHub PR/CI 这一条线”)
|
||||||
|
|
||||||
|
- **只做代码和本地可测部分,真实 PR 本轮没跑**:本仓没有
|
||||||
|
git remote origin(`git remote -v` 为空),gh 未登录
|
||||||
|
(`gh auth status`:未登录)。按任务要求,不登录 GitHub、
|
||||||
|
不向真实 GitHub push 或建 PR,真实 PR 那一步留给用户以后
|
||||||
|
给仓库远端和 PAT 之后再跑;PAT 走 Secure Vault,绝不写进
|
||||||
|
文件、state、日志或聊天记录。
|
||||||
|
- **新增命令(都在 Server 创建之前,不跑模型)**:
|
||||||
|
- `github-workflow`:生成 `.github/workflows/host-verify.yml`
|
||||||
|
(checkout + setup-python 3.12 + `python3 -m unittest
|
||||||
|
discover -s tests -v`),幂等:已存在且内容相同则不改写。
|
||||||
|
临时仓库实测:第一次 changed=true,第二次 changed=false。
|
||||||
|
- `pr --task <名>`:校验任务(无 branch 或状态不是
|
||||||
|
completed/merged 就报错退出)→ 查 remote origin(无 remote
|
||||||
|
就明确报错,不静默失败)→ `git push origin <branch>` →
|
||||||
|
`gh pr create --title/--body/--head`(body 含任务名、提示摘要、
|
||||||
|
未解决评审评论)→ 成功后 pr_url/pr_number 写回任务记录并
|
||||||
|
保存 state;失败打印 gh/git 错误原文,不编造 URL。
|
||||||
|
- `pr-checks --task <名>`:调 `gh pr checks <number>`,解析
|
||||||
|
输出返回 {pr_url, checks: [{name, state}], all_pass},
|
||||||
|
all_pass 仅当 checks 非空且全部为 pass 时为真(空 checks
|
||||||
|
不算过,否则空 PR 也能合)。解析兼容制表符格式和
|
||||||
|
人类可读格式(✓ build pass / ✗ test fail)。
|
||||||
|
- `pr-merge --task <名>`:先调 pr-checks,未全过拒绝且不调
|
||||||
|
merge,并打印未过项;全过才 `gh pr merge --merge`,成功
|
||||||
|
后任务状态标 merged。**不动本地主分支**:合并由 GitHub
|
||||||
|
完成,和本地 merge_task()(直接合进本地主仓 + 自动验测
|
||||||
|
回滚)是两条线,一个任务不要两边都合。
|
||||||
|
- **gh 可注入**:全部 gh 调用走 `run_gh(args, cwd, runner)`,
|
||||||
|
单测注入假 gh,不依赖真实网络和登录;真实路径走 gh CLI。
|
||||||
|
- **单测**:tests 从 19 个涨到 27 个,全部通过。新增 8 个:
|
||||||
|
workflow 内容正确且幂等(含被改坏后恢复);pr-checks
|
||||||
|
解析 pass / fail / mixed / 空;pr-merge 未全过时拒绝且未
|
||||||
|
调 merge、全过时才调 merge 并标 merged;无 branch /
|
||||||
|
非 completed 状态被拒;无 remote 时 pr 报错含 remote;
|
||||||
|
本地 bare 远端 push 成功后假 gh 返回 PR URL(pr_number
|
||||||
|
42 写回任务,bare 远端里确有 host/t1 分支);CLI 层
|
||||||
|
`pr --task` 在无 remote 时退出码非零且输出含 remote。
|
||||||
|
- **未动的部分**:本地 merge/goal/review 行为未改;没有动
|
||||||
|
~/.codex/config.toml;没有动 wechat-bridge;没有提交
|
||||||
|
git commit,改动留在工作区。
|
||||||
|
- **真实 PR 仍缺什么**:仓库远端(`git remote add origin <url>`)
|
||||||
|
和 gh 登录(PAT 走 Secure Vault)。两者齐了之后的顺序:
|
||||||
|
github-workflow 生成并提交 workflow → 任务 completed 后
|
||||||
|
pr → pr-checks → pr-merge。
|
||||||
@@ -47,6 +47,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -919,6 +920,189 @@ def check_state(state_path, state):
|
|||||||
return out
|
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):
|
def copy_tree_excluding_git(src, dst):
|
||||||
"""把 src 工作区的文件拷到 dst(不含 .git、__pycache__、.pyc),
|
"""把 src 工作区的文件拷到 dst(不含 .git、__pycache__、.pyc),
|
||||||
@@ -1143,6 +1327,13 @@ def main():
|
|||||||
help="把 run-due 写进当前用户 crontab(幂等,已有则不重复)")
|
help="把 run-due 写进当前用户 crontab(幂等,已有则不重复)")
|
||||||
p.add_argument("--cron-every-minutes", type=int, default=30)
|
p.add_argument("--cron-every-minutes", type=int, default=30)
|
||||||
sub.add_parser("run-due")
|
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()
|
args = ap.parse_args()
|
||||||
# 配置解析:CLI > 配置文件 > 内置默认。配置文件里绝不放密钥,
|
# 配置解析:CLI > 配置文件 > 内置默认。配置文件里绝不放密钥,
|
||||||
@@ -1235,6 +1426,49 @@ def main():
|
|||||||
save_state(args.state, state)
|
save_state(args.state, state)
|
||||||
print(json.dumps(c, indent=2, ensure_ascii=False))
|
print(json.dumps(c, indent=2, ensure_ascii=False))
|
||||||
return
|
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":
|
if args.cmd == "health":
|
||||||
report = run_health_check(args.state, cfg, args.codex_bin or default_codex_bin(),
|
report = run_health_check(args.state, cfg, args.codex_bin or default_codex_bin(),
|
||||||
args.codex_home, args.approval)
|
args.codex_home, args.approval)
|
||||||
|
|||||||
@@ -373,6 +373,154 @@ class HealthRestoreRecoverTest(unittest.TestCase):
|
|||||||
self.assertTrue(report["checks"]["server_ok"])
|
self.assertTrue(report["checks"]["server_ok"])
|
||||||
|
|
||||||
|
|
||||||
|
class GithubPRTest(unittest.TestCase):
|
||||||
|
class FakeGh:
|
||||||
|
def __init__(self, checks_output="", pr_url=""):
|
||||||
|
self.checks_output = checks_output
|
||||||
|
self.pr_url = pr_url
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def __call__(self, args):
|
||||||
|
self.calls.append(list(args))
|
||||||
|
import types
|
||||||
|
if args[:2] == ["pr", "checks"]:
|
||||||
|
return types.SimpleNamespace(returncode=0,
|
||||||
|
stdout=self.checks_output,
|
||||||
|
stderr="")
|
||||||
|
if args[:2] == ["pr", "create"]:
|
||||||
|
return types.SimpleNamespace(returncode=0,
|
||||||
|
stdout=self.pr_url + "\n",
|
||||||
|
stderr="")
|
||||||
|
if args[:2] == ["pr", "merge"]:
|
||||||
|
return types.SimpleNamespace(returncode=0,
|
||||||
|
stdout="Merged\n", stderr="")
|
||||||
|
return types.SimpleNamespace(returncode=1, stdout="",
|
||||||
|
stderr="unknown fake gh call")
|
||||||
|
|
||||||
|
def test_workflow_content_and_idempotent(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
changed, path = host.generate_github_workflow(tmp)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
content = Path(path).read_text()
|
||||||
|
self.assertIn("actions/checkout", content)
|
||||||
|
self.assertIn("setup-python", content)
|
||||||
|
self.assertIn("python3 -m unittest discover -s tests -v",
|
||||||
|
content)
|
||||||
|
changed2, _ = host.generate_github_workflow(tmp)
|
||||||
|
self.assertFalse(changed2)
|
||||||
|
# 内容被改坏后再次生成应恢复
|
||||||
|
Path(path).write_text("garbage\n")
|
||||||
|
changed3, _ = host.generate_github_workflow(tmp)
|
||||||
|
self.assertTrue(changed3)
|
||||||
|
self.assertEqual(Path(path).read_text(), content)
|
||||||
|
|
||||||
|
def test_pr_checks_parse_pass_fail_mixed(self):
|
||||||
|
checks, all_pass = host.parse_pr_checks(
|
||||||
|
"build\tpass\t1m\ntest\tpass\t2m\n")
|
||||||
|
self.assertTrue(all_pass)
|
||||||
|
self.assertEqual([c["state"] for c in checks], ["pass", "pass"])
|
||||||
|
checks, all_pass = host.parse_pr_checks(
|
||||||
|
"✓ build pass\n✗ test fail\n")
|
||||||
|
self.assertFalse(all_pass)
|
||||||
|
self.assertEqual([c["name"] for c in checks], ["build", "test"])
|
||||||
|
checks, all_pass = host.parse_pr_checks("")
|
||||||
|
self.assertFalse(all_pass)
|
||||||
|
self.assertEqual(checks, [])
|
||||||
|
|
||||||
|
def test_pr_merge_refuses_when_not_all_pass(self):
|
||||||
|
fake = self.FakeGh(checks_output="build\tpass\ntest\tfail\n")
|
||||||
|
task = {"name": "t", "pr_url": "https://example.com/pull/7",
|
||||||
|
"pr_number": 7, "status": "completed"}
|
||||||
|
ok, msg = host.merge_pr(task, gh_runner=fake)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("未全过", msg)
|
||||||
|
self.assertNotIn(["pr", "merge", "7", "--merge"], fake.calls)
|
||||||
|
self.assertEqual(task["status"], "completed")
|
||||||
|
|
||||||
|
def test_pr_merge_only_when_all_pass(self):
|
||||||
|
fake = self.FakeGh(checks_output="build\tpass\ntest\tpass\n")
|
||||||
|
task = {"name": "t", "pr_url": "https://example.com/pull/8",
|
||||||
|
"pr_number": 8, "status": "completed"}
|
||||||
|
ok, msg = host.merge_pr(task, gh_runner=fake)
|
||||||
|
self.assertTrue(ok, msg)
|
||||||
|
self.assertIn(["pr", "merge", "8", "--merge"], fake.calls)
|
||||||
|
self.assertEqual(task["status"], "merged")
|
||||||
|
|
||||||
|
def test_pr_without_branch_or_wrong_status_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
host.create_pr("/tmp", {"name": "t", "status": "completed"})
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
host.create_pr("/tmp", {"name": "t", "branch": "host/t",
|
||||||
|
"status": "running"})
|
||||||
|
|
||||||
|
def test_pr_without_remote_gives_clear_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
_init_git_repo(tmp)
|
||||||
|
Path(tmp, "f.txt").write_text("x\n")
|
||||||
|
subprocess.run(["git", "add", "-A"], cwd=tmp, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-qm", "initial"],
|
||||||
|
cwd=tmp, check=True)
|
||||||
|
task = {"name": "t", "branch": "host/t", "status": "completed",
|
||||||
|
"prompt": "do it"}
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
host.create_pr(tmp, task, gh_runner=self.FakeGh())
|
||||||
|
self.assertIn("remote", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_pr_push_to_local_bare_remote_then_fake_gh(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
repo = os.path.join(tmp, "repo")
|
||||||
|
os.makedirs(repo)
|
||||||
|
_init_git_repo(repo)
|
||||||
|
Path(repo, "f.txt").write_text("x\n")
|
||||||
|
subprocess.run(["git", "add", "-A"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "commit", "-qm", "initial"],
|
||||||
|
cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "checkout", "-qb", "host/t1"],
|
||||||
|
cwd=repo, check=True)
|
||||||
|
Path(repo, "f.txt").write_text("y\n")
|
||||||
|
subprocess.run(["git", "commit", "-qam", "change"],
|
||||||
|
cwd=repo, check=True)
|
||||||
|
remote = os.path.join(tmp, "remote.git")
|
||||||
|
subprocess.run(["git", "init", "--bare", "-q", remote],
|
||||||
|
check=True)
|
||||||
|
subprocess.run(["git", "remote", "add", "origin", remote],
|
||||||
|
cwd=repo, check=True)
|
||||||
|
fake = self.FakeGh(
|
||||||
|
pr_url="https://github.com/example/repo/pull/42")
|
||||||
|
task = {"name": "t1", "branch": "host/t1",
|
||||||
|
"status": "completed", "prompt": "change f"}
|
||||||
|
info = host.create_pr(repo, task, gh_runner=fake)
|
||||||
|
self.assertEqual(info["pr_number"], 42)
|
||||||
|
self.assertEqual(task["pr_url"],
|
||||||
|
"https://github.com/example/repo/pull/42")
|
||||||
|
self.assertEqual(task["pr_number"], 42)
|
||||||
|
branches = subprocess.run(
|
||||||
|
["git", "--git-dir", remote, "branch"], capture_output=True,
|
||||||
|
text=True).stdout
|
||||||
|
self.assertIn("host/t1", branches)
|
||||||
|
|
||||||
|
def test_pr_cli_without_remote_fails_with_clear_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
repo = os.path.join(tmp, "repo")
|
||||||
|
os.makedirs(repo)
|
||||||
|
_init_git_repo(repo)
|
||||||
|
state_path = os.path.join(tmp, "host-state.json")
|
||||||
|
with open(state_path, "w") as f:
|
||||||
|
json.dump({"tasks": {"t1": {
|
||||||
|
"name": "t1", "branch": "host/t1",
|
||||||
|
"status": "completed", "prompt": "do it",
|
||||||
|
"repo": repo}}, "automations": []}, f)
|
||||||
|
cfg_path = os.path.join(tmp, "cfg.json")
|
||||||
|
with open(cfg_path, "w") as f:
|
||||||
|
json.dump({"repo": repo, "state": state_path}, f)
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, str(REPO_ROOT / "host.py"),
|
||||||
|
"--config", cfg_path, "pr", "--task", "t1"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
self.assertNotEqual(r.returncode, 0)
|
||||||
|
self.assertIn("remote", r.stdout + r.stderr)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
Reference in new issue
Block a user