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

+29
View File
@@ -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 云端沙箱。
+47
View File
@@ -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。
+234
View File
@@ -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)
+148
View File
@@ -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()