Files
codex-app-server/cloud/pr.py
T
host 25d9b3d938 App-token:推分支建 PR 改走 GitHub App installation token
- github_app:纯标准库 RS256 签 App JWT(PKCS#1/PKCS#8),
  换 installation token(现换现用、不缓存),按仓库名查安装
  记录,任一步失败回落 None
- pr:app_token 路径 push 走 credential helper(token 只进
  子进程环境),建 PR 走 REST API(读 default_branch 当 base);
  不传 token 时 gh 路径不变
- server:/pr 端点按项目 repo_url 解析仓库名、现换 token,
  响应加 auth_via=app|pat,换失败回落 PAT 不报错
- 测试:JWT/换 token/解析 9 个、pr App 路径 6 个、端点级
  App 与 PAT 回落 2 个
2026-10-26 16:47:55 +08:00

337 lines
14 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,摘要里把它抹掉)。
App-token 路径(cloud/github_app.py 的 resolve_app_token 供
token):调用方传 app_token 时,跳过 gh 二进制与 gh 登录
检查(App 路径不需要 gh);push 走 git credential helper,
token 只放进子进程环境变量 GIT_TOKEN,不进命令行参数、不
进文件,helper 命令里只有变量名;建 PR 走 GitHub REST API
(先读仓库 default_branch 再建,base 不猜 main)。没传
app_token 时走原来的 gh 路径,两条路不互相干扰;App 没装
在该仓库时调用方不传 token、自动回落 PAT。
"""
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, env=None):
"""命令唯一入口:runner 供单测注入,返回带 returncode/stdout/
stderr 的对象;真实路径走 subprocess。env 只在真实路径生效
(与进程环境合并),注入的 runner 不收 env——token 绝不进
假 runner 记录的命令,也不进真实路径的命令行参数。"""
if runner is not None:
return runner(cmd, cwd)
full_env = None
if env:
full_env = dict(os.environ)
full_env.update(env)
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,
env=full_env)
_GITHUB_REMOTE_RE = re.compile(
r"^(?:https?://(?:[^/@\s]+@)?github\.com/"
r"|git@github\.com:|ssh://git@github\.com/)"
r"([^/\s]+)/([^/\s]+?)(?:\.git)?/?$")
def parse_git_remote_owner_repo(url):
"""解析 git remote URL -> (owner, repo),非 github.com 仓库
或解析失败返回 None(调用方据此回落 PAT,不报错)。"""
if not url or not isinstance(url, str):
return None
m = _GITHUB_REMOTE_RE.match(url.strip())
if not m:
return None
return m.group(1), m.group(2)
def _default_github_api(method, url, token, payload):
"""默认 GitHub API 客户端:复用 github_app 的 urllib 实现
(返回 (status, dict);懒 import,避免模块导入环)。"""
from cloud.github_app import _github_api_request
return _github_api_request(method, url, token, payload, None)
def _create_pr_via_api(owner, repo, branch, title, body, token,
github_api):
"""用 installation token 走 REST API 建 PR:先读仓库
default_branch 当 base(不猜 main),再 POST /pulls。
成功返回 {pr_url, pr_number, branch},失败抛 PrStepError。"""
api = github_api or _default_github_api
status, repo_info = api(
"GET", "https://api.github.com/repos/%s/%s" % (owner, repo),
token, None)
if status != 200:
raise PrStepError(
"pr_create",
"GitHub API 读仓库失败:%s" % (repo_info.get("message"),))
base = repo_info.get("default_branch") or "main"
status, data = api(
"POST", "https://api.github.com/repos/%s/%s/pulls" % (owner, repo),
token, {"title": title, "head": branch, "base": base,
"body": body})
if status not in (200, 201) or not data.get("html_url"):
raise PrStepError(
"pr_create",
"GitHub API 建 PR 失败:%s" % (data.get("message"),))
return {"pr_url": data["html_url"], "pr_number": data.get("number"),
"branch": branch}
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, app_token=None):
"""按顺序做跳过检查,全过返回 (worktree_dir, branch);任一步
不过抛 PrSkipped。branch 由调用方按 host 任务名算好传进来
的是 worktree 检查——本函数只查 worktree 与 remote/gh/登录,
分支名由 create_task_pr 组装。传了 app_token 时跳过 gh
二进制与 gh 登录检查(App 路径不需要 gh)。"""
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)
if app_token:
return True
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,
app_token=None, github_api=None):
"""给一个已 merged 的云端任务建 PR。
auto_pr 不为 1 时直接抛 PrSkipped(auto_pr_disabled),调用方
在查 worktree 之前就应先查项目 auto_pr——本函数也再查一次,
防止直接调用者漏查。成功返回 {pr_url, pr_number, branch}。
全部跳过检查通过后,先把 worktree 改动提交到分支再 push,
否则未提交的改动进不了 PR。
传了 app_token 时走 App 路径:push 用 credential helper
(token 只进子进程环境,不进参数),建 PR 走 GitHub API;
不传时走原来的 gh 路径。
"""
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,
app_token=app_token)
_commit_worktree_changes(worktree_dir, branch, task_id,
git_runner=git_runner)
title = "cloud: %s" % task_id
body = "cloud 任务 %s 的自动 PR。" % task_id
if app_token:
remote_url = _git_remote_url(worktree_dir, git_runner)
owner_repo = parse_git_remote_owner_repo(remote_url)
if owner_repo is None:
raise PrStepError(
"push",
"worktree remote 不是 github.com 仓库,App token 无法"
" push:%s" % _scrub_url(remote_url))
push = _run(
["git", "-C", worktree_dir, "-c",
"credential.helper=!f() { echo username=x-access-token;"
" echo password=$GIT_TOKEN; }; f",
"push", "origin", branch],
cwd=worktree_dir, runner=git_runner,
env={"GIT_TOKEN": app_token})
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 ""))))
return _create_pr_via_api(owner_repo[0], owner_repo[1], branch,
title, body, app_token, github_api)
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 ""))))
r = _run(["gh", "pr", "create", "--title", title,
"--body", body,
"--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}