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

+244
View File
@@ -0,0 +1,244 @@
"""App-token 单测:纯标准库 RS256 签名、JWT、installation token
换取、仓库安装查找、resolve 回落。
fixture 是测试专用 RSA key(openssl 现生成、仅供单测,从不用
于生产;即使公开也无害——它不对应任何真实 GitHub App)。
不联网:换 token 挂 fake http 客户端。
"""
import base64
import hashlib
import json
import sys
import tempfile
import os
import unittest
from pathlib import Path
try:
from cloud.db import connect as _db_connect
from cloud.github_app import (
AppError,
_parse_rsa_private_key,
exchange_installation_token,
find_installation_for_repo,
mint_app_jwt,
record_installation_repos,
resolve_app_token,
rsa_sign_sha256,
save_app_config,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.db import connect as _db_connect
from cloud.github_app import (
AppError,
_parse_rsa_private_key,
exchange_installation_token,
find_installation_for_repo,
mint_app_jwt,
record_installation_repos,
resolve_app_token,
rsa_sign_sha256,
save_app_config,
)
# 测试专用 RSA 私钥(PKCS#1 与 PKCS#8,同一密钥对的两版编码
# 是两把不同 key——各测各的格式解析,不要求同模)。
TEST_KEY_PKCS1 = """
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEA2M/hYSQQAJxQfcLxcRob7cq4miMLbyWv+Diq4qppENU8XvNS
7p/JSc8XqWRcGX6FusObKjpskakEL1+tf2wBYGW5jbtgiHzg4J46p+iK/OFi9t+8
O6wjva3+5kWrjvkQIKL/pbGfdvVQjq1j5bg2fbXKbQqb7CbbhxDhRrW3xVt5A+PF
G3+iP0qHcBvWuN1CIUHVvUORSdkY44+8xCs+RjalMqVsOyPzTuMu+ypfPtaHfcGM
DWwkvpXUwq9VrszfrGqi3qWJ6nBQy20eXRUeEnorDS7Rolj+/bILhtbUUT/wFzX7
F/er1zquAQSntgiMUutB50zpPefNe5Jf/uknZwIDAQABAoIBAEK9izVp8nsdozFz
ZvtX/Czw8G6SRxzJzydr2j64EmuaGGFqKadHDKFp3HDKcgdvCR96wzL2lSEplB45
J6FH0Ayqg7n7ajXZqye5zXiOfuplPx+bs8fyr3XeL88Wtz7lsG87fdK1oM7vqQKi
oVO/pxZ/dXang2Poej9TlOU9XvqjIoTo4HNckRhEF7MJV+2nQB/d+edlSt9oLrN4
UxOEBeTEomj+ujJBWFOasOcztRXMl/cQp3PdSZ5Ap4aXElblK5/MocM+tyh6RU++
hLVBpxxjH2CQbG+HMn0rxn7NbtpBdYU3GTjBdKJUv0MQ9m8s39AFfX4h1YW7PCte
uO0tyfECgYEA+pwlgTWnQL5dlwkw+qsg4mhiG0XcDFsfrh6W/TcsUJnfQIGZCJnC
9NxCJEmfQTxgJWEmTvPj+Mi4iFmoMYlfTo1OqI36pXNnH0fmh4l0l9COAUsarQVJ
DzDh5LaCpOn8uSfgJSzdoBY4aYkxJfl8AVsApCmHBojp9Xjw1gis1csCgYEA3Xmk
qm1BeGkUU/nGDCjz6AuIYEbWuj7OwnV/EsvsGsMTDryoWsk0ZTI0KJdyI/RhjMdM
jnv37EOy8YL1NjD01NinQ7Ac1wY1DSUrA+jJ6ctSkjkPFHKVBmiC2c5TJiTXWxd6
Ms7fGQesIrdSdbr7+GrkogKCJyKEwskcRNsFIVUCgYAVqKkVIa3yyoEMLJ8vCtss
thjEgqUh7D4wrjQebkZGMQY6bPjs5V0UDHUq7bH10WH92QeWiz2IotBP+G+w2E6E
W3IQ3zRqWC7xZSVnUVniRQQA2JzVlz9ZMMBVI/Dmj9JnriqRC++bhlAZzuGQhp9Q
/qxvg6dHyU1vCaTG2rkVQQKBgGrkviwfhVznYo9RW1wdG/OgNVc6eYL0s3g5m3Zo
A/G6u2T+e0cuG2LIQ1JgHg/qcHaw7mtKvBNFbYWRTma0ZEaitsA9rP2/GjXY/ZTq
cyGrJ5Q5g+S7lN+964HSSmF7oJFvmGQTYqxC/rrWvi7ia6CvfuAO0+CtkWQuCr3U
8/ntAoGAawnmD9G1JVUSo1ONbV2gsg/Lv6SSq0dbWja4B0fhAR4BWtSFQV8ePTwk
Q5bSZc/zdNHcKPIMr29Aa3TTy7jXHkAs2frAwKGRyErt/cVAbah+BLOWxniTNfgD
6CvUI8gTLJ+Dklmer2o6b0JZEeSxz93xx7sy9E5SqHGStOZRutA=
-----END RSA PRIVATE KEY-----
"""
TEST_KEY_PKCS8 = """
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCgshr1g7WzZ/iF
tXbL+R2tTkzOsKVWeLli7Xe1+5W9jz+mEd/zWl/AD/QuM0ncNNkOQfyCVKgtzX4p
k2yerf4QtdbwvVP36aqs6D0wH1AqkNfC338bbN1eNPNtZ2sG8muAppv4hjUBT7ck
p3hrQ9VU596PFloYlM6M91mkIWGX76oig+pPSL62P82OwYbM3dBKIJsQaAEWbqf6
V08D4MOUubt8zcS3FIpztLKQ+NPrzWGoPQoaHx0cruDZLWu2RdUTav4S3G0BLJFh
TzekF6KGIUC/CdB6uOKFGobJxr9Pz/ZhiM/3PE3KBZXWeNS/+lerV2JB3MAAL2zc
oPTHbUyXAgMBAAECggEADSAETVmeItKoUlIhgK8kCjkzKHigxdYjcjM/dq40q0sY
IZoj0WO9dARO5zfKMxcKmXxbQaaPe/o6RpljeExxdsTwvYYBOzurHzydbsudR8oV
IrYQKC8f9Odcm0Akq5MWTmeNUA200PAatH7h5NpMAjIpaUTOmWO3a9JyJb086Y9e
Tv6Em0iNjZiyfuTz8q3BRcuZvwdXN3ZJVgTqJTN4iUfKmSbhDieIKHw1NieYbimD
/LNyt6OMcZiU5RKvujY+kGLJBhJ5DO3/yl7+DfHeQjHrnyrddB2O8IwqBZp6wcmS
5zTGX3vqclMsZCv2mKoVdWA3QlbDc36N7wumr9nYPQKBgQDbNsFGAaQ26bEsp37H
ixegd9vhqeO8qFuTDMqNiKVs6t0ZV8PUmI0pB8ZzXxtWRiolUXqxZiqvilzhRz4m
B/1lYKmljpSJdL4fX+t65MNW4pY7S/bqd/5Rt4j+DA77Ez9lcbVH9LwpWrhMXFvU
+qyFVglNq7vrDN82FHkrDVf3dQKBgQC7qXWE2hDMZ7CerNQu06GSLehQ65YBtRg/
oQ62dcpEv7vxVn24fzwtMBFlmt3WY+N9Vr8O9dxuiX7PmBOIf1P3BW+qLU+bVExc
AQychjKoHRsimkqDaX8XqjJkA7fD56zahLKLShKwmUdWoNvM9Vf8jokO7Ky6/Ckk
8DR1YRI+WwKBgCJ3OWwIVglMNM8Qhp5ZAB36uleAno3K6pr0J+SY12mIg7q4UdHb
KlaJImqBSYsG/Rlg9iluJsfyuSeyS9Mu7mhCCmo9QJ1b/YhxTFTGIvBGL9ii2O+E
bjfq418OBkzzecQ5XIczs2HNIazf2JBj2TwditjrZ2BNZcra7TIXpv0BAoGAFhyP
dknjdNI641dl4OaawQibuP0pPaZC5yxo+WLDpHobAQK56YVEZ+akBh7LSLmYUmpo
NLEPeg7FXFyE4K5wDEkdsSWJnbm/DZAqFj9RtLFJQzh+0yEZEobBTTuK6ZBgs5az
GuptbVWCLFjjzTCTX3TD/Xgxmg6gInQTGkmHxsUCgYB0p7w195Q+/fUhdCSnvwNQ
VlrX5EBjvjkPZFQkXNCFoeIhQuADyQQJ7P9HHlrxSjHsbZOxTTcVQatm0kk8s6Et
LAxPD9wtwVxzZHHVXCK+l8JNfBmiW14n6BQvkTlGgt1HFojrx4ubz86VwgP8FMuW
Qq9ELI2S82qfw0VlsoZWxQ==
-----END PRIVATE KEY-----
"""
def _b64url_decode(s):
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def _emsa_sha256(message, em_len):
prefix = bytes.fromhex("3031300d060960864801650304020105000420")
t = prefix + hashlib.sha256(message).digest()
return b"\x00\x01" + b"\xff" * (em_len - len(t) - 3) + b"\x00" + t
def _rsa_verify(pem, message, signature):
"""纯 Python RSA 公钥验签(与签名实现独立的验算)。"""
n, e, _d = _parse_rsa_private_key(pem)
em_len = (n.bit_length() + 7) // 8
m = pow(int.from_bytes(signature, "big"), e, n)
return m.to_bytes(em_len, "big") == _emsa_sha256(message, em_len)
class Rs256TestCase(unittest.TestCase):
def test_sign_and_verify_pkcs1(self):
message = b"github app jwt signing input"
sig = rsa_sign_sha256(TEST_KEY_PKCS1, message)
n, _e, _d = _parse_rsa_private_key(TEST_KEY_PKCS1)
self.assertEqual(len(sig), (n.bit_length() + 7) // 8)
self.assertTrue(_rsa_verify(TEST_KEY_PKCS1, message, sig))
def test_sign_and_verify_pkcs8(self):
message = b"another signing input"
sig = rsa_sign_sha256(TEST_KEY_PKCS8, message)
self.assertTrue(_rsa_verify(TEST_KEY_PKCS8, message, sig))
self.assertFalse(_rsa_verify(TEST_KEY_PKCS8, b"tampered", sig))
def test_bad_key(self):
with self.assertRaises(AppError):
rsa_sign_sha256("not a key", b"x")
with self.assertRaises(AppError):
rsa_sign_sha256("-----BEGIN FOO-----\nAAAA\n-----END FOO-----",
b"x")
class JwtTestCase(unittest.TestCase):
def test_mint_structure_and_signature(self):
jwt = mint_app_jwt("5476837", TEST_KEY_PKCS1, now=1000000)
parts = jwt.split(".")
self.assertEqual(len(parts), 3)
header = json.loads(_b64url_decode(parts[0]))
self.assertEqual(header["alg"], "RS256")
payload = json.loads(_b64url_decode(parts[1]))
self.assertEqual(payload["iss"], "5476837")
self.assertEqual(payload["iat"], 1000000 - 60)
self.assertEqual(payload["exp"], 1000000 + 540)
signing_input = (parts[0] + "." + parts[1]).encode("ascii")
self.assertTrue(_rsa_verify(
TEST_KEY_PKCS1, signing_input, _b64url_decode(parts[2])))
def _fake_exchange(expected_installation="175380082"):
def fake(method, url, headers, body):
assert method == "POST", method
assert "/app/installations/%s/access_tokens" % expected_installation in url, url
assert headers["Authorization"].startswith("Bearer "), headers
assert headers["Authorization"].count(".") == 2
return 201, {"token": "fake-installation-token",
"expires_at": "2030-01-01T00:00:00Z",
"permissions": {"contents": "write"},
"repository_selection": "selected"}
return fake
class TokenExchangeTestCase(unittest.TestCase):
def test_exchange_ok(self):
result = exchange_installation_token(
"5476837", TEST_KEY_PKCS1, "175380082",
http_client=_fake_exchange())
self.assertEqual(result["token"], "fake-installation-token")
self.assertEqual(result["repository_selection"], "selected")
def test_exchange_failure_raises(self):
def fake(method, url, headers, body):
return 401, {"message": "Bad credentials"}
with self.assertRaises(AppError) as ctx:
exchange_installation_token(
"5476837", TEST_KEY_PKCS1, "175380082", http_client=fake)
self.assertEqual(ctx.exception.reason, "token_exchange_failed")
class ResolveTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.conn = _db_connect(self.db_path)
def tearDown(self):
self.conn.close()
self.tmp.cleanup()
def _save_config(self):
save_app_config(self.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"})
def test_resolve_full_chain(self):
self._save_config()
record_installation_repos(self.conn, 175380082, "octo",
["octo/a"])
token = resolve_app_token(self.conn, "octo/a",
http_client=_fake_exchange())
self.assertEqual(token, "fake-installation-token")
def test_resolve_falls_back_to_none(self):
# 无配置、无安装、换 token 失败:一律 None(调用方走 PAT)。
self.assertIsNone(resolve_app_token(
self.conn, "octo/a", http_client=_fake_exchange()))
self._save_config()
self.assertIsNone(resolve_app_token(
self.conn, "octo/a", http_client=_fake_exchange()))
record_installation_repos(self.conn, 175380082, "octo",
["octo/a"])
def failing(method, url, headers, body):
return 401, {"message": "Bad credentials"}
self.assertIsNone(resolve_app_token(
self.conn, "octo/a", http_client=failing))
def test_find_installation(self):
record_installation_repos(self.conn, 11, "octo", ["octo/a"])
self.assertEqual(find_installation_for_repo(self.conn, "octo/a"),
"11")
self.assertIsNone(find_installation_for_repo(self.conn, "octo/b"))
if __name__ == "__main__":
unittest.main()
+176
View File
@@ -0,0 +1,176 @@
"""pr.py 的 App-token 路径单测:push 走 credential helper、建 PR
走 GitHub API、gh 完全不被调用;remote 解析与失败分支。
不联网、不跑真实 git/gh:git 用假 runner,GitHub API 用假
客户端;token 值绝不出现在记录的命令里(只出现变量名)。
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.pr import (
PrStepError,
check_pr_ready,
create_task_pr,
parse_git_remote_owner_repo,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.pr import (
PrStepError,
check_pr_ready,
create_task_pr,
parse_git_remote_owner_repo,
)
def _result(returncode=0, stdout="", stderr=""):
return SimpleNamespace(returncode=returncode, stdout=stdout,
stderr=stderr)
class _Git:
"""假 git runner:remote/branch/add/commit/push 全按脚本回。"""
def __init__(self, remote="https://github.com/octo/a.git"):
self.calls = []
self.remote = remote
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, "host/t1\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, "")
def _gh_forbidden(cmd, cwd):
raise AssertionError("App 路径不应调用 gh:%r" % (cmd,))
class _FakeGithubApi:
"""假 GitHub API:记录调用,GET 仓库回 default_branch,
POST pulls 回 PR 链接;失败模式可配。"""
def __init__(self, default_branch="main", pulls_status=201):
self.calls = []
self.default_branch = default_branch
self.pulls_status = pulls_status
def __call__(self, method, url, token, payload):
self.calls.append((method, url, token, payload))
if method == "GET" and url.endswith("/repos/octo/a"):
return 200, {"default_branch": self.default_branch}
if method == "POST" and url.endswith("/repos/octo/a/pulls"):
if self.pulls_status != 201:
return self.pulls_status, {"message": "Validation Failed"}
return 201, {"html_url": "https://github.com/octo/a/pull/42",
"number": 42}
raise AssertionError("unexpected API call: %s %s" % (method, url))
def _worktree(tmp):
d = os.path.join(tmp, "t1")
os.makedirs(d, exist_ok=True)
return d
class ParseRemoteTestCase(unittest.TestCase):
def test_variants(self):
self.assertEqual(
parse_git_remote_owner_repo(
"https://github.com/octo/a.git"), ("octo", "a"))
self.assertEqual(
parse_git_remote_owner_repo(
"https://github.com/octo/a"), ("octo", "a"))
self.assertEqual(
parse_git_remote_owner_repo(
"https://user@github.com/octo/a.git"), ("octo", "a"))
self.assertEqual(
parse_git_remote_owner_repo(
"git@github.com:octo/a.git"), ("octo", "a"))
self.assertEqual(
parse_git_remote_owner_repo(
"ssh://git@github.com/octo/a.git"), ("octo", "a"))
def test_non_github_is_none(self):
self.assertIsNone(parse_git_remote_owner_repo(
"https://example.com/octo/a.git"))
self.assertIsNone(parse_git_remote_owner_repo(""))
self.assertIsNone(parse_git_remote_owner_repo(None))
class AppPathTestCase(unittest.TestCase):
def test_push_uses_credential_helper_and_pr_via_api(self):
with tempfile.TemporaryDirectory() as tmp:
_worktree(tmp)
git = _Git()
api = _FakeGithubApi()
result = create_task_pr(
"t1", 1, worktrees_root=tmp, git_runner=git,
gh_runner=_gh_forbidden, gh_which="/nonexistent/gh",
app_token="fake-app-token", github_api=api)
self.assertEqual(
result["pr_url"], "https://github.com/octo/a/pull/42")
self.assertEqual(result["pr_number"], 42)
self.assertEqual(result["branch"], "host/t1")
pushes = [c for c in git.calls if "push" in c]
self.assertEqual(len(pushes), 1)
helper = [a for a in pushes[0]
if a.startswith("credential.helper=")][0]
self.assertIn("$GIT_TOKEN", helper)
self.assertNotIn("fake-app-token", " ".join(pushes[0]))
posts = [c for c in api.calls if c[0] == "POST"]
self.assertEqual(len(posts), 1)
self.assertEqual(posts[0][2], "fake-app-token")
self.assertEqual(posts[0][3]["head"], "host/t1")
self.assertEqual(posts[0][3]["base"], "main")
self.assertEqual(posts[0][3]["title"], "cloud: t1")
def test_app_path_skips_gh_checks(self):
# gh_which 指到不存在的路径、gh_runner 会炸:App 路径下
# check_pr_ready 仍应通过(只查 worktree + remote)。
with tempfile.TemporaryDirectory() as tmp:
_worktree(tmp)
self.assertTrue(check_pr_ready(
os.path.join(tmp, "t1"), git_runner=_Git(),
gh_which="/nonexistent/gh", gh_runner=_gh_forbidden,
app_token="x"))
def test_non_github_remote_fails_at_push(self):
with tempfile.TemporaryDirectory() as tmp:
_worktree(tmp)
with self.assertRaises(PrStepError) as ctx:
create_task_pr(
"t1", 1, worktrees_root=tmp,
git_runner=_Git("https://example.com/octo/a.git"),
gh_runner=_gh_forbidden, app_token="x",
github_api=_FakeGithubApi())
self.assertEqual(ctx.exception.step, "push")
def test_api_failure_is_pr_create_step(self):
with tempfile.TemporaryDirectory() as tmp:
_worktree(tmp)
with self.assertRaises(PrStepError) as ctx:
create_task_pr(
"t1", 1, worktrees_root=tmp, git_runner=_Git(),
gh_runner=_gh_forbidden, app_token="x",
github_api=_FakeGithubApi(pulls_status=422))
self.assertEqual(ctx.exception.step, "pr_create")
if __name__ == "__main__":
unittest.main()
+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()