From 50c408c4c44f6571e20adef278ce300f496b0e24 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 25 Apr 2026 17:02:27 +0000 Subject: [PATCH] feat: add /release-deleted endpoint for Gitea release deletion handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a release is deleted in Gitea (e.g., to retire an extension), the platform DB still showed the extension as installable. This endpoint removes the version row, recomputes latest_version, and deactivates the extension if no versions remain — making it disappear from the install catalog without touching the repo. Called by n8n workflow NwrRbHNAXFVOLHCQ on Gitea release.deleted event. Co-Authored-By: Claude Opus 4.7 (1M context) --- app_server.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app_server.py b/app_server.py index fd088cc..821a71b 100644 --- a/app_server.py +++ b/app_server.py @@ -6,6 +6,7 @@ Builds extensions from Gitea (no local files needed). Pipeline endpoints (called by n8n): POST /publish — Download from Gitea, build ZIP, publish to Composer Registry + Release + POST /release-deleted — Remove a deleted release from DB; deactivate extension if no versions remain POST /deploy — Deploy extension to a specific customer via EspoCRM API POST /deploy-all — Deploy to all licensed customers POST /sync — Sync installed extensions from EspoCRM servers with platform DB @@ -752,6 +753,8 @@ class APIHandler(BaseHTTPRequestHandler): path = self.path.split('?')[0] if path == '/publish': self._handle_publish(body) + elif path == '/release-deleted': + self._handle_release_deleted(body) elif path == '/deploy': self._handle_deploy(body) elif path == '/deploy-all': @@ -900,6 +903,39 @@ class APIHandler(BaseHTTPRequestHandler): result = build_and_publish(module, version) self._respond(200, result) + def _handle_release_deleted(self, body): + """Triggered by Gitea release-deleted webhook (via n8n). + Removes the version row, recomputes latest_version, and deactivates the extension if no versions remain. + Body: { module: str, version: str (optional — if omitted, only recomputes/deactivates) }""" + module = body.get('module', '') + version = body.get('version', '') + if not module: + self._respond(400, {'error': 'module required'}); return + ext = query("SELECT id FROM extensions WHERE name = %s", (module,), fetch='one') + if not ext: + self._respond(404, {'error': f'Extension {module} not found'}); return + ext_id = ext['id'] + if version: + query("DELETE FROM extension_versions WHERE extension_id = %s AND version = %s", + (ext_id, version), fetch=None) + remaining = query("SELECT COUNT(*) AS cnt FROM extension_versions WHERE extension_id = %s", + (ext_id,), fetch='one')['cnt'] + if remaining == 0: + query("UPDATE extensions SET is_active = false, latest_version = NULL, updated_at = NOW() WHERE id = %s", + (ext_id,), fetch=None) + self._respond(200, {'status': 'ok', 'module': module, 'version': version, + 'action': 'deactivated', 'remaining_versions': 0}) + return + query("""UPDATE extensions e SET latest_version = sub.max_ver, updated_at = NOW() + FROM (SELECT extension_id, version as max_ver FROM extension_versions ev1 + WHERE ev1.extension_id = %s + AND NOT EXISTS (SELECT 1 FROM extension_versions ev2 + WHERE ev2.extension_id = ev1.extension_id + AND string_to_array(ev2.version, '.')::int[] > string_to_array(ev1.version, '.')::int[]) + ) sub WHERE e.id = sub.extension_id""", (ext_id,), fetch=None) + self._respond(200, {'status': 'ok', 'module': module, 'version': version, + 'action': 'version_removed', 'remaining_versions': remaining}) + def _handle_deploy(self, body): cid, module, version = body.get('customer_id'), body.get('module',''), body.get('version','') if not all([cid, module, version]):