From 9fa80bef8927892b77ac207d20cda5d9657e8c00 Mon Sep 17 00:00:00 2001 From: host Date: Mon, 26 Oct 2026 16:11:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=20GitHub=20App=20=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=9C=A8=20Postgres=20=E4=B8=8A=E5=B4=A9=EF=BC=9A=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=B1=82=E8=AF=91=20INSERT=20OR=20IGNORE=EF=BC=8C?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=90=8E=E5=A2=9E=E5=87=8F=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloud/db.py | 28 ++++++++++++-- cloud/server.py | 13 ++++--- cloud/tests/test_github_app.py | 68 ++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/cloud/db.py b/cloud/db.py index 6c977dd..359ddfc 100644 --- a/cloud/db.py +++ b/cloud/db.py @@ -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) diff --git a/cloud/server.py b/cloud/server.py index 9cf2dd7..fc4c992 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -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}) diff --git a/cloud/tests/test_github_app.py b/cloud/tests/test_github_app.py index 9adcaf0..6d49cd1 100644 --- a/cloud/tests/test_github_app.py +++ b/cloud/tests/test_github_app.py @@ -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(