GitHub App 一键安装:manifest 建 App、安装记录、双 secret 验签、/fix-review 内容去重
This commit is contained in:
1 parent
ecf4b5c715
commit
32059178bd
5 files changed
+1210
-14
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user