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

+248
View File
@@ -0,0 +1,248 @@
"""POST /tasks/{id}/pr 的 App-token 接线单测(端点级):
App 路径:项目 repo_url 解析出仓库名、库里有该仓库的 App
安装记录 -> 现换 installation token(fake 换 token 客户端 +
测试专用 pem,JWT 纯标准库签发、不联网)-> push 走
credential helper、建 PR 走 GitHub API(fake),响应里
auth_via=app。
PAT 回落:没有安装记录 -> 走原来的 gh 路径,auth_via=pat。
git/GitHub API 一律 fake,不跑真实命令;token 值绝不出现
在 git 命令记录里。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.db import connect as _db_connect
from cloud.github_app import (
record_installation_repos,
save_app_config,
)
from cloud.server import create_server
from cloud.tests.test_app_token import TEST_KEY_PKCS1
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.db import connect as _db_connect
from cloud.github_app import (
record_installation_repos,
save_app_config,
)
from cloud.server import create_server
from cloud.tests.test_app_token import TEST_KEY_PKCS1
def _result(returncode=0, stdout="", stderr=""):
return SimpleNamespace(returncode=returncode, stdout=stdout,
stderr=stderr)
class _Git:
"""假 git:remote/branch/add/commit/push,记录命令。"""
def __init__(self, remote, branch):
self.calls = []
self.remote = remote
self.branch = branch
def __call__(self, cmd, cwd):
self.calls.append(list(cmd))
if "remote" in cmd:
return _result(0, self.remote + "\n")
if "branch" in cmd:
return _result(0, self.branch + "\n")
if "add" in cmd:
return _result(0, "")
if "commit" in cmd:
return _result(1, "",
"nothing to commit, working tree clean")
if "push" in cmd:
return _result(0, "")
return _result(0, "")
class _Gh:
"""假 gh:auth 通过、pr create 回链接。"""
def __init__(self, pr_url):
self.calls = []
self.pr_url = pr_url
def __call__(self, cmd, cwd):
self.calls.append(list(cmd))
if cmd[:2] == ["gh", "auth"]:
return _result(0, "", "")
if cmd[:2] == ["gh", "pr"]:
return _result(0, self.pr_url + "\n")
return _result(0, "")
class _Api:
"""假 GitHub API:GET 仓库回 default_branch,POST pulls 回 PR。"""
def __init__(self):
self.calls = []
def __call__(self, method, url, token, payload):
self.calls.append((method, url, token, payload))
if method == "GET":
return 200, {"default_branch": "main"}
if method == "POST":
return 201, {"html_url": "https://github.com/octo/a/pull/77",
"number": 77}
raise AssertionError("unexpected API call: %s %s" % (method, url))
def _token_client(method, url, headers, body):
"""假换 token:JWT 已在前面签好(只验格式),直接回 token。"""
assert method == "POST", method
assert "/access_tokens" in url, url
assert headers["Authorization"].startswith("Bearer "), headers
return 201, {"token": "srv-app-token",
"expires_at": "2030-01-01T00:00:00Z",
"permissions": {"contents": "write"},
"repository_selection": "selected"}
class _Case(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
self.worktrees = os.path.join(self.tmp.name, "worktrees")
os.makedirs(self.worktrees, exist_ok=True)
self.server.host_worktrees_dir = self.worktrees
self.git = None
self.api = _Api()
self.server.pr_github_api = self.api
self.server.pr_app_token_client = _token_client
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _project(self, name, repo_url):
status, project = self._req(
"POST", "/projects",
{"name": name, "repo_url": repo_url, "auto_pr": 1},
self.owner_key)
self.assertEqual(status, 201, project)
return project["id"]
def _task(self, task_id, project_id):
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" depends_on, priority, created_by, created_at,"
" updated_at, last_heartbeat) VALUES(?, ?, ?, 'merged',"
" '[]', 0, 'system', 0, 0, NULL)",
(task_id, project_id, "do it"))
conn.commit()
finally:
conn.close()
os.makedirs(os.path.join(self.worktrees, task_id), exist_ok=True)
def test_app_path_via_endpoint(self):
project_id = self._project(
"octo/a", "https://github.com/octo/a")
conn = _db_connect(self.db_path)
try:
save_app_config(conn, {
"app_id": 5476837, "slug": "fake-app",
"client_id": "Iv1.x", "webhook_secret": "fake-secret",
"pem": TEST_KEY_PKCS1,
"html_url": "https://github.com/apps/fake-app"})
record_installation_repos(conn, 175380082, "octo",
["octo/a"])
finally:
conn.close()
self.git = _Git("https://github.com/octo/a.git", "host/t-app-1")
self.server.pr_git_runner = self.git
self._task("t-app-1", project_id)
status, body = self._req(
"POST", "/tasks/t-app-1/pr", key=self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["pr_url"],
"https://github.com/octo/a/pull/77")
self.assertEqual(body["pr_number"], 77)
self.assertEqual(body["auth_via"], "app")
pushes = [c for c in self.git.calls if "push" in c]
self.assertEqual(len(pushes), 1)
self.assertTrue(any(a.startswith("credential.helper=")
for a in pushes[0]))
self.assertNotIn("srv-app-token",
" ".join(" ".join(c) for c in self.git.calls))
posts = [c for c in self.api.calls if c[0] == "POST"]
self.assertEqual(len(posts), 1)
self.assertEqual(posts[0][2], "srv-app-token")
def test_pat_fallback_via_endpoint(self):
project_id = self._project(
"octo/b", "https://github.com/octo/b")
self.git = _Git("https://github.com/octo/b.git", "host/t-pat-1")
self.server.pr_git_runner = self.git
gh = _Gh("https://github.com/octo/b/pull/88")
self.server.pr_gh_runner = gh
self.server.pr_gh_which = "/usr/bin/gh"
self._task("t-pat-1", project_id)
status, body = self._req(
"POST", "/tasks/t-pat-1/pr", key=self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["pr_url"],
"https://github.com/octo/b/pull/88")
self.assertEqual(body["auth_via"], "pat")
pushes = [c for c in self.git.calls if "push" in c]
self.assertEqual(len(pushes), 1)
self.assertFalse(any(a.startswith("credential.helper=")
for a in pushes[0]))
self.assertEqual(self.api.calls, [])
if __name__ == "__main__":
unittest.main()