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:
1 parent
ee4b01c8f6
commit
25d9b3d938
7 files changed
+1060
-20
No files matched your search
+107
-9
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user