feat: add /release-deleted endpoint for Gitea release deletion handler

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 17:02:27 +00:00
parent c2269f8d58
commit 50c408c4c4
+36
View File
@@ -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]):