413 lines
15 KiB
Python
413 lines
15 KiB
Python
"""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()
|