{ "name": "Genome: PR review", "nodes": [ { "parameters": { "httpMethod": "POST", "path": "forgejo-pr-review-forgejo-pr-review-23319ab8687b16f10e0f278fb920c112", "options": {} }, "id": "edf8e431-3637-477d-83bd-1f077843f740", "name": "Webhook PR Review", "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -144, 304 ], "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';\nconst j = $json.body || $json;\nconst review = j.review || null, comment = j.comment || null;\nconst pr = j.pull_request || j.issue || null;\nconst body = ((review && review.content) || (comment && comment.body) || '').toString();\nconst sender = (j.sender && j.sender.login) || 'unknown';\n\nconst m = body.match(/^\\s*(REWORK|RESTART|REVERT\\s+\\d+|SPLIT|REJECT|MERGE)\\s*:?/i);\nif (!m) return { directive: 'NONE' };\nconst headTok = m[1].toUpperCase().replace(/\\s+/g, ' ');\nlet directive = headTok.startsWith('REVERT') ? 'REVERT' : headTok;\nconst feedback = body.slice(m[0].length).trim() || '(nessun dettaglio fornito)';\n\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) || (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) || '';\nconst rawMatch = prBody.match(//);\nconst raw = rawMatch ? rawMatch[1] : null;\n\nif (directive === 'REVERT') return { directive: 'NONE', note: 'REVERT reserved for Step 7' };\nif (!ALLOWED_SENDERS.includes(sender))\n return { directive: 'UNAUTHORIZED', attempted: directive, sender, prNumber, owner, repo };\n\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') ? true\n : (!!raw && raw.startsWith('raw/') && !raw.includes('..') && /^[A-Za-z0-9._\\/-]+$/.test(raw));\nif (!okGenome || !okPr || !okBase || (directive !== 'MERGE' && !okBranch) || !okRaw)\n return { directive: 'INVALID', attempted: directive, prNumber, owner, repo,\n why: { okGenome, okPr, okBranch, okBase, okRaw } };\n\nconst feedback_b64 = Buffer.from(feedback, 'utf8').toString('base64');\nreturn { directive, prNumber, branch, base, repo, owner, sender, raw, feedback, feedback_b64 };" }, "id": "39977823-cbc1-45bb-b479-a57052b482e9", "name": "Parse & validate", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 80, 304 ] }, { "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": "bc8aff39-a6bf-4e7c-8069-505d5855fb62", "name": "Switch", "type": "n8n-nodes-base.switch", "typeVersion": 3.4, "position": [ 320, 304 ] }, { "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": "2cea722f-42f1-475e-8060-7bac7cf4d245", "name": "Forgejo Merge PR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 560, 64 ], "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": "aff153d5-48c8-4a31-bd37-5bce49e60fa9", "name": "Guardia feat/", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 560, 288 ] }, { "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": "1745f043-9dc3-44e2-8654-4cc88114d636", "name": "Forgejo Close PR", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 784, 256 ], "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": "de59c610-c671-4a48-bca4-61ba9988bc65", "name": "Forgejo Delete Branch", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 1008, 256 ], "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": "5c149f65-1ce2-4a39-9b86-aa05a993735c", "name": "E' REJECT?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 1232, 256 ] }, { "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": "24900f7e-959e-4398-8630-721a38443aa4", "name": "Power Manager - ensure-on", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ 1232, 128 ] }, { "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": "={{ $('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": "16774a78-b4eb-491f-9508-040aa3d4dc12", "name": "Run one ingest (rework)", "type": "n8n-nodes-base.executeWorkflow", "typeVersion": 1.3, "position": [ 1440, 128 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "// merged (MERGE) / closed (REJECT). The HTTP node replaced $json with the API response, so read\n// context from the parser (single review -> .first() is correct and pairedItem-proof).\nconst p = $('Parse & validate').first().json;\nconst repoUrl = `https://git.keruhomelab.com/${p.owner}/${p.repo}`;\nconst prUrl = `${repoUrl}/pulls/${p.prNumber}`;\nlet n;\nif (p.directive === 'MERGE') {\n n = { topic: 'genome-ingest', title: `${p.repo} \\u00b7 PR #${p.prNumber} mergiata`,\n priority: 'default', tags: 'twisted_rightwards_arrows', click: prUrl,\n actions: `view, Vedi la PR, ${prUrl}`,\n body: `PR #${p.prNumber} mergiata su \\`${p.base}\\` da **${p.sender}**.` };\n} else {\n n = { topic: 'genome-ingest', title: `${p.repo} \\u00b7 PR #${p.prNumber} chiusa`,\n priority: 'default', tags: 'wastebasket', click: repoUrl, actions: '',\n body: `**REJECT** di **${p.sender}**: PR #${p.prNumber} chiusa e branch \\`${p.branch}\\` rimosso. Nessun nuovo tentativo.\\n> ${p.feedback}` };\n}\nreturn n;" }, "id": "f3c339cb-91e3-4436-b56a-b97c81d4a58f", "name": "Build ntfy action", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1440, 304 ] }, { "parameters": { "mode": "runOnceForEachItem", "jsCode": "// Security / near-miss: unauthorized sender, invalid directive, or the feat/ guard. On all three\n// paths Switch/Guardia pass the parser output through, so $json carries the directive + context.\nconst d = $json;\nconst repoUrl = (d.owner && d.repo) ? `https://git.keruhomelab.com/${d.owner}/${d.repo}` : '';\nlet n;\nif (d.directive === 'UNAUTHORIZED') {\n n = { topic: 'genome-ingest', title: `Sicurezza \\u00b7 direttiva non autorizzata`,\n priority: 'high', tags: 'no_entry', click: repoUrl, actions: '',\n body: `**${d.sender}** ha tentato \\`${d.attempted}\\` su PR #${d.prNumber}, ma non \\u00e8 tra i maintainer autorizzati. **Nessuna azione** eseguita.` };\n} else if (d.directive === 'INVALID') {\n n = { topic: 'genome-ingest', title: `Direttiva non applicata`,\n priority: 'low', tags: 'information_source', click: repoUrl, actions: '',\n body: `\\`${d.attempted}\\` su PR #${d.prNumber} ignorata: precondizioni non soddisfatte (branch / base / marker raw).` };\n} else {\n n = { topic: 'genome-ingest', title: `Sicurezza \\u00b7 branch protetto`,\n priority: 'high', tags: 'no_entry', click: repoUrl, actions: '',\n body: `Rifiutata azione distruttiva (\\`${d.directive}\\`) sul branch \\`${d.branch}\\`: non \\u00e8 un \\`feat/ai-ingest-*\\`. **Nessuna modifica.**` };\n}\nreturn n;" }, "id": "ba552761-b8cb-43c8-a6b1-ac93ca2b17b1", "name": "Build ntfy sicurezza", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 784, 496 ] }, { "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": "156f41ad-e1e1-4a7b-b91c-ceb2043ab147", "name": "ntfy: send", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.4, "position": [ 1664, 384 ], "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": "8c92ff1a-672a-4d15-9aa0-10d5fe11e472", "meta": { "instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417" }, "id": "iho7kFQsXbGIxG7P", "tags": [] }