"""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// 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()