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

+37 -4
View File
@@ -59,7 +59,8 @@ from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.kb import KBStore
from cloud.memory import MemoryStore
from cloud.pr import PrSkipped, PrStepError, create_task_pr
from cloud.pr import (PrSkipped, PrStepError, create_task_pr,
parse_git_remote_owner_repo)
from cloud.workers import (
heartbeat_worker as _registry_heartbeat,
list_workers as _registry_list,
@@ -100,6 +101,7 @@ from cloud.github_app import (
record_installation_repos,
remove_installation,
remove_installation_repos,
resolve_app_token,
save_app_config,
set_fix_review_task_id,
)
@@ -1505,6 +1507,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
pr_skipped 与 reason,任务状态保持 merged 不变;push/pr
创建失败时返回 502 并写清哪一步失败,任务同样保持 merged。
成功时把 pr_url/pr_number 写回任务并记审计 pr_created。
App-token:项目 repo_url 能解析出 github.com 仓库名且
库里有该仓库的 App 安装记录时,现换 installation token
走 App 路径(响应里 auth_via=app),否则走原来的 gh +
PAT 路径(auth_via=pat);换 token 任一步失败都回落
PAT,不因此报错。token 只在本次请求内存里出现。
"""
row = self._fetch_task(task_id)
if row is None:
@@ -1525,16 +1532,39 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
conn = self._db()
try:
prow = conn.execute(
"SELECT auto_pr FROM projects WHERE id = ?",
"SELECT auto_pr, repo_url FROM projects WHERE id = ?",
(task["project_id"],)).fetchone()
finally:
conn.close()
auto_pr = 0
repo_url = None
if prow is not None:
try:
auto_pr = prow["auto_pr"] or 0
except (KeyError, IndexError, TypeError):
auto_pr = 0
try:
repo_url = prow["repo_url"]
except (KeyError, IndexError, TypeError):
repo_url = None
app_token = None
auth_via = "pat"
repo_full_name = None
if repo_url:
parsed = parse_git_remote_owner_repo(repo_url)
if parsed:
repo_full_name = "%s/%s" % parsed
if repo_full_name:
conn = self._db()
try:
app_token = resolve_app_token(
conn, repo_full_name,
http_client=getattr(
self.server, "pr_app_token_client", None))
finally:
conn.close()
if app_token:
auth_via = "app"
try:
result = create_task_pr(
task_id, auto_pr,
@@ -1542,7 +1572,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self.server, "host_worktrees_dir", None),
git_runner=getattr(self.server, "pr_git_runner", None),
gh_runner=getattr(self.server, "pr_gh_runner", None),
gh_which=getattr(self.server, "pr_gh_which", None))
gh_which=getattr(self.server, "pr_gh_which", None),
app_token=app_token,
github_api=getattr(self.server, "pr_github_api", None))
except PrSkipped as exc:
self._send_json(200, {
"task_id": task_id, "status": task["status"],
@@ -1575,7 +1607,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
result["pr_url"])
self._send_json(200, dict(task, pr_url=result["pr_url"],
pr_number=result["pr_number"],
branch=result["branch"]))
branch=result["branch"],
auth_via=auth_via))
def _heartbeat_task(self, user_id, task_id):
row = self._fetch_task(task_id)