{ "name": "Genome: PR review", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "forgejo-pr-review-23319ab8687b16f10e0f278fb920c112", "options": {} }, "id": "58df1ca9-e48e-4834-b231-d97c974cd01b", "name": "Webhook PR Review", "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ 2272, 1344 ], "webhookId": "61ff3a5baa304571" }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "// THE only parser of the review side: parse the directive, VALIDATE, prepare the rework payload.\n// Security: only allow-listed maintainers may drive the gate; destructive directives require a\n// feat/ai-ingest-* branch on the expected base; raw_source is recovered from a machine-readable\n// marker that run-ingest.sh writes into the PR body.\nconst ALLOWED_SENDERS = ['Keru']; // <-- maintainers allowed to issue directives\nconst BASE = 'develop';\n\n// n8n Run Once for Each Item: $json is the current webhook payload\nconst j = $json.body || $json;\nif (!j || typeof j !== 'object') {\n return { directive: 'INVALID', reason: 'malformed webhook payload' };\n}\n\nconst review = j.review || null;\nconst comment = j.comment || null;\nconst pr = j.pull_request || j.issue || null;\n\n// Extract directive text from review content or comment body\nconst body = String(\n (review && review.content) ||\n (comment && comment.body) ||\n ''\n);\nconst sender = String((j.sender && j.sender.login) || 'unknown');\n\n// Match directive at the start of the text (case-insensitive)\nconst m = body.match(/^\\s*(REWORK|RESTART|REVERT\\s+\\d+|SPLIT|REJECT|MERGE)\\s*:?/i);\nif (!m) return { directive: 'NONE' };\n\nconst headTok = m[1].toUpperCase().replace(/\\s+/g, ' ');\nconst directive = headTok.startsWith('REVERT') ? 'REVERT' : headTok;\nconst feedback = body.slice(m[0].length).trim() || '(nessun dettaglio fornito)';\n\n// Extract PR metadata safely\nconst prNumber = (pr && pr.number) || null;\nconst branch = (pr && pr.head && pr.head.ref) || null;\nconst base = (pr && pr.base && pr.base.ref) || null;\nconst repo = (pr && pr.base && pr.base.repo && pr.base.repo.name) ||\n (j.repository && j.repository.name) || null;\nconst owner = (pr && pr.base && pr.base.repo && pr.base.repo.owner && pr.base.repo.owner.login) ||\n (j.repository && j.repository.owner && j.repository.owner.login) || null;\nconst prBody = (pr && pr.body) || (j.issue && j.issue.body) || '';\n\n// Recover raw_source from machine-readable marker: \n// Restricted to valid path characters, no spaces, no HTML breaking\nconst rawMatch = prBody.match(//);\nconst raw = rawMatch ? rawMatch[1] : null;\n\n// REVERT is reserved for future Step 7 implementation\nif (directive === 'REVERT') {\n return { directive: 'NONE', note: 'REVERT reserved for Step 7' };\n}\n\n// Authorization gate\nif (!ALLOWED_SENDERS.includes(sender)) {\n return {\n directive: 'UNAUTHORIZED',\n attempted: directive,\n sender,\n prNumber,\n owner,\n repo\n };\n}\n\n// Validation rules\nconst okGenome = !!repo && /^[a-z0-9][a-z0-9-]{0,63}$/.test(repo);\nconst okPr = !!prNumber && /^[0-9]+$/.test(String(prNumber));\nconst okBranch = !!branch && /^feat\\/ai-ingest-[a-z0-9-]+$/.test(branch);\nconst okBase = base === BASE;\nconst okRaw = (directive === 'MERGE')\n ? true\n : (!!raw && raw.startsWith('raw/') && !raw.includes('..') && /^[A-Za-z0-9._\\/-]+$/.test(raw));\n\nif (!okGenome || !okPr || !okBase || (directive !== 'MERGE' && !okBranch) || !okRaw) {\n return {\n directive: 'INVALID',\n attempted: directive,\n prNumber,\n owner,\n repo,\n why: { okGenome, okPr, okBranch, okBase, okRaw }\n };\n}\n\n// Encode feedback for safe transport through SSH/scripts\nconst feedback_b64 = Buffer.from(feedback, 'utf8').toString('base64');\n\nreturn {\n directive,\n prNumber,\n branch,\n base,\n repo,\n owner,\n sender,\n raw,\n feedback,\n feedback_b64\n};" }, "id": "c668f595-0a28-4bd3-9125-22fee9350d78", "name": "Parse & validate", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2496, 1344 ] }, { "parameters": { "rules": { "values": [ { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.directive }}", "rightValue": "MERGE", "operator": { "type": "string", "operation": "equals" }, "id": "4960f0868bc54687" } ], "combinator": "and" } }, { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.directive }}", "rightValue": "REWORK", "operator": { "type": "string", "operation": "equals" }, "id": "34002fdd92834d38" } ], "combinator": "and" } }, { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.directive }}", "rightValue": "RESTART", "operator": { "type": "string", "operation": "equals" }, "id": "d412a74e32ac4f0c" } ], "combinator": "and" } }, { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.directive }}", "rightValue": "SPLIT", "operator": { "type": "string", "operation": "equals" }, "id": "c0810b33fa474ca0" } ], "combinator": "and" } }, { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.directive }}", "rightValue": "REJECT", "operator": { "type": "string", "operation": "equals" }, "id": "531039e699c44cea" } ], "combinator": "and" } }, { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.directive }}", "rightValue": "UNAUTHORIZED", "operator": { "type": "string", "operation": "equals" }, "id": "cfbd691d2e9a4c2a" } ], "combinator": "and" } }, { "conditions": { "options": { "caseSensitive": true, "typeValidation": "strict", "version": 2 }, "conditions": [ { "leftValue": "={{ $json.directive }}", "rightValue": "INVALID", "operator": { "type": "string", "operation": "equals" }, "id": "251f5b7beea6424a" } ], "combinator": "and" } } ] }, "options": { "fallbackOutput": "none" } }, "id": "489736cc-bab6-4664-8087-91b6d9ff31ad", "name": "Switch", "type": "n8n-nodes-base.switch", "typeVersion": 3.4, "position": [ 2736, 1344 ] }, { "parameters": { "method": "POST", "url": "=https://git.keruhomelab.com/api/v1/repos/{{ $('Parse & validate').first().json.owner }}/{{ $('Parse & validate').first().json.repo }}/pulls/{{ $('Parse & validate').first().json.prNumber }}/merge", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"Do\": \"merge\"\n}", "options": { "timeout": 15000 } }, "id": "3440cb8d-ae4c-4523-ae13-ee5667d24252", "name": "Forgejo Merge PR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 2976, 1104 ], "credentials": { "httpHeaderAuth": { "id": "TBPXSWOF63k9mvm8", "name": "ntfy-token" } } }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "typeValidation": "loose", "version": 2 }, "conditions": [ { "id": "cc369b5fc3d246a4", "leftValue": "={{ $('Parse & validate').first().json.branch }}", "rightValue": "feat/ai-ingest-", "operator": { "type": "string", "operation": "startsWith" } } ], "combinator": "and" }, "options": {} }, "id": "e6d45fce-83d0-44ca-9fa4-86558fec1a0f", "name": "Guardia feat/", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 2976, 1328 ] }, { "parameters": { "method": "PATCH", "url": "=https://git.keruhomelab.com/api/v1/repos/{{ $('Parse & validate').first().json.owner }}/{{ $('Parse & validate').first().json.repo }}/pulls/{{ $('Parse & validate').first().json.prNumber }}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={\n \"state\": \"closed\"\n}", "options": { "timeout": 15000 } }, "id": "1601f705-c758-4df6-a3bd-e3ac2e202c94", "name": "Forgejo Close PR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 3200, 1296 ], "credentials": { "httpHeaderAuth": { "id": "TBPXSWOF63k9mvm8", "name": "ntfy-token" } } }, { "parameters": { "method": "DELETE", "url": "=https://git.keruhomelab.com/api/v1/repos/{{ $('Parse & validate').first().json.owner }}/{{ $('Parse & validate').first().json.repo }}/branches/{{ encodeURIComponent($('Parse & validate').first().json.branch) }}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": { "timeout": 15000 } }, "id": "c2ff2247-efe1-4809-a435-9973188d61bb", "name": "Forgejo Delete Branch", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 3424, 1296 ], "credentials": { "httpHeaderAuth": { "id": "TBPXSWOF63k9mvm8", "name": "ntfy-token" } } }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "55cf6c2a6c7d4d79", "leftValue": "={{ $('Parse & validate').first().json.directive }}", "rightValue": "REJECT", "operator": { "type": "string", "operation": "equals" } } ], "combinator": "and" }, "options": {} }, "id": "a1dbbc06-555d-4a1d-8fbf-ee75f617e98a", "name": "E' REJECT?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 3648, 1296 ] }, { "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": "7fc3e648-4712-4eef-a6f3-12c8805ade1f", "name": "Power Manager - ensure-on", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ 3648, 1168 ] }, { "parameters": { "workflowId": { "__rl": true, "value": "VIi2ovb5gJxNJLbg", "mode": "list", "cachedResultUrl": "/workflow/VIi2ovb5gJxNJLbg", "cachedResultName": "Genome: run-one-ingest" }, "workflowInputs": { "mappingMode": "defineBelow", "value": { "genome": "={{ $('Parse & validate').first().json.repo }}", "raw": "={{ $('Parse & validate').first().json.raw }}", "mode": "rework", "feedback_b64": "={{ $('Parse & validate').first().json.feedback_b64 }}", "reason": "={{ $('Parse & validate').first().json.directive }}", "prevPr": "={{ String($('Parse & validate').first().json.prNumber || '') }}" }, "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": "9704c050-5c63-49fd-a26d-efbae9d92175", "name": "Run one ingest (rework)", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ 3856, 1168 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "// merged (MERGE) / closed (REJECT). The HTTP node replaced $json with the API response,\n// so we read context from the parser via node reference (single review -> .first() is safe).\n// Fallback values prevent crashes if the parser node is unreachable.\nconst p = $('Parse & validate').first().json || {};\nconst repo = p.repo || 'unknown';\nconst owner = p.owner || 'unknown';\nconst prNumber = p.prNumber || '?';\nconst base = p.base || 'develop';\nconst branch = p.branch || 'unknown';\nconst sender = p.sender || 'unknown';\nconst directive = p.directive || 'UNKNOWN';\nconst feedback = p.feedback || '';\n\nconst repoUrl = (owner && repo && repo !== 'unknown')\n ? `https://git.keruhomelab.com/${owner}/${repo}`\n : '';\nconst prUrl = (repoUrl && prNumber !== '?')\n ? `${repoUrl}/pulls/${prNumber}`\n : '';\n\nlet n;\nif (directive === 'MERGE') {\n n = {\n topic: 'genome-ingest',\n title: `${repo} · PR #${prNumber} mergiata`,\n priority: 'default',\n tags: 'twisted_rightwards_arrows',\n click: prUrl,\n actions: `view, Vedi la PR, ${prUrl}`,\n body: `PR #${prNumber} mergiata su \\`${base}\\` da **${sender}**.`\n };\n} else {\n n = {\n topic: 'genome-ingest',\n title: `${repo} · PR #${prNumber} chiusa`,\n priority: 'default',\n tags: 'wastebasket',\n click: repoUrl,\n actions: '',\n body: `**REJECT** di **${sender}**: PR #${prNumber} chiusa e branch \\`${branch}\\` rimosso. Nessun nuovo tentativo.\\n> ${feedback}`\n };\n}\n\nreturn n;" }, "id": "1ce634fd-d402-4a84-9ba1-04673ddffce9", "name": "Build ntfy action", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3856, 1344 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "// Security / near-miss: unauthorized sender, invalid directive, or the feat/ guard.\n// On all three paths Switch/Guardia pass the parser output through, so $json carries the directive + context.\nconst d = $json || {};\nconst directive = d.directive || 'UNKNOWN';\nconst attempted = d.attempted || directive;\nconst sender = d.sender || 'unknown';\nconst prNumber = d.prNumber || '?';\nconst branch = d.branch || 'unknown';\nconst owner = d.owner || '';\nconst repo = d.repo || '';\n\nconst repoUrl = (owner && repo) ? `https://git.keruhomelab.com/${owner}/${repo}` : '';\n\nlet n;\nif (directive === 'UNAUTHORIZED') {\n n = {\n topic: 'genome-ingest',\n title: `Sicurezza · direttiva non autorizzata`,\n priority: 'high',\n tags: 'no_entry',\n click: repoUrl,\n actions: '',\n body: `**${sender}** ha tentato \\`${attempted}\\` su PR #${prNumber}, ma non è tra i maintainer autorizzati. **Nessuna azione** eseguita.`\n };\n} else if (directive === 'INVALID') {\n n = {\n topic: 'genome-ingest',\n title: `Direttiva non applicata`,\n priority: 'low',\n tags: 'information_source',\n click: repoUrl,\n actions: '',\n body: `\\`${attempted}\\` su PR #${prNumber} ignorata: precondizioni non soddisfatte (branch / base / marker raw).`\n };\n} else {\n // Guardia feat/ false branch: destructive action on a non-feat/ai-ingest-* branch\n n = {\n topic: 'genome-ingest',\n title: `Sicurezza · branch protetto`,\n priority: 'high',\n tags: 'no_entry',\n click: repoUrl,\n actions: '',\n body: `Rifiutata azione distruttiva (\\`${attempted || directive}\\`) sul branch \\`${branch}\\`: non è un \\`feat/ai-ingest-*\\`. **Nessuna modifica.**`\n };\n}\n\nreturn n;" }, "id": "32b16592-5126-4cc2-a3f2-d1bda58ac724", "name": "Build ntfy sicurezza", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 3200, 1536 ] }, { "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": "4d45b486-de42-4c7f-be21-b5bfbc05fd44", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 4080, 1424 ], "credentials": { "httpHeaderAuth": { "id": "TBPXSWOF63k9mvm8", "name": "ntfy-token" }, "httpBearerAuth": { "id": "nCv4CUN7Ef086Ewj", "name": "Bearer Auth account" } } } ], "pinData": {}, "connections": { "Webhook PR Review": { "main": [ [ { "node": "Parse & validate", "type": "main", "index": 0 } ] ] }, "Parse & validate": { "main": [ [ { "node": "Switch", "type": "main", "index": 0 } ] ] }, "Switch": { "main": [ [ { "node": "Forgejo Merge PR", "type": "main", "index": 0 } ], [ { "node": "Power Manager - ensure-on", "type": "main", "index": 0 } ], [ { "node": "Guardia feat/", "type": "main", "index": 0 } ], [ { "node": "Guardia feat/", "type": "main", "index": 0 } ], [ { "node": "Guardia feat/", "type": "main", "index": 0 } ], [ { "node": "Build ntfy sicurezza", "type": "main", "index": 0 } ], [ { "node": "Build ntfy sicurezza", "type": "main", "index": 0 } ] ] }, "Forgejo Merge PR": { "main": [ [ { "node": "Build ntfy action", "type": "main", "index": 0 } ] ] }, "Guardia feat/": { "main": [ [ { "node": "Forgejo Close PR", "type": "main", "index": 0 } ], [ { "node": "Build ntfy sicurezza", "type": "main", "index": 0 } ] ] }, "Forgejo Close PR": { "main": [ [ { "node": "Forgejo Delete Branch", "type": "main", "index": 0 } ] ] }, "Forgejo Delete Branch": { "main": [ [ { "node": "E' REJECT?", "type": "main", "index": 0 } ] ] }, "E' REJECT?": { "main": [ [ { "node": "Build ntfy action", "type": "main", "index": 0 } ], [ { "node": "Power Manager - ensure-on", "type": "main", "index": 0 } ] ] }, "Power Manager - ensure-on": { "main": [ [ { "node": "Run one ingest (rework)", "type": "main", "index": 0 } ] ] }, "Build ntfy action": { "main": [ [ { "node": "ntfy: send", "type": "main", "index": 0 } ] ] }, "Build ntfy sicurezza": { "main": [ [ { "node": "ntfy: send", "type": "main", "index": 0 } ] ] } }, "active": true, "settings": { "executionOrder": "v1", "binaryMode": "separate", "timeSavedMode": "fixed", "errorWorkflow": "7Vws3gCX3QnjM3oD", "callerPolicy": "workflowsFromSameOwner", "availableInMCP": false }, "versionId": "22998a54-cd9a-4b57-9c80-df97085a997c", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, "id": "iho7kFQsXbGIxG7P", "tags": [] }