GitHub App 一键安装:manifest 建 App、安装记录、双 secret 验签、/fix-review 内容去重

This commit is contained in:
host committed 2026-10-26 16:02:45 +08:00
1 parent ecf4b5c715
commit 32059178bd
5 files changed
+1210 -14

No files matched your search

+306 -14
View File
@@ -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: