Merge pull request #36 from openqj/github-app-pg-dialect

修 GitHub App 安装在 Postgres 上崩
This commit is contained in:
openqj authored and GitHub committed 2026-10-26 16:13:58 +08:00
commit aa5ad41eba
3 files changed
+101 -8

No files matched your search

+25 -3
View File
@@ -310,9 +310,10 @@ def _load_postgres_driver():
# 不重构所有 store 的前提:现有代码到处用 ``?`` 占位符、
# ``row["col"]``、``conn.execute().fetchall()``、``commit`` /
# ``rollback`` / ``close``、``cursor.lastrowid``、``cursor.rowcount``,
# 以及少量 SQLite 方言(``INSERT OR REPLACE``、``PRAGMA
# table_info``、``AUTOINCREMENT``、FTS5 虚拟表)。适配层在
# 执行前做最小翻译,翻译不了的方言明确报错,绝不静默改语义:
# 以及少量 SQLite 方言(``INSERT OR REPLACE``、``INSERT OR
# IGNORE``、``PRAGMA table_info``、``AUTOINCREMENT``、FTS5 虚拟表)。
# 适配层在执行前做最小翻译,翻译不了的方言明确报错,绝不静默
# 改语义:
# - ``?`` -> ``%s``:只转换单引号字符串字面量之外的 ``?``
# (扫描时跟踪单引号与 ``''`` 转义,字面量里的 ``?`` 不动);
# SQL 里已有的 ``%s``(例如调用方自己拼的 ``IN (%s)`` 占位串
@@ -322,6 +323,8 @@ def _load_postgres_driver():
# _OR_REPLACE_PK;未知表退化为 ``ON CONFLICT DO NOTHING``
# (语义弱于 REPLACE,调用点只有 project_members 与
# slack_users 两张已知主键表,不会走到退化分支)。
# - ``INSERT OR IGNORE`` -> ``ON CONFLICT DO NOTHING``:无冲突
# 目标,任何唯一冲突都跳过,不改已有行。
# - ``PRAGMA table_info(<表>)`` -> information_schema 查询,
# 返回 (cid, name, type, notnull, dflt_value, pk) 六元组,
# 与 sqlite 的 PRAGMA table_info 行形状一致,调用方取 row[1]
@@ -411,6 +414,7 @@ def _convert_placeholders(sql):
_OR_REPLACE_PK = {
"project_members": ("project_id", "user_id"),
"slack_users": ("slack_id",),
"github_app_config": ("id",),
}
_IDENTITY_TABLES = frozenset(
@@ -422,6 +426,10 @@ _OR_REPLACE_RE = _re.compile(
r"^\s*INSERT\s+OR\s+REPLACE\s+INTO\s+(\w+)\s*\(([^)]+)\)\s*"
r"VALUES\s*\((.*)\)\s*$",
_re.IGNORECASE | _re.DOTALL)
_OR_IGNORE_RE = _re.compile(
r"^\s*INSERT\s+OR\s+IGNORE\s+INTO\s+(\w+)\s*\(([^)]+)\)\s*"
r"VALUES\s*\((.*)\)\s*$",
_re.IGNORECASE | _re.DOTALL)
_INSERT_TABLE_RE = _re.compile(
r"^\s*INSERT\s+INTO\s+(?:\w+\.)?(\w+)", _re.IGNORECASE)
_PRAGMA_INFO_RE = _re.compile(
@@ -477,6 +485,20 @@ def _translate_sql(sql):
"INSERT INTO %s (%s) VALUES (%s) ON CONFLICT DO NOTHING"
% (table, ", ".join(cols), values))
return _convert_placeholders(translated), "normal"
m = _OR_IGNORE_RE.match(stripped)
if m:
# INSERT OR IGNORE(SQLite 方言)-> ON CONFLICT DO NOTHING:
# 无冲突目标即可,任何唯一冲突都跳过,不改已有行。2026-10-26
# 实弹教训:GitHub App 安装事件在生产 Postgres 上因这句未
# 翻译直接报语法错,安装记录丢了(delivery 已认领,重试会
# 判 duplicate,事件无法靠重发恢复,只能卸载重装)。
table = m.group(1)
cols = m.group(2)
values = m.group(3)
translated = (
"INSERT INTO %s (%s) VALUES (%s) ON CONFLICT DO NOTHING"
% (table, cols, values))
return _convert_placeholders(translated), "normal"
translated = _AUTOINCREMENT_RE.sub(
"BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", sql)
translated = _convert_placeholders(translated)
+8 -5
View File
@@ -2324,9 +2324,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
仍是配置里的 repo,不是任意 GitHub 仓库的自动克隆,
见 README)。check_run / check_suite 只做通知解析,
返回解析结果而不建任务、不改云端任务状态(CI 状态回填
由 pr-checks 主动拉取)。installation 事件记安装记录
(created/added 增行、removed 减行、deleted 清安装),
不建任务。手动 webhook 与 App webhook 共存时同一评论
由 pr-checks 主动拉取)。installation / installation_repositories
事件记安装记录(created/added 增行、removed 减行、deleted
清安装),不建任务。手动 webhook 与 App webhook 共存时同一评论
会送两遍(delivery_id 不同):/fix-review 另有内容去重
(repo + PR + 评论 id),只建一次任务。
"""
@@ -2373,8 +2373,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
store.close()
except Exception:
pass
# installation 事件:记安装记录,不建任务。
if event == "installation":
# installation / installation_repositories 事件:记安装记录,
# 不建任务。后者是安装后增减仓库的同步事件(added 增行、
# removed 减行),与 installation 事件共用同一解析与动作
# 分发;new_permissions_accepted 无需改行。
if event in ("installation", "installation_repositories"):
inst = parse_installation_event(payload)
if inst is None:
self._send_json(200, {"result": None})
+68
View File
@@ -242,6 +242,47 @@ class SignatureAnyTestCase(unittest.TestCase):
self.assertFalse(verify_github_signature_any([], raw, "sha256=x"))
class DialectTranslationTestCase(unittest.TestCase):
"""Postgres 适配层翻译:2026-10-26 实弹回归——安装事件在生产
Postgres 上因 INSERT OR IGNORE 未翻译直接报语法错。
直接测 _translate_sql(纯函数):函数发出的 SQL 在这里翻
译成合法 Postgres,SQLite 路径不受影响(翻译只走 Pg 游标)。
"""
def test_or_ignore_translates(self):
from cloud.db import _translate_sql
sql, kind = _translate_sql(
"INSERT OR IGNORE INTO github_app_installations("
"installation_id, account_login, repo_full_name, created_at)"
" VALUES(?, ?, ?, ?)")
self.assertEqual(kind, "normal")
self.assertNotIn("OR IGNORE", sql)
self.assertIn("ON CONFLICT DO NOTHING", sql)
self.assertIn("%s", sql)
self.assertNotIn("?", sql)
def test_or_ignore_with_null_value_translates(self):
from cloud.db import _translate_sql
sql, _ = _translate_sql(
"INSERT OR IGNORE INTO github_fix_review_dedup("
"repo, pr_number, comment_id, task_id, created_at)"
" VALUES(?, ?, ?, NULL, ?)")
self.assertNotIn("OR IGNORE", sql)
self.assertIn("ON CONFLICT DO NOTHING", sql)
self.assertIn("NULL", sql)
def test_or_replace_app_config_updates_on_conflict(self):
from cloud.db import _translate_sql
sql, _ = _translate_sql(
"INSERT OR REPLACE INTO github_app_config(id, app_id, slug,"
" client_id, webhook_secret, pem, html_url, created_at)"
" VALUES(1, ?, ?, ?, ?, ?, ?, ?)")
self.assertIn("ON CONFLICT (id) DO UPDATE SET", sql)
self.assertIn("slug = EXCLUDED.slug", sql)
self.assertNotIn("OR REPLACE", sql)
class AppHttpTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@@ -391,6 +432,33 @@ class AppHttpTestCase(unittest.TestCase):
self.assertEqual(
body["installations"][0]["repo_full_name"], "octo/a")
def test_installation_repositories_sync(self):
# 安装后增减仓库的同步事件:added 增行。
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": "added",
"installation": {"id": 11, "account": {"login": "octo"}},
"repositories_added": [{"full_name": "octo/c"}]}
status, body = self._webhook(
"installation_repositories", payload, "fake-app-secret",
"d-inst-repos-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(
[r["repo_full_name"] for r in body["installations"]],
["octo/c"])
def test_webhook_no_secret_is_503(self):
os.environ.pop("GITHUB_WEBHOOK_SECRET", None)
status, _ = self._webhook(