From dc8f392ff5aaeb07a9862ab555407a75b9b703e0 Mon Sep 17 00:00:00 2001 From: Clint Masden Date: Fri, 27 Feb 2026 13:27:14 -0600 Subject: [PATCH] =?UTF-8?q?Add=20webhook=20auto-deploy:=20Gitea=20push=20?= =?UTF-8?q?=E2=86=92=20Coolify=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add webhook endpoints (setup/teardown/status) for batch management - Add step 10 (webhook) to upsert pipeline for automatic setup - Add DELETE /routes endpoint for Traefik route removal - Add giteaFetch helper for Gitea API calls - Document webhook flow, CIFS hooks fix, IPv6 healthcheck gotcha - Update port allocation table (all 11 apps deployed) Co-Authored-By: Claude Opus 4.6 --- README.md | 123 +++++++++++++- api/routes/coolify.js | 379 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 468 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 938d0c9..66b30d1 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,19 @@ All endpoints return JSON. The Vite dev server proxies `/api/*` to the API serve | GET | `/api/coolify/servers` | List Coolify servers | | GET | `/api/coolify/next-port` | Get next available HOST_PORT | | POST | `/api/coolify/routes` | Add Traefik route via SSH | +| DELETE | `/api/coolify/routes` | Remove Traefik route via SSH | | POST | `/api/coolify/drift` | Check drift between coolify.json and live state | -| POST | `/api/coolify/upsert` | Full pipeline: config → create/update → env → route → deploy | +| POST | `/api/coolify/upsert` | Full pipeline: config → create/update → env → route → webhook → deploy | | GET | `/api/coolify/config?path=...` | Read coolify.json from a project | +### Webhooks + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/coolify/webhooks/setup` | Set Coolify webhook secret + create Gitea webhook on all apps | +| DELETE | `/api/coolify/webhooks/teardown` | Remove webhook secret + delete Gitea webhooks | +| GET | `/api/coolify/webhooks/status` | Check webhook configuration status per app | + ### System | Method | Path | Description | @@ -120,7 +129,7 @@ All endpoints return JSON. The Vite dev server proxies `/api/*` to the API serve ### Coolify Config (`gitea.repo.management/config.json`) -Shared with the gitea.repo.management project. Contains Coolify API credentials, server UUIDs, SSH config: +Shared with the gitea.repo.management project. Contains Coolify API credentials, server UUIDs, SSH config, and webhook/Gitea settings: ```json { @@ -134,7 +143,13 @@ Shared with the gitea.repo.management project. Contains Coolify API credentials, "sshUser": "clint", "sshPassword": "", "traefikPath": "/home/clint/containers/coolify/data/proxy/dynamic/custom.yml", - "targetIp": "192.168.69.5" + "targetIp": "192.168.69.5", + "domain": "dotrepo.com", + "webhookSecret": "" + }, + "gitea": { + "url": "http://192.168.69.4:2003", + "token": "" } } ``` @@ -172,6 +187,7 @@ The `POST /api/coolify/upsert` endpoint runs the full deploy pipeline: 7. **route** — Add Traefik route (remote) or set FQDN (local) 8. **changelog** — Write coolify.changelog.json to repo 9. **deploy** — Trigger Coolify deployment +10. **webhook** — Set Coolify webhook secret + create/update Gitea webhook for auto-deploy on push ## Docker Deploy Pipeline (Legacy/Direct) @@ -201,9 +217,11 @@ The `POST /api/docker/deploy` endpoint handles tar-based deployment: | 2004 | Every Noise at Once | Deployed | | 2005 | Learn X In Y | Deployed | | 2006 | Actual Budget Report | Deployed | -| 2007 | dotrepo-timer | Pending | -| 2008 | dotrepo-travel | Pending | +| 2007 | dotrepo-timer | Deployed | +| 2008 | dotrepo-travel | Deployed | | 2009 | dotrepo-racker | Deployed | +| 2011 | spike-breakdown | Deployed | +| 2012 | dotrepo-site | Deployed | ## LLM / Agent Usage @@ -231,3 +249,98 @@ curl "http://localhost:3100/api/servers/1771305558920/logs?remotePath=~/containe ``` **Always use this API** to access/change Coolify and Gitea deployment state. Do not call the Coolify API directly — this API wraps it with enrichment (HOST_PORT), safety (drift detection, changelog), and routing (Traefik management). + +## Webhook Auto-Deploy + +Every `git push` to Gitea automatically triggers a Coolify deployment. The pipeline: + +``` +git push → Gitea post-receive hook → Gitea webhook POST → Coolify webhook handler → Docker build + deploy +``` + +### How It Works + +1. Each Coolify app has a `manual_webhook_secret_gitea` — a shared HMAC-SHA256 secret +2. Each Gitea repo has a webhook pointing to `http://192.168.69.4:2010/webhooks/source/gitea/events/manual` +3. On push, Gitea signs the payload with the secret and POSTs to Coolify +4. Coolify validates the signature, matches the repo+branch, and queues a deployment + +### Setup + +Webhooks are automatically configured during `upsert` (step 10). To set up webhooks on all existing apps: + +```bash +# Set up webhooks on all Coolify apps +curl -X POST http://localhost:3100/api/coolify/webhooks/setup + +# Check status +curl http://localhost:3100/api/coolify/webhooks/status + +# Remove all webhooks +curl -X DELETE http://localhost:3100/api/coolify/webhooks/teardown +``` + +### Key Details + +- **Webhook URL must use internal IP** (`http://192.168.69.4:2010`), NOT the public URL (`https://coolify.clintmasden.duckdns.org`). The public URL fails due to hairpin NAT — Gitea and Coolify are on the same box (192.168.69.4). +- **Gitea ports**: 2003 = HTTP API, 2004 = SSH. Do not confuse them. +- **Branch**: All repos use `master` (not `main`). Coolify's `git_branch` must match. The upsert pipeline defaults to `master`. + +## Gotchas and Troubleshooting + +### Docker Healthcheck IPv6 Issue + +Alpine Linux resolves `localhost` to `::1` (IPv6) before `127.0.0.1` (IPv4). If your container only listens on IPv4 (e.g., nginx on `0.0.0.0:3000`), healthchecks using `localhost` will fail silently. + +**Fix**: Always use `127.0.0.1` instead of `localhost` in healthcheck commands: + +```yaml +# docker-compose.yml +healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] +``` + +```dockerfile +# Dockerfile +HEALTHCHECK CMD wget -qO- http://127.0.0.1:3000/ || exit 1 +``` + +### Gitea CIFS Hooks Not Executable + +Gitea's git data is stored on a CIFS/SMB mount (`//192.168.69.2/archive`). CIFS doesn't support per-file permissions — all files inherit `file_mode` from the mount options. If `file_mode=0664` (the default), git hooks won't execute because they lack the execute bit. This silently breaks: + +- Webhook delivery (post-receive hook doesn't fire) +- Branch tracking (Gitea sees repos as "empty") +- All git server-side hooks + +**Fix**: Change `file_mode=0664` to `file_mode=0775` in `/etc/fstab`: + +``` +//192.168.69.2/archive /home/clint/archive cifs ...,file_mode=0775,... 0 0 +``` + +Then remount: `sudo mount -o remount /home/clint/archive` + +After remounting, run `resync_all_hooks` via Gitea admin API to regenerate hook files: + +```bash +curl -X POST -H "Authorization: token " \ + "http://192.168.69.4:2003/api/v1/admin/cron/resync_all_hooks" +``` + +### Gitea Default Branch Mismatch + +All repos use `master` as their default branch. If Gitea's `default_branch` setting is `main` (the Gitea default for new repos), it will report the repo as `empty: true` and won't deliver webhooks for pushes to `master`. + +**Fix**: Set `default_branch` to `master` via API: + +```bash +curl -X PATCH -H "Authorization: token " \ + -H "Content-Type: application/json" \ + "http://192.168.69.4:2003/api/v1/repos/clint/" \ + -d '{"default_branch":"master"}' +``` + +### dockerComposeLocation vs baseDirectory + +In coolify.json, `dockerComposeLocation` is **relative to** `baseDirectory`. If your `baseDirectory` is `/myapp`, set `dockerComposeLocation` to `/docker-compose.yml`, NOT `/myapp/docker-compose.yml`. diff --git a/api/routes/coolify.js b/api/routes/coolify.js index 3221053..6f8652d 100644 --- a/api/routes/coolify.js +++ b/api/routes/coolify.js @@ -28,6 +28,62 @@ async function resolveServer(config, serverName) { return { uuid: match.uuid, ip, isLocal, name: match.name }; } +/** + * Insert a Traefik route into custom.yml safely. + * Parses the three top-level sections (routers, middlewares, services) by + * finding lines that match /^ \w+:$/ (exactly 2-space indent, no deeper). + * This avoids the substring-match bug where " middlewares:" inside a + * router definition gets confused with the " middlewares:" section header. + * + * Returns the new YAML string, or throws if structure is unrecognized. + */ +function insertTraefikRoute(currentYml, routerBlock, serviceBlock) { + const lines = currentYml.split('\n'); + + // Find exact line indices for the 3 top-level sections under http: + // They are always indented with exactly 2 spaces: " routers:", " middlewares:", " services:" + let routersLine = -1, middlewaresLine = -1, servicesLine = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/^ routers:\s*$/.test(line)) routersLine = i; + else if (/^ middlewares:\s*$/.test(line)) middlewaresLine = i; + else if (/^ services:\s*$/.test(line)) servicesLine = i; + } + + if (routersLine === -1 || servicesLine === -1) { + throw new Error('Traefik custom.yml missing required sections (routers/services). Aborting to avoid corruption.'); + } + + // Insert router block: just before middlewares (if exists) or just before services + const insertRouterBefore = middlewaresLine !== -1 ? middlewaresLine : servicesLine; + + // Build new lines array + const before = lines.slice(0, insertRouterBefore); + // Remove trailing blank lines from the routers section + while (before.length > 0 && before[before.length - 1].trim() === '') before.pop(); + before.push(''); // single blank line separator + before.push(...routerBlock.split('\n')); + before.push(''); // blank line before next section + + const middle = lines.slice(insertRouterBefore, servicesLine); + const servicesSection = lines.slice(servicesLine); + // Remove trailing blank lines from services + while (servicesSection.length > 0 && servicesSection[servicesSection.length - 1].trim() === '') servicesSection.pop(); + servicesSection.push(''); // blank line before new service + servicesSection.push(...serviceBlock.split('\n')); + servicesSection.push(''); // trailing newline + + const newLines = [...before, ...middle, ...servicesSection]; + const result = newLines.join('\n'); + + // Sanity check: verify the 3 sections still exist + if (!result.includes(' routers:') || !result.includes(' services:')) { + throw new Error('Traefik YAML validation failed after insertion. Aborting to avoid corruption.'); + } + + return result; +} + function wrap(fn) { return async (req, res) => { try { @@ -39,6 +95,25 @@ function wrap(fn) { }; } +async function giteaFetch(config, path, options = {}) { + const giteaUrl = config.gitea?.url || `http://${config.coolify?.sshHost}:2004`; + const giteaToken = config.gitea?.token || config.token; + const res = await fetch(`${giteaUrl}/api/v1${path}`, { + ...options, + headers: { + 'Authorization': `token ${giteaToken}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Gitea API ${res.status}: ${text}`); + } + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + // ─── Routes ──────────────────────────────────────────────────────── // GET /apps — list all Coolify apps (enriched with HOST_PORT) @@ -171,6 +246,9 @@ router.get('/next-port', wrap(async () => { router.post('/routes', wrap(async (req) => { const config = loadConfig(); const { routeName, domain, port } = req.body; + if (!routeName || !domain || !port) { + return { error: 'Missing required fields: routeName, domain, port' }; + } const traefikPath = config.coolify?.traefikPath; const targetIp = config.coolify?.targetIp || '192.168.69.5'; @@ -205,24 +283,70 @@ router.post('/routes', wrap(async (req) => { return { added: false, reason: 'route_exists' }; } - // Insert router before middlewares section, service before end of services section - let newYml; - const middlewaresSplit = current.split(' middlewares:'); - const servicesSplit = current.split(' services:'); - if (middlewaresSplit.length === 2 && servicesSplit.length === 2) { - // Insert router block before middlewares, service block at end of services - newYml = middlewaresSplit[0].trimEnd() + '\n\n' + routerBlock + '\n\n middlewares:' + middlewaresSplit[1]; - // Now add service at the end - newYml = newYml.trimEnd() + '\n\n' + serviceBlock + '\n'; - } else if (servicesSplit.length === 2) { - newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n'; - } else { - newYml = current.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\n'; + const newYml = insertTraefikRoute(current, routerBlock, serviceBlock); + const b64 = Buffer.from(newYml).toString('base64'); + await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`); + return { added: true }; +})); + +// DELETE /routes — remove a Traefik route by routeName +router.delete('/routes', wrap(async (req) => { + const config = loadConfig(); + const { routeName } = req.body; + if (!routeName) { + return { error: 'Missing required field: routeName' }; + } + const traefikPath = config.coolify?.traefikPath; + const current = await sshExec(config, `sudo cat "${traefikPath}"`); + + if (!current.includes(` ${routeName}:`)) { + return { removed: false, reason: 'route_not_found' }; + } + + const lines = current.split('\n'); + const filtered = []; + let skip = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + const indent = line.length - trimmed.length; + + // Match routeName: or routeName-http: at 4-space indent (router/service level) + if (indent === 4 && (trimmed === routeName + ':' || trimmed.startsWith(routeName + ':') || trimmed === routeName + '-http:' || trimmed.startsWith(routeName + '-http:'))) { + skip = true; + continue; + } + + if (skip) { + if (line.trim() === '' || indent > 4) continue; + skip = false; + } + + filtered.push(line); + } + + // Clean up double blank lines + const cleaned = []; + for (let i = 0; i < filtered.length; i++) { + if (filtered[i].trim() === '' && i > 0 && cleaned.length > 0 && cleaned[cleaned.length - 1].trim() === '') continue; + cleaned.push(filtered[i]); + } + + const newYml = cleaned.join('\n'); + + // Validate structure + if (!newYml.includes(' routers:') || !newYml.includes(' services:')) { + throw new Error('Traefik YAML validation failed after removal. Aborting to avoid corruption.'); + } + // Verify route is gone + if (newYml.includes(` ${routeName}:`)) { + throw new Error(`Route ${routeName} still found after removal. Aborting.`); } const b64 = Buffer.from(newYml).toString('base64'); await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`); - return { added: true }; + return { removed: true, routeName }; })); // POST /drift — check drift between coolify.json and live Coolify state @@ -262,7 +386,8 @@ router.post('/drift', wrap(async (req) => { } const repoName = appConfig.repo || basename(projectPath); - const expectedGitRepo = `git@${config.coolify?.sshHost}:2004/${config.owner}/${repoName}.git`; + const gitOwner = appConfig.gitOwner || config.owner; + const expectedGitRepo = `git@${config.coolify?.sshHost}:2004/${gitOwner}/${repoName}.git`; compare('repo', expectedGitRepo, app.git_repository, 'Git repository'); try { @@ -311,7 +436,8 @@ router.post('/upsert', wrap(async (req) => { if (!appConfig) throw new Error(`App "${appName}" not found in coolify.json`); const repoName = appConfig.repo || basename(projectPath); - const gitRepo = `git@${config.coolify?.sshHost}:2004/${config.owner}/${repoName}.git`; + const gitOwner = appConfig.gitOwner || config.owner; + const gitRepo = `git@${config.coolify?.sshHost}:2004/${gitOwner}/${repoName}.git`; const branch = appConfig.branch || 'master'; const baseDir = appConfig.baseDirectory || '/'; const composeLocation = appConfig.dockerComposeLocation || '/docker-compose.yml'; @@ -455,7 +581,7 @@ router.post('/upsert', wrap(async (req) => { const traefikPath = config.coolify?.traefikPath; const currentYml = await sshExec(config, `sudo cat "${traefikPath}"`); - if (currentYml.includes(appConfig.domain)) { + if (currentYml.includes('Host(`' + appConfig.domain + '`)')) { step('route', 'done', `Traefik route already exists for ${appConfig.domain}`); } else { const routerBlock = [ @@ -482,18 +608,7 @@ router.post('/upsert', wrap(async (req) => { ` - url: "http://${server.ip}:${appConfig.port}"`, ].join('\n'); - // Insert router before middlewares section, service at end - let newYml; - const middlewaresSplit = currentYml.split(' middlewares:'); - const servicesSplit = currentYml.split(' services:'); - if (middlewaresSplit.length === 2 && servicesSplit.length === 2) { - newYml = middlewaresSplit[0].trimEnd() + '\n\n' + routerBlock + '\n\n middlewares:' + middlewaresSplit[1]; - newYml = newYml.trimEnd() + '\n\n' + serviceBlock + '\n'; - } else if (servicesSplit.length === 2) { - newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n'; - } else { - newYml = currentYml.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\n'; - } + const newYml = insertTraefikRoute(currentYml, routerBlock, serviceBlock); const b64 = Buffer.from(newYml).toString('base64'); await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`); @@ -526,7 +641,213 @@ router.post('/upsert', wrap(async (req) => { const deployResult = await coolifyFetch(config, `/deploy?uuid=${appUuid}&force=true`); step('deploy', 'done', `Deployment triggered — ${appConfig.domain ? `https://${appConfig.domain}` : appUuid}`); + // Step 10: Webhook — set Coolify secret + create Gitea webhook + const webhookSecret = config.coolify?.webhookSecret; + if (webhookSecret) { + step('webhook', 'running', 'Configuring auto-deploy webhook...'); + const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010'; + const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`; + const giteaOwner = appConfig.gitOwner || config.owner || 'clint'; + + try { + // Set secret on Coolify app + await coolifyFetch(config, `/applications/${appUuid}`, { + method: 'PATCH', + body: JSON.stringify({ manual_webhook_secret_gitea: webhookSecret }), + }); + + // Create/update Gitea webhook + const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`); + const existing = hooks.find(h => h.config?.url === webhookUrl); + if (existing) { + await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${existing.id}`, { + method: 'PATCH', + body: JSON.stringify({ + config: { url: webhookUrl, content_type: 'json', secret: webhookSecret }, + active: true, + }), + }); + step('webhook', 'done', `Webhook updated (hook ${existing.id}) → auto-deploy on push`); + } else { + const created = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`, { + method: 'POST', + body: JSON.stringify({ + type: 'gitea', + active: true, + config: { url: webhookUrl, content_type: 'json', secret: webhookSecret }, + events: ['push'], + }), + }); + step('webhook', 'done', `Webhook created (hook ${created.id}) → auto-deploy on push`); + } + } catch (err) { + step('webhook', 'done', `Warning: webhook setup failed — ${err.message}`); + } + } else { + step('webhook', 'skipped', 'No webhookSecret in config — skipping auto-deploy setup'); + } + return { success: true, uuid: appUuid, domain: appConfig.domain, changelogEntry: entry, steps }; })); +// ─── Webhook Management ───────────────────────────────────────── + +// POST /webhooks/setup — configure webhooks on Coolify apps + Gitea repos +// Sets manual_webhook_secret_gitea on each Coolify app, then creates/updates +// a Gitea webhook on the corresponding repo pointing to Coolify's internal URL. +router.post('/webhooks/setup', wrap(async (req) => { + const config = loadConfig(); + const { appName } = req.body; // optional — if omitted, sets up ALL apps + const webhookSecret = config.coolify?.webhookSecret; + if (!webhookSecret) throw new Error('Missing coolify.webhookSecret in config.json'); + + const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010'; + const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`; + const giteaOwner = config.owner || 'clint'; + + const apps = await coolifyFetch(config, '/applications'); + const targets = appName ? apps.filter(a => a.name === appName) : apps; + const results = []; + + for (const app of targets) { + const result = { app: app.name, uuid: app.uuid, coolify: null, gitea: null }; + + // 1. Set webhook secret on Coolify app + try { + await coolifyFetch(config, `/applications/${app.uuid}`, { + method: 'PATCH', + body: JSON.stringify({ manual_webhook_secret_gitea: webhookSecret }), + }); + result.coolify = 'secret_set'; + } catch (err) { + result.coolify = `error: ${err.message}`; + } + + // 2. Create/update webhook on Gitea repo + const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/); + if (!gitRepoMatch) { + result.gitea = 'skipped: no git repo configured'; + results.push(result); + continue; + } + const repoName = gitRepoMatch[1]; + + try { + const existingHooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`); + const existing = existingHooks.find(h => h.config?.url === webhookUrl); + + if (existing) { + await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${existing.id}`, { + method: 'PATCH', + body: JSON.stringify({ + config: { url: webhookUrl, content_type: 'json', secret: webhookSecret }, + active: true, + }), + }); + result.gitea = `updated (hook ${existing.id})`; + } else { + const created = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`, { + method: 'POST', + body: JSON.stringify({ + type: 'gitea', + active: true, + config: { url: webhookUrl, content_type: 'json', secret: webhookSecret }, + events: ['push'], + }), + }); + result.gitea = `created (hook ${created.id})`; + } + } catch (err) { + result.gitea = `error: ${err.message}`; + } + + results.push(result); + } + + return { webhookUrl, appsConfigured: results.length, results }; +})); + +// DELETE /webhooks/teardown — remove webhooks from all (or specified) apps +router.delete('/webhooks/teardown', wrap(async (req) => { + const config = loadConfig(); + const { appName } = req.body; + const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010'; + const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`; + const giteaOwner = config.owner || 'clint'; + + const apps = await coolifyFetch(config, '/applications'); + const targets = appName ? apps.filter(a => a.name === appName) : apps; + const results = []; + + for (const app of targets) { + const result = { app: app.name, uuid: app.uuid, coolify: null, gitea: null }; + + try { + await coolifyFetch(config, `/applications/${app.uuid}`, { + method: 'PATCH', + body: JSON.stringify({ manual_webhook_secret_gitea: '' }), + }); + result.coolify = 'secret_cleared'; + } catch (err) { + result.coolify = `error: ${err.message}`; + } + + const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/); + if (!gitRepoMatch) { + result.gitea = 'skipped'; + results.push(result); + continue; + } + const repoName = gitRepoMatch[1]; + + try { + const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`); + const matching = hooks.filter(h => h.config?.url === webhookUrl); + for (const hook of matching) { + await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${hook.id}`, { method: 'DELETE' }); + } + result.gitea = matching.length > 0 ? `removed ${matching.length} hook(s)` : 'none found'; + } catch (err) { + result.gitea = `error: ${err.message}`; + } + + results.push(result); + } + + return { appsConfigured: results.length, results }; +})); + +// GET /webhooks/status — check webhook status across all apps +router.get('/webhooks/status', wrap(async () => { + const config = loadConfig(); + const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010'; + const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`; + const giteaOwner = config.owner || 'clint'; + + const apps = await coolifyFetch(config, '/applications'); + const results = []; + + for (const app of apps) { + const result = { app: app.name, uuid: app.uuid, coolifySecretSet: false, giteaWebhookActive: false, repo: null }; + + try { + const detail = await coolifyFetch(config, `/applications/${app.uuid}`); + result.coolifySecretSet = !!detail.manual_webhook_secret_gitea; + } catch { /* skip */ } + + const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/); + if (gitRepoMatch) { + result.repo = gitRepoMatch[1]; + try { + const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${result.repo}/hooks`); + result.giteaWebhookActive = hooks.some(h => h.config?.url === webhookUrl && h.active); + } catch { /* skip */ } + } + + results.push(result); + } + + return { webhookUrl, results }; +})); + export default router;