GitHub App 一键安装:manifest 建 App、安装记录、双 secret 验签、/fix-review 内容去重
This commit is contained in:
1 parent
ecf4b5c715
commit
32059178bd
5 files changed
+1210
-14
No files matched your search
+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:
|
||||
|
||||
Reference in new issue
Block a user