GitHub App 一键安装:manifest 建 App、安装记录、双 secret 验签、/fix-review 内容去重

This commit is contained in:
host committed 2026-10-26 16:02:45 +08:00
1 parent ecf4b5c715
commit 32059178bd
5 files changed
+1210 -14

No files matched your search

+412
View File
@@ -0,0 +1,412 @@
"""GitHub App 一键安装:manifest 建 App + 安装记录 + App webhook。
范围(诚实边界):本模块只做“安装”这一半——manifest 生成、
建 App 回调换凭证、安装事件记录、App webhook 验签。App 的
pem 虽然存下来了,但本模块不用它签 JWT、不用它换 installation
token:推分支、建 PR 目前仍走 gh + PAT(见 cloud/pr.py),
App-token API 动作是下一阶段,不在本模块假装已做。
流程(GitHub manifest 流程,见 GitHub 文档):
POST /github-app/setup/state(持本站 key)-> state + setup_url
-> 用户打开 setup 页(表单含 manifest JSON + state)点提交
-> GitHub 建 App 页(用户确认 Create)
-> GitHub 跳回 /github-app/callback?code=...&state=...
-> server 验 setup state(一次性、1 小时过期)-> 换 code
(POST api.github.com/app-manifests/{code}/conversions,
无需认证,1 小时内有效)-> 存 App 配置
-> 成功页给安装链接(https://github.com/apps/<slug>/
installations/new),用户点安装 -> installation 事件
到 /webhooks/github -> 记安装记录。
凭证存放:App 配置存数据库 github_app_config 表(单行),
不进仓库、不进日志、不出现在任何 API 响应里;webhook_secret
与 pem 只在 server 进程内读(验签用 secret,JWT 阶段用 pem)。
与 env 里 GITHUB_WEBHOOK_SECRET 的关系:手动 webhook 仍用
env secret,App webhook 用库里 secret,验签时两个都试(过渡
期共存);App 装好后应删掉手动 webhook,避免同一事件送两
遍(/fix-review 另有内容去重兜底,见 claim_fix_review)。
零第三方依赖,只用 Python 标准库;换 code 与 userinfo 均可
注入 fake http 客户端做单测。
"""
import json
import secrets
import time
import urllib.request
from cloud.db import connect as _db_connect
# setup state 有效期:与 manifest code 的 1 小时对齐。
SETUP_STATE_TTL_SECONDS = 3600
# manifest 默认值:App 名与主页;hook / 回调地址由部署的公网
# 域名(GITHUB_APP_PUBLIC_BASE)拼出,不硬编码 diante.me。
DEFAULT_APP_NAME = "codex-app-server"
DEFAULT_APP_URL = "https://github.com/openqj/codex-app-server"
# App 权限与事件:与当前 PAT 能力对齐(读仓库、推分支、建 PR、
# 读 issue),不多要;webhook 只订阅 /fix-review 与 PR 相关事件。
APP_PERMISSIONS = {
"contents": "write",
"pull_requests": "write",
"issues": "read",
}
APP_EVENTS = ["issue_comment", "pull_request"]
class AppError(Exception):
"""App 流程失败:换 code 失败、响应缺字段等。
reason 取值:``exchange_failed`` / ``config_incomplete`` /
``state_invalid`` / ``state_expired``。
"""
def __init__(self, message, reason=None):
super().__init__(message)
self.reason = reason
def build_app_manifest(public_base, app_name=DEFAULT_APP_NAME,
app_url=DEFAULT_APP_URL, public=False):
"""拼 manifest dict:GitHub 建 App 表单直接提交这份 JSON。
public=False 是单租户部署的默认(只有 App 所有者能安装);
多租户 SaaS 形态才需要 public=True(改环境变量翻转)。
"""
if not public_base or not isinstance(public_base, str):
raise ValueError("public_base required")
base = public_base.rstrip("/")
return {
"name": app_name,
"url": app_url,
"hook_attributes": {"url": base + "/webhooks/github"},
"redirect_url": base + "/github-app/callback",
"callback_urls": [base + "/github-app/callback"],
"public": bool(public),
"default_permissions": dict(APP_PERMISSIONS),
"default_events": list(APP_EVENTS),
}
class SetupStateStore:
"""setup state 存储:一次性、1 小时过期,防回调被冒用。
表 github_app_setup_states:state(主键)、created_at。
state 只由持本站 key 调 POST /github-app/setup/state 的
操作方签发;回调验到即删,同一 state 第二次回调必然失败。
"""
def __init__(self, db_path):
self.db_path = str(db_path)
self.conn = _db_connect(self.db_path)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS github_app_setup_states("
"state TEXT PRIMARY KEY, created_at REAL)"
)
self.conn.commit()
def create_state(self):
state = secrets.token_urlsafe(32)
self.conn.execute(
"INSERT INTO github_app_setup_states(state, created_at)"
" VALUES(?, ?)",
(state, time.time()),
)
self.conn.commit()
return state
def consume_state(self, state, now=None,
ttl_seconds=SETUP_STATE_TTL_SECONDS):
"""校验并消耗 state:成功返回 True,失败返回 False。
不存在 -> False;过期 -> 删行并返回 False;成功 -> 删行
并返回 True。``now`` 只为单测注入时间。
"""
if not state:
return False
if now is None:
now = time.time()
row = self.conn.execute(
"SELECT created_at FROM github_app_setup_states"
" WHERE state = ?",
(state,),
).fetchone()
if row is None:
return False
self.conn.execute(
"DELETE FROM github_app_setup_states WHERE state = ?",
(state,),
)
self.conn.commit()
if now > row[0] + ttl_seconds:
return False
return True
def _urllib_post_json(url, headers):
"""默认的换 code 实现:POST 空 body,返回解析后的 dict。"""
request = urllib.request.Request(url, data=b"", method="POST",
headers=headers)
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read().decode("utf-8", "replace")
try:
parsed = json.loads(raw)
except ValueError as exc:
raise AppError("manifest conversion response is not JSON: %s"
% (exc,), reason="exchange_failed") from exc
if not isinstance(parsed, dict):
raise AppError("manifest conversion response is not an object",
reason="exchange_failed")
return parsed
def exchange_manifest_code(code, http_client=None):
"""用 manifest code 换 App 配置:返回 {id, slug, client_id,
webhook_secret, pem, html_url}(缺关键字段即 AppError)。
http_client 注入约定(单测用):``http_client(url, headers)
-> dict``。真实路径无需认证(code 本身就是一次性凭证),
1 小时内必须完成。
"""
if not code or not isinstance(code, str):
raise AppError("code required", reason="exchange_failed")
url = "https://api.github.com/app-manifests/%s/conversions" % code
headers = {"Accept": "application/vnd.github+json",
"User-Agent": "codex-app-server"}
client = http_client if http_client is not None else _urllib_post_json
try:
result = client(url, headers)
except AppError:
raise
except Exception as exc:
raise AppError(
"manifest conversion failed: %s" % (exc,),
reason="exchange_failed",
) from exc
if not isinstance(result, dict) or result.get("error"):
raise AppError("manifest conversion failed: %r" % (result,),
reason="exchange_failed")
missing = [k for k in ("id", "webhook_secret", "pem")
if not result.get(k)]
if missing:
raise AppError(
"manifest conversion response missing: %s" % ",".join(missing),
reason="config_incomplete")
return {
"app_id": result.get("id"),
"slug": result.get("slug"),
"client_id": result.get("client_id"),
"webhook_secret": result.get("webhook_secret"),
"pem": result.get("pem"),
"html_url": result.get("html_url"),
}
def save_app_config(conn, config):
"""存 App 配置(单行,id 恒为 1;重复 setup 直接覆盖)。
调用方须保证 config 含 webhook_secret 与 pem;本函数不校验
它们的值,只负责落库。secret/pem 绝不出现在返回值里。
"""
conn.execute(
"CREATE TABLE IF NOT EXISTS github_app_config("
"id INTEGER PRIMARY KEY CHECK(id = 1), "
"app_id TEXT, slug TEXT, client_id TEXT, "
"webhook_secret TEXT, pem TEXT, html_url TEXT, created_at REAL)"
)
conn.execute(
"INSERT OR REPLACE INTO github_app_config(id, app_id, slug,"
" client_id, webhook_secret, pem, html_url, created_at)"
" VALUES(1, ?, ?, ?, ?, ?, ?, ?)",
(config.get("app_id"), config.get("slug"),
config.get("client_id"), config.get("webhook_secret"),
config.get("pem"), config.get("html_url"), time.time()),
)
conn.commit()
def get_app_config(conn):
"""取 App 配置 dict,未配置返回 None。
返回里含 webhook_secret 与 pem——调用方只许在 server 进程内
用(验签 / JWT),绝不放进任何 API 响应;公开状态接口须
另外拼不含 secret 的视图。
"""
conn.execute(
"CREATE TABLE IF NOT EXISTS github_app_config("
"id INTEGER PRIMARY KEY CHECK(id = 1), "
"app_id TEXT, slug TEXT, client_id TEXT, "
"webhook_secret TEXT, pem TEXT, html_url TEXT, created_at REAL)"
)
row = conn.execute(
"SELECT app_id, slug, client_id, webhook_secret, pem, html_url,"
" created_at FROM github_app_config WHERE id = 1",
).fetchone()
if row is None:
return None
return {
"app_id": row[0], "slug": row[1], "client_id": row[2],
"webhook_secret": row[3], "pem": row[4], "html_url": row[5],
"created_at": row[6],
}
def public_app_status(conn):
"""公开状态视图:不含 secret/pem,只给配置与否 + 身份信息。"""
config = get_app_config(conn)
if config is None:
return {"configured": False, "app_id": None, "slug": None,
"html_url": None}
return {"configured": True, "app_id": config["app_id"],
"slug": config["slug"], "html_url": config["html_url"]}
def _ensure_installations_table(conn):
conn.execute(
"CREATE TABLE IF NOT EXISTS github_app_installations("
"installation_id TEXT, account_login TEXT, "
"repo_full_name TEXT, created_at REAL, "
"UNIQUE(installation_id, repo_full_name))"
)
conn.commit()
def record_installation_repos(conn, installation_id, account_login, repos):
"""记安装的仓库行(按 (installation_id, repo) 去重,多次
同步不翻倍)。repos 为 full_name 字符串列表。"""
_ensure_installations_table(conn)
now = time.time()
for repo in repos or []:
conn.execute(
"INSERT OR IGNORE INTO github_app_installations("
"installation_id, account_login, repo_full_name, created_at)"
" VALUES(?, ?, ?, ?)",
(str(installation_id), account_login, repo, now),
)
conn.commit()
def remove_installation_repos(conn, installation_id, repos):
"""删安装的部分仓库行(removed 事件用)。"""
_ensure_installations_table(conn)
for repo in repos or []:
conn.execute(
"DELETE FROM github_app_installations"
" WHERE installation_id = ? AND repo_full_name = ?",
(str(installation_id), repo),
)
conn.commit()
def remove_installation(conn, installation_id):
"""删整个安装的全部仓库行(deleted / suspended 后的清理用,
返回删掉的行数)。"""
_ensure_installations_table(conn)
cur = conn.execute(
"DELETE FROM github_app_installations WHERE installation_id = ?",
(str(installation_id),),
)
conn.commit()
return cur.rowcount
def list_installations(conn):
"""列出全部安装行(按 installation_id / repo 排序)。"""
_ensure_installations_table(conn)
rows = conn.execute(
"SELECT installation_id, account_login, repo_full_name, created_at"
" FROM github_app_installations"
" ORDER BY installation_id, repo_full_name",
).fetchall()
return [{"installation_id": r[0], "account_login": r[1],
"repo_full_name": r[2], "created_at": r[3]} for r in rows]
def parse_installation_event(payload):
"""解析 installation 事件,返回动作 dict 或 None。
只认 action 在 created / deleted / added / removed /
new_permissions_accepted 且带 installation 的 payload;其它
事件返回 None(调用方继续走原有解析)。repositories 取
full_name:created 用 payload repositories,added 用
repositories_added,removed 用 repositories_removed。
"""
if not isinstance(payload, dict):
return None
action = payload.get("action")
if action not in ("created", "deleted", "added", "removed",
"new_permissions_accepted"):
return None
installation = payload.get("installation")
if not isinstance(installation, dict):
return None
account = installation.get("account") or {}
def names(key):
out = []
for repo in payload.get(key) or []:
if isinstance(repo, dict) and repo.get("full_name"):
out.append(repo["full_name"])
return out
if action == "added":
repos = names("repositories_added")
elif action == "removed":
repos = names("repositories_removed")
elif action == "deleted":
repos = []
else:
repos = names("repositories")
return {
"action": action,
"installation_id": installation.get("id"),
"account_login": account.get("login"),
"repos": repos,
}
def claim_fix_review(conn, repo, pr_number, comment_id):
""" /fix-review 内容去重:同一仓库 + PR + 评论只建一次任务。
手动 webhook 与 App webhook 共存时,同一评论会送两遍,
delivery_id 不同、内容相同——delivery 去重拦不住,内容去重
才能拦。返回 (is_new, task_id):首次认领 True;已认领过
返回 False 与当初建的 task_id(可能为 None:认领方还没来
得及把 task_id 写回,任务正在建)。comment_id 为 None 时
无法去重,直接放行(不阻塞合法任务)。
"""
conn.execute(
"CREATE TABLE IF NOT EXISTS github_fix_review_dedup("
"repo TEXT, pr_number INTEGER, comment_id TEXT, "
"task_id TEXT, created_at REAL, "
"UNIQUE(repo, pr_number, comment_id))"
)
if comment_id is None:
return True, None
cur = conn.execute(
"INSERT OR IGNORE INTO github_fix_review_dedup("
"repo, pr_number, comment_id, task_id, created_at)"
" VALUES(?, ?, ?, NULL, ?)",
(repo, pr_number, str(comment_id), time.time()),
)
conn.commit()
if cur.rowcount > 0:
return True, None
row = conn.execute(
"SELECT task_id FROM github_fix_review_dedup"
" WHERE repo = ? AND pr_number = ? AND comment_id = ?",
(repo, pr_number, str(comment_id)),
).fetchone()
return False, row[0] if row else None
def set_fix_review_task_id(conn, repo, pr_number, comment_id, task_id):
"""认领后把 task_id 写回内容去重行(供重复送达时回读)。"""
if comment_id is None:
return
conn.execute(
"UPDATE github_fix_review_dedup SET task_id = ?"
" WHERE repo = ? AND pr_number = ? AND comment_id = ?",
(task_id, repo, pr_number, str(comment_id)),
)
conn.commit()