diff --git a/deploy/n8n/genome-PR-review.json b/deploy/n8n/genome-PR-review.json new file mode 100644 index 0000000..86c32b7 --- /dev/null +++ b/deploy/n8n/genome-PR-review.json @@ -0,0 +1,773 @@ +{ + "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": [] +} \ No newline at end of file