5 files changed
+1210
-14
No files matched your search
@@ -159,6 +159,45 @@ client_secret(只走环境变量),不是无认证后门。
|
||||
没有返回 404)后恢复原来的 oauth 账号登录。查看自己的绑定:
|
||||
`GET /oauth/{provider}/bindings`(须持本站 key,只看得到自己的)。
|
||||
|
||||
### GitHub App 一键安装(已实现安装一半;App-token API 动作未做)
|
||||
|
||||
目标:把手动建 webhook(填 URL、secret、选事件)变成“点一下
|
||||
安装”。流程用 GitHub manifest 流程(GitHub 文档标准流程):
|
||||
|
||||
1. 操作方持本站 key 调 `POST /github-app/setup/state`(body 可含
|
||||
org),返回 `{state, setup_url}`;要求环境变量
|
||||
`GITHUB_APP_PUBLIC_BASE`(公网域名,如 `https://api.diante.me`,
|
||||
manifest 里的 hook / 回调地址都由它拼出)。
|
||||
2. 用户打开 setup_url:表单页含 manifest JSON(App 名默认
|
||||
`codex-app-server`,可由 `GITHUB_APP_NAME` / `GITHUB_APP_URL`
|
||||
改;权限 contents:write、pull_requests:write、issues:read,
|
||||
事件 issue_comment、pull_request;默认私有 App,只有所有者
|
||||
能安装,`GITHUB_APP_PUBLIC=1` 可翻成公开)与 state,点提交
|
||||
到 GitHub 建 App 页(个人账号 `/settings/apps/new`,组织账号
|
||||
`/organizations/{org}/settings/apps/new`),用户确认 Create。
|
||||
3. GitHub 跳回 `GET /github-app/callback?code=...&state=...`:
|
||||
server 验 setup state(一次性、1 小时过期,manifest code 同样
|
||||
1 小时内有效)→ POST
|
||||
`api.github.com/app-manifests/{code}/conversions` 换回 id、slug、
|
||||
webhook_secret、pem → 存库里 `github_app_config` 表(单行)。
|
||||
成功页只显示 App 身份与安装链接
|
||||
`https://github.com/apps/<slug>/installations/new`,绝不显示
|
||||
webhook_secret 与 pem。
|
||||
4. 用户点安装:installation 事件到 `/webhooks/github`,记
|
||||
`github_app_installations` 表(created/added 增行、removed 减
|
||||
行、deleted 清安装),`GET /github-app/status`(须持本站 key)
|
||||
可查配置与安装列表,同样不含 secret/pem。
|
||||
|
||||
验签与去重:webhook 验签同时接受 App secret(库里)与 env 里
|
||||
`GITHUB_WEBHOOK_SECRET`(手动 webhook,过渡期共存);App 装好
|
||||
后应删掉手动 webhook,避免同一事件送两遍;即使共存,
|
||||
/fix-review 也有内容去重(repo + PR + 评论 id,
|
||||
`github_fix_review_dedup` 表),只建一次任务。
|
||||
|
||||
诚实边界:pem 虽然存下来了,但本阶段不用它签 JWT、不用它换
|
||||
installation token;推分支、建 PR 仍走 gh + PAT(见“云端派发与
|
||||
自动 PR”),App-token API 动作是下一阶段。
|
||||
|
||||
### 推送凭证清单
|
||||
|
||||
- webhook / slack:需要可注入 sender(worker 侧),HTTP 层没有
|
||||
|
||||
@@ -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()
|
||||
+306
-14
@@ -48,7 +48,7 @@ import uuid
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.cost import CostStore
|
||||
@@ -85,12 +85,30 @@ from cloud.push import (
|
||||
dispatch_event as _push_dispatch,
|
||||
list_subscriptions as _push_list,
|
||||
)
|
||||
from cloud.github_app import (
|
||||
DEFAULT_APP_NAME,
|
||||
DEFAULT_APP_URL,
|
||||
AppError,
|
||||
SetupStateStore,
|
||||
build_app_manifest,
|
||||
claim_fix_review,
|
||||
exchange_manifest_code,
|
||||
get_app_config,
|
||||
list_installations,
|
||||
parse_installation_event,
|
||||
public_app_status,
|
||||
record_installation_repos,
|
||||
remove_installation,
|
||||
remove_installation_repos,
|
||||
save_app_config,
|
||||
set_fix_review_task_id,
|
||||
)
|
||||
from cloud.webhooks import (
|
||||
WebhookDeliveryStore,
|
||||
parse_github_check_run,
|
||||
parse_github_pr_comment,
|
||||
parse_slack_command,
|
||||
verify_github_signature,
|
||||
verify_github_signature_any,
|
||||
verify_slack_signature,
|
||||
)
|
||||
|
||||
@@ -559,6 +577,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
if path.startswith("/github-app/"):
|
||||
# GitHub App 一键安装:setup/state 与 status 须持本站 key
|
||||
# (在处理器内验);setup 表单页与 callback 靠 setup
|
||||
# state 凭证(一次性、1 小时),不走 X-API-Key(浏览器
|
||||
# 回调带不了请求头,state 就是凭证)。
|
||||
if method == "POST" and path == "/github-app/setup/state":
|
||||
self._github_app_setup_state()
|
||||
elif method == "GET" and path == "/github-app/setup":
|
||||
self._github_app_setup_page()
|
||||
elif method == "GET" and path == "/github-app/callback":
|
||||
self._github_app_callback()
|
||||
elif method == "GET" and path == "/github-app/status":
|
||||
self._github_app_status()
|
||||
else:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
if path.startswith("/webhooks/"):
|
||||
# webhook 靠签名认证(外部服务无法持 X-API-Key),不走 API key。
|
||||
if method != "POST":
|
||||
@@ -2042,6 +2076,196 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._send_json(200, {"unbound": True, "provider": provider})
|
||||
|
||||
# -- GitHub App 一键安装 ------------------------------------------
|
||||
def _github_app_public_base(self):
|
||||
base = os.environ.get("GITHUB_APP_PUBLIC_BASE") or ""
|
||||
return base.rstrip("/") or None
|
||||
|
||||
def _github_app_error_page(self, reason, message):
|
||||
"""App 流程错误页:只含 reason 与提示,不含任何 secret、
|
||||
pem、code、state(message 来自流程错误,本身不含密钥,
|
||||
仍做 HTML 转义)。"""
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>GitHub App setup failed</title></head><body>"
|
||||
"<h1>GitHub App setup failed</h1>"
|
||||
"<p>reason: %s</p><p>%s</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(str(reason or "error")),
|
||||
html.escape(str(message))))
|
||||
|
||||
def _github_app_setup_state(self):
|
||||
"""签发 setup state:须持本站 key(只有操作方能发起建 App)。
|
||||
|
||||
要求环境变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
|
||||
https://api.diante.me):manifest 里的 hook / 回调地址
|
||||
都由它拼出。返回 {state, setup_url},setup_url 是用户
|
||||
在浏览器里打开的表单页;state 一次性、1 小时过期。
|
||||
"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
base = self._github_app_public_base()
|
||||
if not base:
|
||||
self._send_json(400, {
|
||||
"error": "GITHUB_APP_PUBLIC_BASE not configured"})
|
||||
return
|
||||
body, err = self._read_json()
|
||||
if err:
|
||||
body = {}
|
||||
org = (body or {}).get("org")
|
||||
store = SetupStateStore(self.server.db_path)
|
||||
try:
|
||||
state = store.create_state()
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
setup_url = base + "/github-app/setup?state=" + state
|
||||
if org:
|
||||
setup_url += "&org=" + quote(str(org))
|
||||
self._send_json(200, {"state": state, "setup_url": setup_url})
|
||||
|
||||
def _github_app_setup_page(self):
|
||||
"""建 App 表单页:公开页,但 state 必须是操作方签发的。
|
||||
|
||||
无 state 或 state 未签发时不渲染表单(state 是回调的
|
||||
唯一凭证,公开页自己现发 state 等于谁都能覆盖 App 配置)。
|
||||
表单按 GitHub manifest 文档 POST 到 github.com 的建 App
|
||||
页(个人账号 /settings/apps/new,组织账号
|
||||
/organizations/{org}/settings/apps/new),manifest JSON
|
||||
由 JS 填进隐藏字段;用户在 GitHub 页确认 Create 后跳回
|
||||
本站 /github-app/callback。
|
||||
"""
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
state = (query.get("state") or [None])[0]
|
||||
org = (query.get("org") or [None])[0]
|
||||
if not state:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"missing_state",
|
||||
"setup state required: ask the operator to issue one"))
|
||||
return
|
||||
base = self._github_app_public_base()
|
||||
if not base:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"not_configured", "GITHUB_APP_PUBLIC_BASE not configured"))
|
||||
return
|
||||
manifest = build_app_manifest(
|
||||
base,
|
||||
app_name=os.environ.get("GITHUB_APP_NAME") or DEFAULT_APP_NAME,
|
||||
app_url=os.environ.get("GITHUB_APP_URL") or DEFAULT_APP_URL,
|
||||
public=os.environ.get("GITHUB_APP_PUBLIC") == "1")
|
||||
manifest_json = json.dumps(manifest).replace("<", "\\u003c")
|
||||
if org:
|
||||
action = ("https://github.com/organizations/%s"
|
||||
"/settings/apps/new?state=%s"
|
||||
% (quote(str(org)),
|
||||
quote(state)))
|
||||
else:
|
||||
action = ("https://github.com/settings/apps/new?state=%s"
|
||||
% quote(state))
|
||||
perms = ", ".join("%s: %s" % kv
|
||||
for kv in manifest["default_permissions"].items())
|
||||
events = ", ".join(manifest["default_events"])
|
||||
page = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>Create GitHub App</title></head><body>"
|
||||
"<h1>Create GitHub App</h1>"
|
||||
"<p>App name: %s</p>"
|
||||
"<p>Webhook URL: %s</p>"
|
||||
"<p>Permissions: %s</p>"
|
||||
"<p>Events: %s</p>"
|
||||
"<p>Click Submit to create the app on GitHub. GitHub will "
|
||||
"redirect back here to finish setup.</p>"
|
||||
"<form action=\"%s\" method=\"post\">"
|
||||
"<input type=\"hidden\" name=\"manifest\" id=\"manifest\">"
|
||||
"<input type=\"submit\" value=\"Create GitHub App\">"
|
||||
"</form>"
|
||||
"<script>document.getElementById(\"manifest\").value = "
|
||||
"JSON.stringify(%s);</script>"
|
||||
"</body></html>"
|
||||
% (html.escape(manifest["name"]),
|
||||
html.escape(manifest["hook_attributes"]["url"]),
|
||||
html.escape(perms), html.escape(events),
|
||||
html.escape(action), manifest_json))
|
||||
self._send_html(200, page)
|
||||
|
||||
def _github_app_callback(self):
|
||||
"""建 App 回调:验 setup state -> 换 code -> 存配置。
|
||||
|
||||
state 一次性、1 小时过期;换 code 失败或响应缺字段时
|
||||
报错误页,不存任何东西。成功页只显示 App 身份(slug、
|
||||
id、主页)与安装链接,绝不显示 webhook_secret 与 pem
|
||||
(两者只在库里,server 进程内用)。
|
||||
"""
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
code = (query.get("code") or [None])[0]
|
||||
state = (query.get("state") or [None])[0]
|
||||
if not code or not state:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"missing_params", "code and state required"))
|
||||
return
|
||||
store = SetupStateStore(self.server.db_path)
|
||||
try:
|
||||
ok = store.consume_state(state)
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if not ok:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
"invalid_state", "setup state invalid or expired"))
|
||||
return
|
||||
http_client = getattr(self.server, "github_app_exchange_client",
|
||||
None)
|
||||
try:
|
||||
config = exchange_manifest_code(code, http_client=http_client)
|
||||
except AppError as e:
|
||||
self._send_html(400, self._github_app_error_page(
|
||||
e.reason or "exchange_failed", str(e)))
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
save_app_config(conn, config)
|
||||
finally:
|
||||
conn.close()
|
||||
slug = config.get("slug") or ""
|
||||
page = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<title>GitHub App created</title></head><body>"
|
||||
"<h1>GitHub App created</h1>"
|
||||
"<p>App: %s</p>"
|
||||
"<p>App ID: %s</p>"
|
||||
"<p>Install it on your repositories:</p>"
|
||||
"<p><a href=\"https://github.com/apps/%s/installations/new\">"
|
||||
"Install this GitHub App</a></p>"
|
||||
"<p>After installing, webhooks from the app are verified "
|
||||
"automatically. API actions (push, PR) still use the "
|
||||
"existing PAT until the App-token phase; you may remove "
|
||||
"the manual webhook once the app is installed.</p>"
|
||||
"</body></html>"
|
||||
% (html.escape(slug),
|
||||
html.escape(str(config.get("app_id"))),
|
||||
html.escape(slug)))
|
||||
self._send_html(200, page)
|
||||
|
||||
def _github_app_status(self):
|
||||
"""App 配置与安装状态:须持本站 key;绝不返回 secret/pem。"""
|
||||
user_id = self._authenticate()
|
||||
if user_id is None:
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
status = public_app_status(conn)
|
||||
status["installations"] = list_installations(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, status)
|
||||
|
||||
# -- 推送订阅与分发 ---------------------------------------------------
|
||||
def _push_create_endpoint(self, user_id, project_id):
|
||||
body, err = self._read_json()
|
||||
@@ -2088,23 +2312,41 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
顺序是故意的:secret 未配 503、签名无效 401(两者都在
|
||||
去重之前,避免未验签的重复送达污染去重表);同一
|
||||
X-GitHub-Delivery 第二次送达直接回 200 duplicate,不
|
||||
重复建任务、不重复记审计。/fix-review 解析成功后在
|
||||
控制面建一个 queued 任务(prompt 含 repo、PR 号、评论
|
||||
原文、作者),由 worker 认领执行——webhook 本身不跑模型。
|
||||
任务归属:按项目名 == repo full_name 匹配项目;没有匹配
|
||||
项目时不凭空建项目、不建任务,返回 task_id 为 None 并
|
||||
说明原因(worker 的 repo 仍是配置里的 repo,不是任意
|
||||
GitHub 仓库的自动克隆,见 README)。check_run /
|
||||
check_suite 只做通知解析,返回解析结果而不建任务、不改
|
||||
云端任务状态(CI 状态回填由 pr-checks 主动拉取)。
|
||||
重复建任务、不重复记审计。验签接受两个 secret:App
|
||||
webhook secret(库里,App 装好后)与 env 里
|
||||
GITHUB_WEBHOOK_SECRET(手动 webhook,过渡期共存;App
|
||||
装好、手动 webhook 删掉后自然不再被命中)。
|
||||
/fix-review 解析成功后在控制面建一个 queued 任务(prompt
|
||||
含 repo、PR 号、评论原文、作者),由 worker 认领执行——
|
||||
webhook 本身不跑模型。任务归属:按项目名 == repo
|
||||
full_name 匹配项目;没有匹配项目时不凭空建项目、不建
|
||||
任务,返回 task_id 为 None 并说明原因(worker 的 repo
|
||||
仍是配置里的 repo,不是任意 GitHub 仓库的自动克隆,
|
||||
见 README)。check_run / check_suite 只做通知解析,
|
||||
返回解析结果而不建任务、不改云端任务状态(CI 状态回填
|
||||
由 pr-checks 主动拉取)。installation 事件记安装记录
|
||||
(created/added 增行、removed 减行、deleted 清安装),
|
||||
不建任务。手动 webhook 与 App webhook 共存时同一评论
|
||||
会送两遍(delivery_id 不同):/fix-review 另有内容去重
|
||||
(repo + PR + 评论 id),只建一次任务。
|
||||
"""
|
||||
raw = self._read_body()
|
||||
secret = os.environ.get("GITHUB_WEBHOOK_SECRET")
|
||||
if not secret:
|
||||
app_secret = None
|
||||
conn = self._db()
|
||||
try:
|
||||
config = get_app_config(conn)
|
||||
if config is not None:
|
||||
app_secret = config.get("webhook_secret")
|
||||
finally:
|
||||
conn.close()
|
||||
secrets = [s for s in
|
||||
(app_secret, os.environ.get("GITHUB_WEBHOOK_SECRET"))
|
||||
if s]
|
||||
if not secrets:
|
||||
self._send_json(503, {"error": "github webhook secret not configured"})
|
||||
return
|
||||
signature = self.headers.get("X-Hub-Signature-256")
|
||||
if not verify_github_signature(secret, raw, signature):
|
||||
if not verify_github_signature_any(secrets, raw, signature):
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
try:
|
||||
@@ -2131,6 +2373,42 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
store.close()
|
||||
except Exception:
|
||||
pass
|
||||
# installation 事件:记安装记录,不建任务。
|
||||
if event == "installation":
|
||||
inst = parse_installation_event(payload)
|
||||
if inst is None:
|
||||
self._send_json(200, {"result": None})
|
||||
return
|
||||
conn = self._db()
|
||||
try:
|
||||
action = inst["action"]
|
||||
if action == "created":
|
||||
record_installation_repos(
|
||||
conn, inst["installation_id"],
|
||||
inst["account_login"], inst["repos"])
|
||||
elif action == "added":
|
||||
record_installation_repos(
|
||||
conn, inst["installation_id"],
|
||||
inst["account_login"], inst["repos"])
|
||||
elif action == "removed":
|
||||
remove_installation_repos(
|
||||
conn, inst["installation_id"], inst["repos"])
|
||||
elif action == "deleted":
|
||||
remove_installation(conn, inst["installation_id"])
|
||||
# new_permissions_accepted:权限已接受,无需改行。
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
auth.audit("system", "", "github_app_installation",
|
||||
"%s:%s" % (inst["installation_id"],
|
||||
inst["action"]))
|
||||
self._send_json(200, {
|
||||
"result": "installation_recorded",
|
||||
"installation_id": inst["installation_id"],
|
||||
"action": inst["action"],
|
||||
"repos": inst["repos"],
|
||||
})
|
||||
return
|
||||
# check_run / check_suite:只解析,不建任务、不改状态。
|
||||
check = parse_github_check_run(payload)
|
||||
if check is not None:
|
||||
@@ -2163,6 +2441,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
error="no matching project for repo"))
|
||||
return
|
||||
project_id = project_row[0]
|
||||
# 内容去重:手动 webhook 与 App webhook 共存时同一评论
|
||||
# 送两遍(delivery_id 不同),只建一次任务;重复送达
|
||||
# 回读当初的 task_id,不重复记审计。
|
||||
comment_id = (payload.get("comment") or {}).get("id")
|
||||
is_new, existing_task_id = claim_fix_review(
|
||||
conn, repo_name, result.get("pr_number"), comment_id)
|
||||
if not is_new:
|
||||
self._send_json(200, dict(
|
||||
result, task_id=existing_task_id,
|
||||
project_id=project_id, duplicate=True))
|
||||
return
|
||||
task_id = uuid.uuid4().hex
|
||||
now = time.time()
|
||||
prompt = (
|
||||
@@ -2196,6 +2485,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
duplicate=True))
|
||||
return
|
||||
raise
|
||||
set_fix_review_task_id(
|
||||
conn, repo_name, result.get("pr_number"), comment_id,
|
||||
task_id)
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as auth:
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""GitHub App 一键安装单测:manifest、setup state、换 code、
|
||||
配置、安装记录、内容去重、双 secret 验签、HTTP 端点。
|
||||
|
||||
不联网:换 code 挂 server.github_app_exchange_client 的 fake;
|
||||
测试里出现的 secret、pem、key 一律是 fake 占位,绝无真实
|
||||
secret、真实 GitHub 账号。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.db import connect as _db_connect
|
||||
from cloud.github_app import (
|
||||
AppError,
|
||||
SetupStateStore,
|
||||
build_app_manifest,
|
||||
claim_fix_review,
|
||||
exchange_manifest_code,
|
||||
get_app_config,
|
||||
list_installations,
|
||||
parse_installation_event,
|
||||
public_app_status,
|
||||
record_installation_repos,
|
||||
remove_installation,
|
||||
remove_installation_repos,
|
||||
save_app_config,
|
||||
set_fix_review_task_id,
|
||||
)
|
||||
from cloud.server import create_server
|
||||
from cloud.webhooks import verify_github_signature_any
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.db import connect as _db_connect
|
||||
from cloud.github_app import (
|
||||
AppError,
|
||||
SetupStateStore,
|
||||
build_app_manifest,
|
||||
claim_fix_review,
|
||||
exchange_manifest_code,
|
||||
get_app_config,
|
||||
list_installations,
|
||||
parse_installation_event,
|
||||
public_app_status,
|
||||
record_installation_repos,
|
||||
remove_installation,
|
||||
remove_installation_repos,
|
||||
save_app_config,
|
||||
set_fix_review_task_id,
|
||||
)
|
||||
from cloud.server import create_server
|
||||
from cloud.webhooks import verify_github_signature_any
|
||||
|
||||
BASE = "https://example.invalid"
|
||||
|
||||
|
||||
def _fake_exchange(result=None):
|
||||
def fake(url, headers):
|
||||
assert url.startswith(
|
||||
"https://api.github.com/app-manifests/"), url
|
||||
return dict(result or {
|
||||
"id": 424242, "slug": "fake-codex-app",
|
||||
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
|
||||
"pem": "-----BEGIN RSA PRIVATE KEY-----\nfake-pem\n"
|
||||
"-----END RSA PRIVATE KEY-----\n",
|
||||
"html_url": "https://github.com/apps/fake-codex-app"})
|
||||
return fake
|
||||
|
||||
|
||||
class ManifestTestCase(unittest.TestCase):
|
||||
def test_manifest_urls_and_permissions(self):
|
||||
m = build_app_manifest(BASE)
|
||||
self.assertEqual(m["hook_attributes"]["url"],
|
||||
BASE + "/webhooks/github")
|
||||
self.assertEqual(m["redirect_url"], BASE + "/github-app/callback")
|
||||
self.assertEqual(m["default_permissions"]["contents"], "write")
|
||||
self.assertEqual(m["default_permissions"]["pull_requests"], "write")
|
||||
self.assertEqual(m["default_permissions"]["issues"], "read")
|
||||
self.assertIn("issue_comment", m["default_events"])
|
||||
self.assertIn("pull_request", m["default_events"])
|
||||
self.assertFalse(m["public"])
|
||||
|
||||
def test_manifest_requires_base(self):
|
||||
with self.assertRaises(ValueError):
|
||||
build_app_manifest("")
|
||||
|
||||
|
||||
class SetupStateTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_consume_once_and_expired(self):
|
||||
store = SetupStateStore(self.db_path)
|
||||
state = store.create_state()
|
||||
self.assertTrue(store.consume_state(state))
|
||||
self.assertFalse(store.consume_state(state))
|
||||
store.conn.close()
|
||||
|
||||
def test_expired_state_rejected(self):
|
||||
store = SetupStateStore(self.db_path)
|
||||
state = store.create_state()
|
||||
# 注入“1 小时之后”的 now:state 应过期且被删。
|
||||
self.assertFalse(store.consume_state(
|
||||
state, now=9999999999.0))
|
||||
self.assertFalse(store.consume_state(state))
|
||||
store.conn.close()
|
||||
|
||||
|
||||
class ExchangeTestCase(unittest.TestCase):
|
||||
def test_exchange_ok(self):
|
||||
config = exchange_manifest_code(
|
||||
"fake-code", http_client=_fake_exchange())
|
||||
self.assertEqual(config["app_id"], 424242)
|
||||
self.assertEqual(config["slug"], "fake-codex-app")
|
||||
self.assertEqual(config["webhook_secret"], "fake-app-secret")
|
||||
|
||||
def test_exchange_missing_fields(self):
|
||||
with self.assertRaises(AppError) as ctx:
|
||||
exchange_manifest_code(
|
||||
"fake-code",
|
||||
http_client=_fake_exchange({"id": 1, "pem": "x"}))
|
||||
self.assertEqual(ctx.exception.reason, "config_incomplete")
|
||||
|
||||
def test_exchange_http_error(self):
|
||||
def boom(url, headers):
|
||||
raise RuntimeError("boom")
|
||||
with self.assertRaises(AppError) as ctx:
|
||||
exchange_manifest_code("fake-code", http_client=boom)
|
||||
self.assertEqual(ctx.exception.reason, "exchange_failed")
|
||||
|
||||
|
||||
class ConfigAndInstallationTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.conn = _db_connect(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.conn.close()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_config_roundtrip_and_public_status(self):
|
||||
self.assertIsNone(get_app_config(self.conn))
|
||||
save_app_config(self.conn, {
|
||||
"app_id": 7, "slug": "fake-app", "client_id": "Iv1.x",
|
||||
"webhook_secret": "fake-secret", "pem": "fake-pem",
|
||||
"html_url": "https://github.com/apps/fake-app"})
|
||||
config = get_app_config(self.conn)
|
||||
self.assertEqual(config["slug"], "fake-app")
|
||||
status = public_app_status(self.conn)
|
||||
self.assertTrue(status["configured"])
|
||||
self.assertNotIn("webhook_secret", status)
|
||||
self.assertNotIn("pem", status)
|
||||
|
||||
def test_installations_created_added_removed_deleted(self):
|
||||
record_installation_repos(self.conn, 11, "octo",
|
||||
["octo/a", "octo/b"])
|
||||
record_installation_repos(self.conn, 11, "octo", ["octo/a"])
|
||||
rows = list_installations(self.conn)
|
||||
self.assertEqual(len(rows), 2) # 重复同步不翻倍
|
||||
record_installation_repos(self.conn, 11, "octo", ["octo/c"])
|
||||
remove_installation_repos(self.conn, 11, ["octo/b"])
|
||||
repos = [r["repo_full_name"] for r in list_installations(self.conn)]
|
||||
self.assertEqual(sorted(repos), ["octo/a", "octo/c"])
|
||||
removed = remove_installation(self.conn, 11)
|
||||
self.assertEqual(removed, 2)
|
||||
self.assertEqual(list_installations(self.conn), [])
|
||||
|
||||
def test_parse_installation_event(self):
|
||||
payload = {"action": "created",
|
||||
"installation": {"id": 11, "account": {"login": "octo"}},
|
||||
"repositories": [{"full_name": "octo/a"}]}
|
||||
inst = parse_installation_event(payload)
|
||||
self.assertEqual(inst["installation_id"], 11)
|
||||
self.assertEqual(inst["repos"], ["octo/a"])
|
||||
added = dict(payload, action="added", repositories_added=[
|
||||
{"full_name": "octo/b"}])
|
||||
self.assertEqual(parse_installation_event(added)["repos"],
|
||||
["octo/b"])
|
||||
self.assertIsNone(parse_installation_event(
|
||||
{"action": "created"}))
|
||||
self.assertIsNone(parse_installation_event(
|
||||
{"action": "opened", "installation": {"id": 1}}))
|
||||
|
||||
|
||||
class DedupTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.conn = _db_connect(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.conn.close()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_content_dedup(self):
|
||||
is_new, task_id = claim_fix_review(
|
||||
self.conn, "octo/a", 3, 99)
|
||||
self.assertTrue(is_new)
|
||||
self.assertIsNone(task_id)
|
||||
set_fix_review_task_id(self.conn, "octo/a", 3, 99, "task-1")
|
||||
is_new, task_id = claim_fix_review(
|
||||
self.conn, "octo/a", 3, 99)
|
||||
self.assertFalse(is_new)
|
||||
self.assertEqual(task_id, "task-1")
|
||||
# 另一个评论 id 不受影响。
|
||||
self.assertTrue(claim_fix_review(
|
||||
self.conn, "octo/a", 3, 100)[0])
|
||||
# 无评论 id 时无法去重,直接放行。
|
||||
self.assertTrue(claim_fix_review(
|
||||
self.conn, "octo/a", 3, None)[0])
|
||||
|
||||
|
||||
class SignatureAnyTestCase(unittest.TestCase):
|
||||
def _sig(self, secret, raw):
|
||||
return "sha256=" + hmac.new(
|
||||
secret.encode(), raw, hashlib.sha256).hexdigest()
|
||||
|
||||
def test_any_secret_matches(self):
|
||||
raw = b'{"zen":"hi"}'
|
||||
self.assertTrue(verify_github_signature_any(
|
||||
["app-secret", "legacy-secret"], raw,
|
||||
self._sig("legacy-secret", raw)))
|
||||
self.assertFalse(verify_github_signature_any(
|
||||
["app-secret", "legacy-secret"], raw,
|
||||
self._sig("wrong-secret", raw)))
|
||||
self.assertFalse(verify_github_signature_any([], raw, "sha256=x"))
|
||||
|
||||
|
||||
class AppHttpTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.user_a, self.key_a = self.auth.create_user("user-a")
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.server.github_app_exchange_client = _fake_exchange()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
os.environ.pop("GITHUB_APP_PUBLIC_BASE", None)
|
||||
os.environ.pop("GITHUB_WEBHOOK_SECRET", None)
|
||||
|
||||
def _req(self, method, path, body=None, key=None, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
try:
|
||||
hdrs = dict(headers or {})
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = body if isinstance(body, str) else json.dumps(body)
|
||||
hdrs.setdefault("Content-Type", "application/json")
|
||||
if key is not None:
|
||||
hdrs["X-API-Key"] = key
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read().decode()
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
parsed = raw
|
||||
return resp.status, parsed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _sig(self, secret, raw):
|
||||
return "sha256=" + hmac.new(
|
||||
secret.encode(), raw, hashlib.sha256).hexdigest()
|
||||
|
||||
def _webhook(self, event, payload, secret, delivery):
|
||||
raw = json.dumps(payload)
|
||||
return self._req(
|
||||
"POST", "/webhooks/github", body=raw,
|
||||
headers={"X-GitHub-Event": event,
|
||||
"X-GitHub-Delivery": delivery,
|
||||
"X-Hub-Signature-256": self._sig(secret, raw.encode())})
|
||||
|
||||
def _issue_setup_state(self):
|
||||
os.environ["GITHUB_APP_PUBLIC_BASE"] = BASE
|
||||
status, body = self._req(
|
||||
"POST", "/github-app/setup/state", {}, key=self.key_a)
|
||||
self.assertEqual(status, 200, body)
|
||||
return body["state"], body["setup_url"]
|
||||
|
||||
def test_setup_state_requires_auth_and_base(self):
|
||||
status, _ = self._req("POST", "/github-app/setup/state", {})
|
||||
self.assertEqual(status, 401)
|
||||
os.environ.pop("GITHUB_APP_PUBLIC_BASE", None)
|
||||
status, body = self._req(
|
||||
"POST", "/github-app/setup/state", {}, key=self.key_a)
|
||||
self.assertEqual(status, 400, body)
|
||||
|
||||
def test_setup_page_and_callback_flow(self):
|
||||
state, setup_url = self._issue_setup_state()
|
||||
self.assertTrue(setup_url.startswith(BASE + "/github-app/setup"))
|
||||
# 无 state 不渲染表单。
|
||||
status, page = self._req("GET", "/github-app/setup")
|
||||
self.assertEqual(status, 400, page)
|
||||
# 有 state 渲染表单:manifest 与 GitHub 建 App 地址都在。
|
||||
status, page = self._req(
|
||||
"GET", "/github-app/setup?state=%s" % state)
|
||||
self.assertEqual(status, 200, page)
|
||||
self.assertIn("github.com/settings/apps/new", page)
|
||||
self.assertIn("codex-app-server", page)
|
||||
self.assertIn("/webhooks/github", page)
|
||||
# 回调:坏 state 失败;好 state 换 code 存配置。
|
||||
status, page = self._req(
|
||||
"GET", "/github-app/callback?code=x&state=nope")
|
||||
self.assertEqual(status, 400, page)
|
||||
status, page = self._req(
|
||||
"GET", "/github-app/callback?code=fake-code&state=%s" % state)
|
||||
self.assertEqual(status, 200, page)
|
||||
self.assertIn("fake-codex-app", page)
|
||||
self.assertIn("/installations/new", page)
|
||||
self.assertNotIn("fake-app-secret", page)
|
||||
self.assertNotIn("fake-pem", page)
|
||||
# 同一 state 第二次回调必然失败。
|
||||
status, page = self._req(
|
||||
"GET", "/github-app/callback?code=fake-code&state=%s" % state)
|
||||
self.assertEqual(status, 400, page)
|
||||
# 状态接口:已配置、无 secret。
|
||||
status, body = self._req(
|
||||
"GET", "/github-app/status", key=self.key_a)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["configured"])
|
||||
self.assertEqual(body["slug"], "fake-codex-app")
|
||||
self.assertNotIn("webhook_secret", body)
|
||||
self.assertNotIn("pem", body)
|
||||
|
||||
def test_webhook_dual_secret_and_installation(self):
|
||||
# 先存一个 App 配置(secret 为 fake-app-secret)。
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
save_app_config(conn, {
|
||||
"app_id": 424242, "slug": "fake-codex-app",
|
||||
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
|
||||
"pem": "fake-pem",
|
||||
"html_url": "https://github.com/apps/fake-codex-app"})
|
||||
finally:
|
||||
conn.close()
|
||||
os.environ["GITHUB_WEBHOOK_SECRET"] = "legacy-secret"
|
||||
ping = {"zen": "hi"}
|
||||
# App secret 与 legacy secret 都能验过;错 secret 401。
|
||||
status, _ = self._webhook(
|
||||
"ping", ping, "fake-app-secret", "d-app-1")
|
||||
self.assertEqual(status, 200)
|
||||
status, _ = self._webhook(
|
||||
"ping", ping, "legacy-secret", "d-legacy-1")
|
||||
self.assertEqual(status, 200)
|
||||
status, _ = self._webhook("ping", ping, "wrong", "d-wrong-1")
|
||||
self.assertEqual(status, 401)
|
||||
# installation 事件记安装记录。
|
||||
inst_payload = {
|
||||
"action": "created",
|
||||
"installation": {"id": 11, "account": {"login": "octo"}},
|
||||
"repositories": [{"full_name": "octo/a"},
|
||||
{"full_name": "octo/b"}]}
|
||||
status, body = self._webhook(
|
||||
"installation", inst_payload, "fake-app-secret", "d-inst-1")
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["result"], "installation_recorded")
|
||||
status, body = self._req(
|
||||
"GET", "/github-app/status", key=self.key_a)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(len(body["installations"]), 2)
|
||||
self.assertEqual(
|
||||
body["installations"][0]["repo_full_name"], "octo/a")
|
||||
|
||||
def test_webhook_no_secret_is_503(self):
|
||||
os.environ.pop("GITHUB_WEBHOOK_SECRET", None)
|
||||
status, _ = self._webhook(
|
||||
"ping", {"zen": "hi"}, "anything", "d-none-1")
|
||||
self.assertEqual(status, 503)
|
||||
|
||||
def test_fix_review_content_dedup_over_http(self):
|
||||
# 项目名 == 仓库 full_name,/fix-review 才能建任务。
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO projects(id, name, owner_id, created_at,"
|
||||
" repo_url, auto_pr) VALUES(?, ?, ?, 0, NULL, 0)",
|
||||
("proj-1", "octo/a", self.user_a))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
save_app_config(conn, {
|
||||
"app_id": 424242, "slug": "fake-codex-app",
|
||||
"client_id": "Iv1.fake", "webhook_secret": "fake-app-secret",
|
||||
"pem": "fake-pem",
|
||||
"html_url": "https://github.com/apps/fake-codex-app"})
|
||||
finally:
|
||||
conn.close()
|
||||
payload = {
|
||||
"action": "created",
|
||||
"comment": {"id": 777, "body": "please /fix-review this",
|
||||
"user": {"login": "octo"}},
|
||||
"issue": {"number": 5},
|
||||
"repository": {"full_name": "octo/a"}}
|
||||
# 同一评论送两遍,delivery_id 不同(模拟手动 + App 双通道)。
|
||||
status, first = self._webhook(
|
||||
"issue_comment", payload, "fake-app-secret", "d-fix-1")
|
||||
self.assertEqual(status, 200, first)
|
||||
self.assertIsNotNone(first.get("task_id"), first)
|
||||
status, second = self._webhook(
|
||||
"issue_comment", payload, "fake-app-secret", "d-fix-2")
|
||||
self.assertEqual(status, 200, second)
|
||||
self.assertTrue(second.get("duplicate"), second)
|
||||
self.assertEqual(second.get("task_id"), first.get("task_id"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -60,6 +60,20 @@ def verify_github_signature(secret, payload_bytes, signature_header):
|
||||
return hmac.compare_digest(expected, signature_header)
|
||||
|
||||
|
||||
def verify_github_signature_any(secrets, payload_bytes, signature_header):
|
||||
"""多 secret 验签:任一 secret 对上即 True,全不对或无 secret
|
||||
即 False。过渡期手动 webhook(env secret)与 App webhook
|
||||
(库里 secret)共存时用;App 装好、手动 webhook 删掉后,
|
||||
env secret 自然不再被命中,不用改代码。"""
|
||||
if not secrets:
|
||||
return False
|
||||
for secret in secrets:
|
||||
if secret and verify_github_signature(
|
||||
secret, payload_bytes, signature_header):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def parse_github_pr_comment(payload):
|
||||
"""解析 GitHub issue_comment 事件,返回 fix-review 触发点或 None。
|
||||
|
||||
|
||||
Reference in new issue
Block a user