Files
codex-app-server/cloud/pr.py
T
host 0b96b2929c pr: commit worktree changes before push; fix merge/pr order docs
POST /tasks/{id}/pr used to push the branch as-is, so uncommitted
worktree changes never reached the PR (empty diff). Now, after all
skip checks pass, the endpoint commits the worktree on host/<task>
(guarded: refuses when the worktree is on another branch), cleans
__pycache__/*.pyc like host.py merge does, and falls back to the
cloud identity when no git identity is configured. Nothing-to-commit
proceeds. Docs corrected: PR comes after status-merge and before
any local git merge (which deletes branch+worktree).
2026-10-26 15:20:02 +08:00

239 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""云端自动 PR:默认关闭、显式触发、缺条件时跳过不失败。
零第三方依赖,只用 Python 标准库;不改 host.py 的任务逻辑,
只复用它的规则:host 任务名 = cloud 任务 id 经安全字符过滤
(见 cloud/sandbox.py LocalExecutor._host_name),分支名是
host/<host 任务名>,worktree 在 <仓库根>/work/host-worktrees/
<host 任务名>。
触发规则(按顺序,任一步跳过即返回,不推、不改任务状态):
1. 任务所属项目的 auto_pr 不为 1 -> pr_skipped, auto_pr_disabled
2. 本地没有对应 worktree -> pr_skipped, no_worktree
3. worktree 里没有 git remote origin -> pr_skipped, no_remote
4. 本机没有 gh 二进制 -> pr_skipped, no_gh
5. gh 未登录(gh auth status 非零) -> pr_skipped,
gh_not_authenticated
只有以上全过才提交 worktree 改动、push 分支 + gh pr create;
push 或 pr create 失败时抛 PrStepError(调用方据此返回 502),
任务状态保持 merged 不变,绝不把任务标 failed。
提交规则(create_task_pr 在全部跳过检查通过后、push 之前做):
worktree 必须正好在分支 host/<任务名> 上,否则直接失败,
绝不在别的分支上提交;先清掉 worktree 内的 __pycache__ /
*.pyc(与 host.py merge 同规则,只动 worktree,不动主仓),
再 git add -A + commit;nothing to commit 时直接继续。
身份用环境里的 git 身份(与 host.py merge 一致),缺身份时
回退到 cloud/cloud@local 再试一次。
顺序约束(与云端 merge 接口配套):任务 completed -> 调
POST /tasks/{id}/merge 改状态 -> 调 POST /tasks/{id}/pr 推分支
建 PR -> 再在 GitHub 上合 PR 或本地 host.py merge。本地
host.py merge 成功后会删分支和 worktree,此后调 PR 会跳过
no_worktree,所以 PR 必须在本地 git merge 之前做。
worker 不自动推:自动 PR 只由显式调 POST /tasks/{id}/pr
触发。默认自动推会把测试任务也推到远端,本模块不做。
本模块不存、不打任何密钥:gh 的登录凭证由 gh 自己管
(gh auth login),push/pr 的命令与错误文本里不出现任何
token;错误文本只含 git/gh 的原文摘要,不含远端 URL 里的
userinfo(若 remote URL 含 userinfo,摘要里把它抹掉)。
"""
import os
import re
import shutil
import subprocess
_SKIP_AUTO_PR = "auto_pr_disabled"
_SKIP_NO_WORKTREE = "no_worktree"
_SKIP_NO_REMOTE = "no_remote"
_SKIP_NO_GH = "no_gh"
_SKIP_NOT_AUTHED = "gh_not_authenticated"
class PrSkipped(Exception):
"""跳过:reason 见上列常量,调用方返回 200 + pr_skipped。"""
def __init__(self, reason):
super().__init__(reason)
self.reason = reason
class PrStepError(Exception):
"""push / pr create 哪一步失败:step 为 push 或 pr_create。"""
def __init__(self, step, message):
super().__init__(message)
self.step = step
self.message = message
def host_name_for_task(task_id):
"""cloud 任务 id -> host 任务名(与 sandbox._host_name 同规则)。"""
return re.sub(r"[^A-Za-z0-9_.-]", "-", str(task_id))
def default_worktrees_root():
"""默认 worktree 根:<仓库根>/work/host-worktrees。"""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.join(os.path.dirname(here), "work", "host-worktrees")
def resolve_worktrees_root(override=None):
"""worktree 根优先级:显式参数 > 环境变量 > 默认。"""
if override:
return override
return os.environ.get("CLOUD_HOST_WORKTREES_DIR") or \
default_worktrees_root()
def _scrub_url(text):
"""抹掉 URL 里的 userinfo(https://user:pass@host -> https://host),
防止错误文本把凭证带出去。"""
if not text:
return text
return re.sub(r"(https?://)[^/@\s]+@", r"\1", text)
def _run(cmd, cwd=None, runner=None):
"""命令唯一入口:runner 供单测注入,返回带 returncode/stdout/
stderr 的对象;真实路径走 subprocess。"""
if runner is not None:
return runner(cmd, cwd)
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
def _git_remote_url(worktree_dir, git_runner=None):
r = _run(["git", "-C", worktree_dir, "remote", "get-url", "origin"],
cwd=worktree_dir, runner=git_runner)
url = (getattr(r, "stdout", "") or "").strip()
if getattr(r, "returncode", 1) != 0 or not url:
return None
return url
def check_pr_ready(worktree_dir, git_runner=None, gh_which=None,
gh_runner=None):
"""按顺序做跳过检查,全过返回 (worktree_dir, branch);任一步
不过抛 PrSkipped。branch 由调用方按 host 任务名算好传进来
的是 worktree 检查——本函数只查 worktree 与 remote/gh/登录,
分支名由 create_task_pr 组装。"""
if not os.path.isdir(worktree_dir):
raise PrSkipped(_SKIP_NO_WORKTREE)
if _git_remote_url(worktree_dir, git_runner) is None:
raise PrSkipped(_SKIP_NO_REMOTE)
gh_bin = gh_which if gh_which is not None else shutil.which("gh")
if not gh_bin:
raise PrSkipped(_SKIP_NO_GH)
r = _run(["gh", "auth", "status"], cwd=worktree_dir, runner=gh_runner)
if getattr(r, "returncode", 1) != 0:
raise PrSkipped(_SKIP_NOT_AUTHED)
return True
def _commit_worktree_changes(worktree_dir, branch, task_id,
git_runner=None):
"""push 之前把 worktree 的改动提交到分支上。
顺序:先确认 worktree 正好在目标分支上(不在就直接失败,
绝不在别的分支上提交,commit 到 main 里就污染主分支了);
再清掉 worktree 内的 __pycache__ / *.pyc(可再生的缓存,
与 host.py merge 同规则);再 git add -A + commit。
nothing to commit 时直接返回;缺 git 身份时回退到
cloud/cloud@local 再试一次;其它失败抛 PrStepError(commit)。
"""
cur = _run(["git", "-C", worktree_dir, "branch", "--show-current"],
cwd=worktree_dir, runner=git_runner)
if (getattr(cur, "stdout", "") or "").strip() != branch:
raise PrStepError(
"commit", "worktree 不在分支 %s 上,拒绝提交" % branch)
for root, dirs, files in os.walk(worktree_dir):
if ".git" in dirs:
dirs.remove(".git")
if "__pycache__" in dirs:
shutil.rmtree(os.path.join(root, "__pycache__"),
ignore_errors=True)
dirs.remove("__pycache__")
for fn in files:
if fn.endswith(".pyc"):
try:
os.remove(os.path.join(root, fn))
except OSError:
pass
add = _run(["git", "-C", worktree_dir, "add", "-A"],
cwd=worktree_dir, runner=git_runner)
if getattr(add, "returncode", 1) != 0:
raise PrStepError(
"commit", "git add 失败:%s" % (
(getattr(add, "stderr", "") or "")
or (getattr(add, "stdout", "") or "")))
msg = "cloud: %s" % task_id
c = _run(["git", "-C", worktree_dir, "commit", "-m", msg],
cwd=worktree_dir, runner=git_runner)
if getattr(c, "returncode", 1) == 0:
return
out = ((getattr(c, "stdout", "") or "")
+ (getattr(c, "stderr", "") or ""))
if "nothing to commit" in out:
return
if ("tell me who you are" in out or "user.name" in out
or "user.email" in out):
c2 = _run(["git", "-C", worktree_dir,
"-c", "user.name=cloud", "-c", "user.email=cloud@local",
"commit", "-m", msg],
cwd=worktree_dir, runner=git_runner)
if getattr(c2, "returncode", 1) == 0:
return
out2 = ((getattr(c2, "stdout", "") or "")
+ (getattr(c2, "stderr", "") or ""))
if "nothing to commit" in out2:
return
raise PrStepError(
"commit", "git commit 失败(已试 cloud 身份):%s" % out2[:300])
raise PrStepError("commit", "git commit 失败:%s" % out[:300])
def create_task_pr(task_id, auto_pr, worktrees_root=None,
git_runner=None, gh_runner=None, gh_which=None):
"""给一个已 merged 的云端任务建 PR。
auto_pr 不为 1 时直接抛 PrSkipped(auto_pr_disabled),调用方
在查 worktree 之前就应先查项目 auto_pr——本函数也再查一次,
防止直接调用者漏查。成功返回 {pr_url, pr_number, branch}。
全部跳过检查通过后,先把 worktree 改动提交到分支再 push,
否则未提交的改动进不了 PR。
"""
if auto_pr != 1:
raise PrSkipped(_SKIP_AUTO_PR)
host_name = host_name_for_task(task_id)
worktree_dir = os.path.join(
resolve_worktrees_root(worktrees_root), host_name)
branch = "host/" + host_name
check_pr_ready(worktree_dir, git_runner=git_runner,
gh_which=gh_which, gh_runner=gh_runner)
_commit_worktree_changes(worktree_dir, branch, task_id,
git_runner=git_runner)
push = _run(["git", "-C", worktree_dir, "push", "origin", branch],
cwd=worktree_dir, runner=git_runner)
if getattr(push, "returncode", 1) != 0:
raise PrStepError(
"push", "git push origin %s 失败:%s" % (
branch, _scrub_url(
(getattr(push, "stderr", "") or "")
or (getattr(push, "stdout", "") or ""))))
title = "cloud: %s" % task_id
r = _run(["gh", "pr", "create", "--title", title,
"--body", "cloud 任务 %s 的自动 PR。" % task_id,
"--head", branch],
cwd=worktree_dir, runner=gh_runner)
out = (getattr(r, "stdout", "") or "").strip()
if getattr(r, "returncode", 1) != 0 or not out:
raise PrStepError(
"pr_create", "gh pr create 失败:%s" % _scrub_url(
(getattr(r, "stderr", "") or "") 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
return {"pr_url": pr_url, "pr_number": pr_number, "branch": branch}