{ "name": "Genome: ingest", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "forgejo-push", "options": {} }, "id": "8c44b478-1a95-4c3b-8ac1-d7c57e228414", "name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ 1520, 1728 ], "webhookId": "cf215f5d31e04dd2" }, { "parameters": { "jsCode": "// Bell filter: proceed ONLY on develop pushes that actually touch raw/.\n// Returning [] stops the flow (no node needed).\n// Performance: never wake vm101 for wiki-only pushes (e.g. an ingest PR merged back to develop).\n// pending-raw remains the source of truth.\nconst item = $input.first().json;\nconst b = item.body || item;\nconst ref = String(b.ref || '');\nconst genome = String((b.repository && b.repository.name) || '').toLowerCase().trim();\n\n// Branch filter\nif (ref !== 'refs/heads/develop') return [];\n\n// Genome name validation (DNS-like: lowercase alphanum + hyphen, 1-64 chars)\nif (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(genome)) return [];\n\n// Collect all touched paths safely (added, modified, removed)\nconst commits = Array.isArray(b.commits) ? b.commits : [];\nconst touched = [];\nfor (const c of commits) {\n if (!c || typeof c !== 'object') continue;\n for (const key of ['added', 'modified', 'removed']) {\n const list = c[key];\n if (!Array.isArray(list)) continue;\n for (const p of list) {\n if (typeof p === 'string' && p.startsWith('raw/')) {\n touched.push(p);\n }\n }\n }\n}\n\n// Gate: stop if nothing under raw/ was touched\nif (touched.length === 0) return [];\n\nreturn [{ json: { genome, touchedCount: touched.length } }];" }, "id": "604787c7-4e83-468e-9a98-3ac084203040", "name": "Gate push", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1744, 1728 ] }, { "parameters": { "workflowId": { "__rl": true, "value": "zbtRXWsLt56nEIfz", "mode": "list", "cachedResultUrl": "/workflow/zbtRXWsLt56nEIfz", "cachedResultName": "Power Manager" }, "workflowInputs": { "mappingMode": "defineBelow", "value": { "mode": "ensure-on" }, "matchingColumns": [ "mode" ], "schema": [ { "id": "mode", "displayName": "mode", "required": false, "defaultMatch": false, "display": true, "canBeUsedToMatch": true, "type": "string", "removed": false } ], "attemptToConvertTypes": false, "convertFieldsToString": true }, "options": {} }, "id": "f93073a3-7753-4ce1-9ef1-2a0c16386543", "name": "Power Manager - ensure-on", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ 1952, 1728 ] }, { "parameters": { "authentication": "privateKey", "command": "=ssh vm101 'pi pending-raw {{ $('Gate push').first().json.genome }}'" }, "id": "876dbdaf-3620-4c2c-a65b-336f0b11198c", "name": "SSH: pending-raw", "type": "n8n-nodes-base.ssh", "typeVersion": 1, "position": [ 2176, 1728 ], "credentials": { "sshPrivateKey": { "id": "GJQjKzte7Hjdfz89", "name": "n8n container -> n8n-runner@nexus" } } }, { "parameters": { "jsCode": "// Parse pending-raw -> one item per raw, carrying everything run-one-ingest needs.\n// Unsafe filenames (spaces / odd chars) are NOT ingested -> a 'badname' item -> ntfy.\nconst out = String($input.first().json.stdout || '').trim();\nlet d;\ntry {\n d = JSON.parse(out);\n} catch (e) {\n return [{ json: { _kind: 'error', reason: 'pending-raw non parsabile', raw: out.substring(0, 500) } }];\n}\n\nif (!d || typeof d !== 'object') {\n return [{ json: { _kind: 'error', reason: 'pending-raw non è un oggetto JSON', raw: out.substring(0, 500) } }];\n}\n\nconst files = Array.isArray(d.files) ? d.files : [];\nif (files.length === 0) return [];\n\n// Build reason map from detail array\nconst why = {};\nfor (const it of (Array.isArray(d.detail) ? d.detail : [])) {\n if (it && typeof it.path === 'string' && typeof it.reason === 'string') {\n why[it.path] = it.reason;\n }\n}\n\nconst SAFE = /^[A-Za-z0-9._\\/-]+$/;\nconst items = [];\nfor (const raw of files) {\n if (typeof raw !== 'string') {\n items.push({ json: { _kind: 'badname', genome: d.genome, raw: String(raw),\n hint: String(raw).replace(/[^A-Za-z0-9._\\/-]+/g, '-').toLowerCase() || 'invalid' } });\n continue;\n }\n if (SAFE.test(raw)) {\n items.push({ json: { _kind: 'ingest', genome: d.genome, raw,\n mode: 'ingest', feedback_b64: '', reason: why[raw] || 'new', prevPr: '' } });\n } else {\n const hint = raw.replace(/[^A-Za-z0-9._\\/-]+/g, '-').toLowerCase() || 'invalid';\n items.push({ json: { _kind: 'badname', genome: d.genome, raw, hint } });\n }\n}\nreturn items;" }, "id": "f5bbbed3-222e-4129-a764-7cf47d69c5ce", "name": "Split raw files", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2400, 1728 ] }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "cbacf5d98d594ba5", "leftValue": "={{ $json._kind }}", "rightValue": "ingest", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "id": "5398e2c4-c7ca-4ca4-a2d7-e75077453b7c", "name": "Nome valido?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 2624, 1728 ] }, { "parameters": { "workflowId": { "__rl": true, "value": "VIi2ovb5gJxNJLbg", "mode": "list", "cachedResultUrl": "/workflow/VIi2ovb5gJxNJLbg", "cachedResultName": "Genome: run-one-ingest" }, "workflowInputs": { "mappingMode": "defineBelow", "value": { "genome": "={{ $json.genome }}", "raw": "={{ $json.raw }}", "mode": "ingest", "feedback_b64": "", "reason": "={{ $json.reason }}", "prevPr": "" }, "matchingColumns": [], "schema": [ { "id": "genome", "displayName": "genome", "required": false, "defaultMatch": false, "display": true, "canBeUsedToMatch": true, "type": "string", "removed": false }, { "id": "raw", "displayName": "raw", "required": false, "defaultMatch": false, "display": true, "canBeUsedToMatch": true, "type": "string", "removed": false }, { "id": "mode", "displayName": "mode", "required": false, "defaultMatch": false, "display": true, "canBeUsedToMatch": true, "type": "string", "removed": false }, { "id": "feedback_b64", "displayName": "feedback_b64", "required": false, "defaultMatch": false, "display": true, "canBeUsedToMatch": true, "type": "string", "removed": false }, { "id": "reason", "displayName": "reason", "required": false, "defaultMatch": false, "display": true, "canBeUsedToMatch": true, "type": "string", "removed": false }, { "id": "prevPr", "displayName": "prevPr", "required": false, "defaultMatch": false, "display": true, "canBeUsedToMatch": true, "type": "string", "removed": false } ], "attemptToConvertTypes": false, "convertFieldsToString": true }, "options": { "waitForSubWorkflow": false } }, "id": "0f274662-62bb-448b-ae4b-47e4bbcfd35a", "name": "Run one ingest", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ 2832, 1616 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "// Build ntfy notification for files with invalid names.\n// Run Once for Each Item: $json is the current badname item.\nconst d = $json || {};\nconst genome = d.genome || 'unknown';\nconst raw = String(d.raw || 'unknown');\nconst hint = String(d.hint || 'unknown');\n\n// Escape backticks to avoid breaking markdown\nconst rawEsc = raw.replace(/`/g, '\\`');\nconst hintEsc = hint.replace(/`/g, '\\`');\n\nreturn {\n topic: 'genome-ingest',\n title: `${genome} · file da rinominare`,\n priority: 'high',\n tags: 'warning',\n click: '',\n actions: '',\n body: `Il file \\`${rawEsc}\\` ha spazi o caratteri non ammessi e **non** è stato ingerito.\\nRinominalo in: \\`${hintEsc}\\``\n};" }, "id": "0f785bcd-cdc6-4dac-9ced-1c5cfa3453dc", "name": "Build ntfy badname", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2832, 1840 ] }, { "parameters": { "method": "POST", "url": "=http://ntfy/{{ $json.topic }}", "authentication": "genericCredentialType", "genericAuthType": "httpBearerAuth", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Title", "value": "={{ $json.title }}" }, { "name": "Priority", "value": "={{ $json.priority }}" }, { "name": "Tags", "value": "={{ $json.tags }}" }, { "name": "Click", "value": "={{ $json.click }}" }, { "name": "Actions", "value": "={{ $json.actions }}" }, { "name": "Markdown", "value": "yes" } ] }, "sendBody": true, "contentType": "raw", "rawContentType": "Raw / Text", "body": "={{ $json.body }}", "options": { "timeout": 15000 } }, "id": "9cd2bde3-6846-4855-ad01-e3a4cdbce208", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 3056, 1840 ], "credentials": { "httpHeaderAuth": { "id": "TBPXSWOF63k9mvm8", "name": "ntfy-token" }, "httpBearerAuth": { "id": "nCv4CUN7Ef086Ewj", "name": "Bearer Auth account" } } } ], "pinData": {}, "connections": { "Webhook": { "main": [ [ { "node": "Gate push", "type": "main", "index": 0 } ] ] }, "Gate push": { "main": [ [ { "node": "Power Manager - ensure-on", "type": "main", "index": 0 } ] ] }, "Power Manager - ensure-on": { "main": [ [ { "node": "SSH: pending-raw", "type": "main", "index": 0 } ] ] }, "SSH: pending-raw": { "main": [ [ { "node": "Split raw files", "type": "main", "index": 0 } ] ] }, "Split raw files": { "main": [ [ { "node": "Nome valido?", "type": "main", "index": 0 } ] ] }, "Nome valido?": { "main": [ [ { "node": "Run one ingest", "type": "main", "index": 0 } ], [ { "node": "Build ntfy badname", "type": "main", "index": 0 } ] ] }, "Build ntfy badname": { "main": [ [ { "node": "ntfy: send", "type": "main", "index": 0 } ] ] } }, "active": true, "settings": { "executionOrder": "v1", "binaryMode": "separate", "timeSavedMode": "fixed", "errorWorkflow": "7Vws3gCX3QnjM3oD", "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false }, "versionId": "63863925-606f-4200-824c-52f1919f2bb1", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, "id": "mUJUuQxcDiiPWcUE", "tags": [] }