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 个
This commit is contained in:
host committed 2026-10-26 16:47:55 +08:00
1 parent ee4b01c8f6
commit 25d9b3d938
7 files changed
+1060 -20

No files matched your search

+107 -9
View File
@@ -38,6 +38,15 @@ host/<host 任务名>,worktree 在 <仓库根>/work/host-worktrees/
(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
@@ -96,12 +105,69 @@ def _scrub_url(text):
return re.sub(r"(https?://)[^/@\s]+@", r"\1", text)
def _run(cmd, cwd=None, runner=None):
def _run(cmd, cwd=None, runner=None, env=None):
"""命令唯一入口:runner 供单测注入,返回带 returncode/stdout/
stderr 的对象;真实路径走 subprocess。"""
stderr 的对象;真实路径走 subprocess。env 只在真实路径生效
(与进程环境合并),注入的 runner 不收 env——token 绝不进
假 runner 记录的命令,也不进真实路径的命令行参数。"""
if runner is not None:
return runner(cmd, cwd)
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
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):
@@ -114,15 +180,18 @@ def _git_remote_url(worktree_dir, git_runner=None):
def check_pr_ready(worktree_dir, git_runner=None, gh_which=None,
gh_runner=None):
gh_runner=None, app_token=None):
"""按顺序做跳过检查,全过返回 (worktree_dir, branch);任一步
不过抛 PrSkipped。branch 由调用方按 host 任务名算好传进来
的是 worktree 检查——本函数只查 worktree 与 remote/gh/登录,
分支名由 create_task_pr 组装。"""
分支名由 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)
@@ -195,7 +264,8 @@ def _commit_worktree_changes(worktree_dir, branch, task_id,
def create_task_pr(task_id, auto_pr, worktrees_root=None,
git_runner=None, gh_runner=None, gh_which=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),调用方
@@ -203,6 +273,9 @@ def create_task_pr(task_id, auto_pr, worktrees_root=None,
防止直接调用者漏查。成功返回 {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)
@@ -211,9 +284,35 @@ def create_task_pr(task_id, auto_pr, worktrees_root=None,
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)
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:
@@ -222,9 +321,8 @@ def create_task_pr(task_id, auto_pr, worktrees_root=None,
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,
"--body", body,
"--head", branch],
cwd=worktree_dir, runner=gh_runner)
out = (getattr(r, "stdout", "") or "").strip()