Files
codex-app-server/cloud/github_app.py
T

413 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()