Compare commits
No commits in common. "main" and "release/1.11.0" have entirely different histories.
main
...
release/1.
17 changed files with 496 additions and 2206 deletions
2
Makefile
2
Makefile
|
|
@ -1,5 +1,5 @@
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Knowledge Genome - Makefile v. 1.13.0
|
# Knowledge Genome - Makefile v. 1.11.0
|
||||||
# Orchestrates the setup and management of the knowledge base.
|
# Orchestrates the setup and management of the knowledge base.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,773 +0,0 @@
|
||||||
{
|
|
||||||
"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: <!-- kg:raw=path -->\n// Restricted to valid path characters, no spaces, no HTML breaking\nconst rawMatch = prBody.match(/<!--\\s*kg:raw=([^\\s>]+)\\s*-->/);\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": []
|
|
||||||
}
|
|
||||||
|
|
@ -7,28 +7,48 @@
|
||||||
"path": "forgejo-push",
|
"path": "forgejo-push",
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "8c44b478-1a95-4c3b-8ac1-d7c57e228414",
|
|
||||||
"name": "Webhook",
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
"type": "n8n-nodes-base.webhook",
|
||||||
"typeVersion": 2.1,
|
"typeVersion": 2.1,
|
||||||
"position": [
|
"position": [
|
||||||
1520,
|
1040,
|
||||||
1728
|
240
|
||||||
],
|
],
|
||||||
"webhookId": "cf215f5d31e04dd2"
|
"id": "9cc1b02e-6885-4a19-af34-ed2783ae99bf",
|
||||||
|
"name": "Webhook",
|
||||||
|
"webhookId": "bb518834-da85-46bb-bb72-97ba21a78faa"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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 } }];"
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
},
|
},
|
||||||
"id": "604787c7-4e83-468e-9a98-3ac084203040",
|
"conditions": [
|
||||||
"name": "Gate push",
|
{
|
||||||
"type": "n8n-nodes-base.code",
|
"id": "cc000000-0000-4000-8000-000000000001",
|
||||||
"typeVersion": 2,
|
"leftValue": "={{ $json.body.ref }}",
|
||||||
|
"rightValue": "refs/heads/develop",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
"position": [
|
"position": [
|
||||||
1744,
|
1264,
|
||||||
1728
|
240
|
||||||
]
|
],
|
||||||
|
"id": "b2dd46aa-cdc3-4103-ad05-c728d9bd14ee",
|
||||||
|
"name": "IF: ref == develop"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|
@ -64,28 +84,29 @@
|
||||||
},
|
},
|
||||||
"options": {}
|
"options": {}
|
||||||
},
|
},
|
||||||
"id": "f93073a3-7753-4ce1-9ef1-2a0c16386543",
|
|
||||||
"name": "Power Manager - ensure-on",
|
|
||||||
"type": "n8n-nodes-base.executeWorkflow",
|
"type": "n8n-nodes-base.executeWorkflow",
|
||||||
"typeVersion": 1.3,
|
"typeVersion": 1.3,
|
||||||
"position": [
|
"position": [
|
||||||
1952,
|
1488,
|
||||||
1728
|
240
|
||||||
]
|
],
|
||||||
|
"id": "e10f6af4-73ac-4689-b9f4-9c656d7c0cc4",
|
||||||
|
"name": "Power Manager - ensure-on"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"authentication": "privateKey",
|
"authentication": "privateKey",
|
||||||
"command": "=ssh vm101 'pi pending-raw {{ $('Gate push').first().json.genome }}'"
|
"command": "=ssh vm101 'pi changed-raw {{ $('Webhook').item.json.body.repository.name }} {{ $('Webhook').item.json.body.before }} {{ $('Webhook').item.json.body.after }}'"
|
||||||
},
|
},
|
||||||
"id": "876dbdaf-3620-4c2c-a65b-336f0b11198c",
|
|
||||||
"name": "SSH: pending-raw",
|
|
||||||
"type": "n8n-nodes-base.ssh",
|
"type": "n8n-nodes-base.ssh",
|
||||||
"typeVersion": 1,
|
"typeVersion": 1,
|
||||||
"position": [
|
"position": [
|
||||||
2176,
|
1712,
|
||||||
1728
|
240
|
||||||
],
|
],
|
||||||
|
"id": "479d2e9d-0fde-417a-9122-d9780cc5dcba",
|
||||||
|
"name": "SSH: changed-raw",
|
||||||
|
"executeOnce": true,
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"sshPrivateKey": {
|
"sshPrivateKey": {
|
||||||
"id": "GJQjKzte7Hjdfz89",
|
"id": "GJQjKzte7Hjdfz89",
|
||||||
|
|
@ -95,166 +116,69 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"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;"
|
"jsCode": "// run-once-for-all: parse changed-raw (JSON intero) -> 1 item per raw.\n// I nomi raw con spazi o caratteri non sicuri romperebbero il trasporto SSH\n// (lo spazio e' separatore di token nel comando 'pi ingest g raw'). Quindi qui\n// valido i nomi: quelli problematici NON vengono ingeriti, ma emettono un item\n// di tipo 'badname' che a valle diventa un ntfy 'rinomina il file'.\nconst out = ($input.first().json.stdout || '').toString().trim();\nlet d;\ntry { d = JSON.parse(out); }\ncatch (e) { return [{ json: { _kind: 'error', reason: 'changed-raw non parsabile', raw: out } }]; }\nif (!d.files || d.files.length === 0) return []; // niente raw -> stop silenzioso\n\n// regola 'non rompicoglioni': consentiti lettere, numeri, punto, slash, trattino, underscore.\n// VIETATI: spazi e tutto il resto (che spezzano SSH o gli slug downstream).\nconst SAFE = /^[A-Za-z0-9._\\/-]+$/;\nconst out_items = [];\nfor (const raw of d.files) {\n if (SAFE.test(raw)) {\n out_items.push({ json: { _kind: 'ingest', genome: d.genome, raw } });\n } else {\n out_items.push({ json: { _kind: 'badname', genome: d.genome, raw,\n hint: raw.replace(/[^A-Za-z0-9._\\/-]+/g, '-').toLowerCase() } });\n }\n}\nreturn out_items;"
|
||||||
},
|
},
|
||||||
"id": "f5bbbed3-222e-4129-a764-7cf47d69c5ce",
|
|
||||||
"name": "Split raw files",
|
|
||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [
|
"position": [
|
||||||
2400,
|
1920,
|
||||||
1728
|
240
|
||||||
]
|
],
|
||||||
|
"id": "d540e454-4648-475c-8dce-5111ef876f75",
|
||||||
|
"name": "Split raw files"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"conditions": {
|
"authentication": "privateKey",
|
||||||
"options": {
|
"command": "=ssh vm101 'pi ingest {{ $json.genome }} {{ $json.raw }}'"
|
||||||
"caseSensitive": true,
|
|
||||||
"leftValue": "",
|
|
||||||
"typeValidation": "strict",
|
|
||||||
"version": 2
|
|
||||||
},
|
},
|
||||||
"conditions": [
|
"type": "n8n-nodes-base.ssh",
|
||||||
{
|
"typeVersion": 1,
|
||||||
"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": [
|
"position": [
|
||||||
2624,
|
2144,
|
||||||
1728
|
240
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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,
|
"id": "7e30e055-7bc5-484a-a405-d29ea06ff175",
|
||||||
"convertFieldsToString": true
|
"name": "SSH: pi ingest",
|
||||||
},
|
"credentials": {
|
||||||
"options": {
|
"sshPrivateKey": {
|
||||||
"waitForSubWorkflow": false
|
"id": "GJQjKzte7Hjdfz89",
|
||||||
|
"name": "n8n container -> n8n-runner@nexus"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"id": "0f274662-62bb-448b-ae4b-47e4bbcfd35a",
|
|
||||||
"name": "Run one ingest",
|
|
||||||
"type": "n8n-nodes-base.executeWorkflow",
|
|
||||||
"typeVersion": 1.3,
|
|
||||||
"position": [
|
|
||||||
2832,
|
|
||||||
1616
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"mode": "runOnceForEachItem",
|
"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};"
|
"jsCode": "// per-item: ultima riga JSON di run-ingest.sh\nconst out = ($json.stdout || '').trim();\nconst line = out.split('\\n').filter(l => l.trim().startsWith('{')).pop();\nif (!line) return { status: 'error', reason: 'nessuna riga JSON run-ingest', raw: out };\ntry { return JSON.parse(line); } catch (e) { return { status: 'error', reason: 'JSON non parsabile', raw: line }; }"
|
||||||
},
|
},
|
||||||
"id": "0f785bcd-cdc6-4dac-9ced-1c5cfa3453dc",
|
|
||||||
"name": "Build ntfy badname",
|
|
||||||
"type": "n8n-nodes-base.code",
|
"type": "n8n-nodes-base.code",
|
||||||
"typeVersion": 2,
|
"typeVersion": 2,
|
||||||
"position": [
|
"position": [
|
||||||
2832,
|
2368,
|
||||||
1840
|
240
|
||||||
]
|
],
|
||||||
|
"id": "f60878f4-8cca-43d0-b8b3-0aa1a422237b",
|
||||||
|
"name": "Parse ingest"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForEachItem",
|
||||||
|
"jsCode": "const d = $json;\nlet n;\nif (d.status === 'ok') {\n n = { title: `Ingest ${d.slug}: PR aperta`, priority: 'default', tags: 'inbox_tray',\n body: `\\u2705 ${d.slug}: PR aperta (lint ${d.lint_clean ? 'clean' : 'KO'}${d.conflict ? ', CONFLITTO' : ''})\\n\\n\\ud83d\\udd17 ${d.pr_url}` };\n} else if (d.status === 'pr_failed') {\n n = { title: `Ingest ${d.slug}: PR FALLITA`, priority: 'high', tags: 'warning',\n body: `\\u26a0\\ufe0f ${d.slug}: semantic/lint ok ma PR non aperta.\\n\\n${(d.detail || '').split('\\n')[0]}` };\n} else {\n n = { title: 'Ingest: ERRORE', priority: 'high', tags: 'rotating_light',\n body: `\\u274c ${d.reason || 'errore'}\\n\\n${(d.raw || '').slice(0,300)}` };\n}\nreturn n;"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
2592,
|
||||||
|
240
|
||||||
|
],
|
||||||
|
"id": "9018dff6-b314-4ca8-b8ff-fd5423818816",
|
||||||
|
"name": "Build ntfy"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"url": "=http://ntfy/{{ $json.topic }}",
|
"url": "http://ntfy/homelab-genome",
|
||||||
"authentication": "genericCredentialType",
|
"authentication": "genericCredentialType",
|
||||||
"genericAuthType": "httpBearerAuth",
|
"genericAuthType": "httpBearerAuth",
|
||||||
"sendHeaders": true,
|
"sendHeaders": true,
|
||||||
|
|
@ -271,18 +195,6 @@
|
||||||
{
|
{
|
||||||
"name": "Tags",
|
"name": "Tags",
|
||||||
"value": "={{ $json.tags }}"
|
"value": "={{ $json.tags }}"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Click",
|
|
||||||
"value": "={{ $json.click }}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Actions",
|
|
||||||
"value": "={{ $json.actions }}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Markdown",
|
|
||||||
"value": "yes"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -290,18 +202,16 @@
|
||||||
"contentType": "raw",
|
"contentType": "raw",
|
||||||
"rawContentType": "Raw / Text",
|
"rawContentType": "Raw / Text",
|
||||||
"body": "={{ $json.body }}",
|
"body": "={{ $json.body }}",
|
||||||
"options": {
|
"options": {}
|
||||||
"timeout": 15000
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"id": "9cd2bde3-6846-4855-ad01-e3a4cdbce208",
|
|
||||||
"name": "ntfy: send",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
"typeVersion": 4.4,
|
"typeVersion": 4.4,
|
||||||
"position": [
|
"position": [
|
||||||
3056,
|
2800,
|
||||||
1840
|
240
|
||||||
],
|
],
|
||||||
|
"id": "1f572cb3-741b-46bc-87fa-1e23ade5a9be",
|
||||||
|
"name": "ntfy: send notification",
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"httpHeaderAuth": {
|
"httpHeaderAuth": {
|
||||||
"id": "TBPXSWOF63k9mvm8",
|
"id": "TBPXSWOF63k9mvm8",
|
||||||
|
|
@ -312,6 +222,53 @@
|
||||||
"name": "Bearer Auth account"
|
"name": "Bearer Auth account"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "dd000000-0000-4000-8000-000000000001",
|
||||||
|
"leftValue": "={{ $json._kind }}",
|
||||||
|
"rightValue": "ingest",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
3168,
|
||||||
|
240
|
||||||
|
],
|
||||||
|
"id": "d2d2e2b2-9bd7-446b-b6b8-0a865d49c601",
|
||||||
|
"name": "IF: nome valido"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForEachItem",
|
||||||
|
"jsCode": "const d=$json;\nreturn {\n title: 'Ingest: nome file non valido',\n priority: 'high',\n tags: 'warning',\n body: `\\u26a0\\ufe0f \"${d.raw}\" ha spazi o caratteri non ammessi e non e' stato ingerito.\\n\\nRinominalo in: ${d.hint}`\n};"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
3392,
|
||||||
|
400
|
||||||
|
],
|
||||||
|
"id": "3b69aa97-170a-4666-8b0e-4b51b48b2817",
|
||||||
|
"name": "Build ntfy badname"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pinData": {},
|
"pinData": {},
|
||||||
|
|
@ -320,14 +277,14 @@
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "Gate push",
|
"node": "IF: ref == develop",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Gate push": {
|
"IF: ref == develop": {
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -342,14 +299,14 @@
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "SSH: pending-raw",
|
"node": "SSH: changed-raw",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"SSH: pending-raw": {
|
"SSH: changed-raw": {
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -364,18 +321,51 @@
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "Nome valido?",
|
"node": "IF: nome valido",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Nome valido?": {
|
"SSH: pi ingest": {
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "Run one ingest",
|
"node": "Parse ingest",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse ingest": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build ntfy",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build ntfy": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "ntfy: send notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"IF: nome valido": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "SSH: pi ingest",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
|
|
@ -393,7 +383,7 @@
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"node": "ntfy: send",
|
"node": "ntfy: send notification",
|
||||||
"type": "main",
|
"type": "main",
|
||||||
"index": 0
|
"index": 0
|
||||||
}
|
}
|
||||||
|
|
@ -404,13 +394,9 @@
|
||||||
"active": true,
|
"active": true,
|
||||||
"settings": {
|
"settings": {
|
||||||
"executionOrder": "v1",
|
"executionOrder": "v1",
|
||||||
"binaryMode": "separate",
|
"binaryMode": "separate"
|
||||||
"timeSavedMode": "fixed",
|
|
||||||
"errorWorkflow": "7Vws3gCX3QnjM3oD",
|
|
||||||
"callerPolicy": "workflowsFromSameOwner",
|
|
||||||
"availableInMCP": false
|
|
||||||
},
|
},
|
||||||
"versionId": "63863925-606f-4200-824c-52f1919f2bb1",
|
"versionId": "2115dd9f-e2b6-4acb-8de0-4a166eb9729a",
|
||||||
"meta": {
|
"meta": {
|
||||||
"instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417"
|
"instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Genome: on-error",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {},
|
|
||||||
"id": "f715ed51-95e6-475f-8aa5-d0df531cc7cf",
|
|
||||||
"name": "Error Trigger",
|
|
||||||
"type": "n8n-nodes-base.errorTrigger",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
688,
|
|
||||||
-32
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"mode": "runOnceForEachItem",
|
|
||||||
"jsCode": "// Global error handler: set this workflow as the \"Error Workflow\" in each genome workflow's\n// Settings. Catches ANY node failure (SSH down, Forgejo 4xx/5xx, etc.) and notifies once.\n// Run Once for Each Item: $json is the error trigger payload.\nconst e = $json.execution || {};\nconst w = $json.workflow || {};\n\n// Safely extract error message from various shapes\nconst rawMsg = (e.error && (e.error.message || e.error.description)) || 'errore sconosciuto';\nconst msg = String(rawMsg).trim();\n\nconst lastNode = e.lastNodeExecuted ? ` (nodo: ${e.lastNodeExecuted})` : '';\nconst workflowName = w.name || 'n8n';\nconst executionUrl = e.url || '';\n\n// Escape markdown to avoid breaking the notification body\nconst msgEsc = msg.replace(/`/g, '\\`').replace(/\\n/g, '\\n');\n\nreturn {\n topic: 'genome-ingest',\n title: `Workflow KO · ${workflowName}`,\n priority: 'high',\n tags: 'rotating_light',\n click: executionUrl,\n actions: executionUrl ? `view, Apri l'esecuzione, ${executionUrl}` : '',\n body: `**${workflowName}** è fallito${lastNode}.\\n\\n${msgEsc}`\n};"
|
|
||||||
},
|
|
||||||
"id": "dd39bc0f-918a-4645-8f04-540ac9089311",
|
|
||||||
"name": "Build ntfy",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
928,
|
|
||||||
-32
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "a9ee90f3-d7fe-445d-96af-12caef46473f",
|
|
||||||
"name": "ntfy: send",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
1152,
|
|
||||||
-32
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "TBPXSWOF63k9mvm8",
|
|
||||||
"name": "ntfy-token"
|
|
||||||
},
|
|
||||||
"httpBearerAuth": {
|
|
||||||
"id": "nCv4CUN7Ef086Ewj",
|
|
||||||
"name": "Bearer Auth account"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pinData": {},
|
|
||||||
"connections": {
|
|
||||||
"Error Trigger": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Build ntfy",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Build ntfy": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "ntfy: send",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active": true,
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1",
|
|
||||||
"binaryMode": "separate"
|
|
||||||
},
|
|
||||||
"versionId": "036161c9-c934-474e-9b4f-634259f2a866",
|
|
||||||
"meta": {
|
|
||||||
"instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417"
|
|
||||||
},
|
|
||||||
"id": "7Vws3gCX3QnjM3oD",
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Genome: prune",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"httpMethod": "POST",
|
|
||||||
"path": "forgejo-push-prune",
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "d31388b9-c6d6-4f28-9a6c-b381922bf5e0",
|
|
||||||
"name": "Webhook prune",
|
|
||||||
"type": "n8n-nodes-base.webhook",
|
|
||||||
"typeVersion": 2.1,
|
|
||||||
"position": [
|
|
||||||
1232,
|
|
||||||
-64
|
|
||||||
],
|
|
||||||
"webhookId": "d6ac11900058434e"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "// Gate: proceed ONLY on develop pushes that REMOVED at least one file under raw/.\n// Additions/modifications are handled by the ingest flow; this flow reacts to deletions only.\nconst item = $input.first().json;\nconst b = item.body || item;\nconst ref = String(b.ref || '');\nconst genome = String((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 removed paths safely\nconst removed = [];\nfor (const c of (b.commits || [])) {\n if (!c || !Array.isArray(c.removed)) continue;\n for (const p of c.removed) {\n if (typeof p === 'string' && p.startsWith('raw/')) {\n removed.push(p);\n }\n }\n}\n\n// Gate: stop if nothing under raw/ was removed\nif (removed.length === 0) return [];\n\nreturn [{ json: { genome, removedCount: removed.length } }];"
|
|
||||||
},
|
|
||||||
"id": "84848a31-d099-459e-bd03-67abc2cf2b77",
|
|
||||||
"name": "Gate prune",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
1456,
|
|
||||||
-64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "175e4191-eb1b-4e5d-8d82-c39205753152",
|
|
||||||
"name": "Power Manager - ensure-on",
|
|
||||||
"type": "n8n-nodes-base.executeWorkflow",
|
|
||||||
"typeVersion": 1.3,
|
|
||||||
"position": [
|
|
||||||
1680,
|
|
||||||
-64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"authentication": "privateKey",
|
|
||||||
"command": "=ssh vm101 'pi orphan-wiki {{ $('Gate prune').first().json.genome }}'"
|
|
||||||
},
|
|
||||||
"id": "598f20f8-d668-48da-90e3-1bfada3ace92",
|
|
||||||
"name": "SSH: orphan-wiki",
|
|
||||||
"type": "n8n-nodes-base.ssh",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
1904,
|
|
||||||
-64
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"sshPrivateKey": {
|
|
||||||
"id": "GJQjKzte7Hjdfz89",
|
|
||||||
"name": "n8n container -> n8n-runner@nexus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"jsCode": "// Gate: proceed to prune only if orphan-wiki actually found orphans.\n// run-prune re-derives independently anyway (no detected-vs-pruned race);\n// this gate just avoids taking the lock for nothing.\nconst out = String($input.first().json.stdout || '').trim();\nlet d;\n\ntry {\n d = JSON.parse(out);\n} catch (e) {\n // Malformed JSON from orphan-wiki — log and stop\n return [{ json: { _gate: 'parse-error', raw: out.substring(0, 500) } }];\n}\n\n// Strict validation: d must be object with numeric count > 0\nif (!d || typeof d !== 'object' || typeof d.count !== 'number' || d.count <= 0) {\n return []; // 0 orphans or missing count -> stop silently\n}\n\nreturn [{ json: { genome: d.genome, count: d.count } }];"
|
|
||||||
},
|
|
||||||
"id": "3b644d61-26d8-4024-baed-bcb4ad169a6a",
|
|
||||||
"name": "Orfani?",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
2112,
|
|
||||||
-64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"authentication": "privateKey",
|
|
||||||
"command": "=ssh vm101 'pi prune {{ $json.genome }}'"
|
|
||||||
},
|
|
||||||
"id": "a8cae2c2-6f2f-4ef6-add9-287195aa84b5",
|
|
||||||
"name": "SSH: prune",
|
|
||||||
"type": "n8n-nodes-base.ssh",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
2336,
|
|
||||||
-64
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"sshPrivateKey": {
|
|
||||||
"id": "GJQjKzte7Hjdfz89",
|
|
||||||
"name": "n8n container -> n8n-runner@nexus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"mode": "runOnceForEachItem",
|
|
||||||
"jsCode": "// Extract the last JSON line from SSH stdout (the command may print logs before/after).\n// Run Once for Each Item: $json is the current SSH result item.\nconst out = String($json.stdout || '').trim();\nconst jsonLines = out\n .split('\\n')\n .map(l => l.trim())\n .filter(l => l.startsWith('{') && l.endsWith('}'));\n\nconst line = jsonLines.pop(); // last JSON object line (command prints JSON last)\n\nlet r;\ntry {\n r = line ? JSON.parse(line) : { status: 'error', reason: 'nessuna riga JSON trovata in stdout' };\n} catch (e) {\n r = { status: 'error', reason: 'JSON non parsabile', rawLine: line?.substring(0, 1000) };\n}\n\n// Ensure consistent shape for downstream nodes\nreturn {\n status: r.status || 'error',\n reason: r.reason || 'errore sconosciuto',\n count: r.count,\n pr_url: r.pr_url,\n genome: r.genome,\n _raw: line?.substring(0, 500)\n};"
|
|
||||||
},
|
|
||||||
"id": "da1ab42c-32e1-4c4d-82a1-925fcee1a098",
|
|
||||||
"name": "Parse prune",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
2560,
|
|
||||||
-64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"mode": "runOnceForEachItem",
|
|
||||||
"jsCode": "// Build ntfy notification for genome pruning.\n// Run Once for Each Item: $json is the parsed prune result.\nconst d = $json;\nconst genome = d.genome || 'unknown';\n\nlet n;\nif (d.status === 'ok') {\n const pm = (d.pr_url || '').match(/\\/pulls\\/(\\d+)/);\n const num = pm ? `#${pm[1]}` : '';\n n = {\n topic: 'genome-ingest',\n title: `${genome} \\u00b7 potatura ${num}`.replace(/\\s+/g, ' ').trim(),\n priority: 'default',\n tags: 'broom',\n click: d.pr_url || '',\n actions: d.pr_url ? `view, Apri la PR, ${d.pr_url}` : '',\n body: `${d.count} sorgente/i orfane proposte per la rimozione. **Approva la PR** per potare, oppure chiudila da Forgejo per annullare.`\n };\n} else {\n n = {\n topic: 'genome-ingest',\n title: `${genome} \\u00b7 errore potatura`.trim(),\n priority: 'high',\n tags: 'rotating_light',\n click: '',\n actions: '',\n body: `${d.reason || 'errore sconosciuto durante la potatura'}.`\n };\n}\n\nreturn n;"
|
|
||||||
},
|
|
||||||
"id": "ebe99407-6038-4f8f-a73f-7dc7b0a011e0",
|
|
||||||
"name": "Build ntfy",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
2784,
|
|
||||||
-64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "0bd3654e-a73d-4c3a-83ed-9f57ca4aad24",
|
|
||||||
"name": "ntfy: send",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
2992,
|
|
||||||
-64
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "TBPXSWOF63k9mvm8",
|
|
||||||
"name": "ntfy-token"
|
|
||||||
},
|
|
||||||
"httpBearerAuth": {
|
|
||||||
"id": "nCv4CUN7Ef086Ewj",
|
|
||||||
"name": "Bearer Auth account"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pinData": {},
|
|
||||||
"connections": {
|
|
||||||
"Webhook prune": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Gate prune",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Gate prune": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Power Manager - ensure-on",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Power Manager - ensure-on": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "SSH: orphan-wiki",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"SSH: orphan-wiki": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Orfani?",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Orfani?": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "SSH: prune",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"SSH: prune": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Parse prune",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Parse prune": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Build ntfy",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Build ntfy": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "ntfy: send",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active": true,
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1",
|
|
||||||
"binaryMode": "separate",
|
|
||||||
"timeSavedMode": "fixed",
|
|
||||||
"errorWorkflow": "7Vws3gCX3QnjM3oD",
|
|
||||||
"callerPolicy": "workflowsFromSameOwner",
|
|
||||||
"availableInMCP": false
|
|
||||||
},
|
|
||||||
"versionId": "999f640c-aae6-42aa-9a95-aba26987e9d0",
|
|
||||||
"meta": {
|
|
||||||
"instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417"
|
|
||||||
},
|
|
||||||
"id": "smH5Qrv7CQnTtdAF",
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
222
deploy/n8n/genome-raw-commit.json
Normal file
222
deploy/n8n/genome-raw-commit.json
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
{
|
||||||
|
"name": "Genome: raw → commit",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rule": {
|
||||||
|
"interval": [
|
||||||
|
{
|
||||||
|
"field": "cronExpression",
|
||||||
|
"expression": "*/2 * * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.scheduleTrigger",
|
||||||
|
"typeVersion": 1.3,
|
||||||
|
"position": [
|
||||||
|
384,
|
||||||
|
1056
|
||||||
|
],
|
||||||
|
"id": "520c79c8-76e6-41c0-8836-4d8d8f4ed236",
|
||||||
|
"name": "Schedule: ogni 2 min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "privateKey",
|
||||||
|
"command": "sudo -u homelab -H /usr/local/bin/genome-raw-commit genome-test"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.ssh",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
608,
|
||||||
|
1056
|
||||||
|
],
|
||||||
|
"id": "fe89a85f-d63e-47d9-a7b4-08222f2635d0",
|
||||||
|
"name": "SSH: genome-raw-commit",
|
||||||
|
"executeOnce": true,
|
||||||
|
"credentials": {
|
||||||
|
"sshPrivateKey": {
|
||||||
|
"id": "GJQjKzte7Hjdfz89",
|
||||||
|
"name": "n8n container -> n8n-runner@nexus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "// Lo script ora stampa JSON multilinea (jq -n). git manda i progressi su stderr,\n// quindi stdout e' SOLO il JSON: si parsa per intero.\nconst out = ($input.first().json.stdout || '').trim();\nlet data;\ntry {\n data = JSON.parse(out);\n} catch (e) {\n data = { status: 'error', reason: 'output non parsabile', genome: 'genome-test', raw: out };\n}\nreturn [{ json: data }];"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
832,
|
||||||
|
1056
|
||||||
|
],
|
||||||
|
"id": "74051cc5-5760-453d-80e4-0696d31bfc15",
|
||||||
|
"name": "Parse result"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c0000000-0000-4000-8000-000000000001",
|
||||||
|
"leftValue": "={{ $json.status }}",
|
||||||
|
"rightValue": "noop",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "notEquals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [
|
||||||
|
1056,
|
||||||
|
1056
|
||||||
|
],
|
||||||
|
"id": "5813753d-f015-4a4e-b386-9d60659077c3",
|
||||||
|
"name": "IF: non noop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const d = $input.first().json;\nlet n;\nif (d.status === 'ok') {\n const f = d.files && d.files[0];\n n = {\n title: `Genome: ${d.commits} raw -> ${d.base}`,\n priority: 'default',\n tags: 'inbox_tray',\n body: `✅ ${d.genome}: ${d.commits} commit su ${d.base} (HEAD ${d.head})\\n\\n${d.summary || ''}`\n + (f ? `\\n\\n🔗 Forgejo: ${f.remote_url}\\n📂 Locale: ${f.local_url}` : '')\n };\n} else {\n n = {\n title: 'Genome raw commit: ERRORE',\n priority: 'high',\n tags: 'warning',\n body: `\\u274C ${d.genome || 'genome-test'}: ${d.reason || 'errore sconosciuto'}`\n };\n}\nreturn [{ json: n }];"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
1264,
|
||||||
|
976
|
||||||
|
],
|
||||||
|
"id": "29eee748-4c2d-4e1e-8013-a64bc9cbf816",
|
||||||
|
"name": "Build ntfy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://ntfy/homelab-genome",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpBearerAuth",
|
||||||
|
"sendHeaders": true,
|
||||||
|
"headerParameters": {
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "Title",
|
||||||
|
"value": "={{ $json.title }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Priority",
|
||||||
|
"value": "={{ $json.priority }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tags",
|
||||||
|
"value": "={{ $json.tags }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sendBody": true,
|
||||||
|
"contentType": "raw",
|
||||||
|
"rawContentType": "Raw / Text",
|
||||||
|
"body": "={{ $json.body }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.4,
|
||||||
|
"position": [
|
||||||
|
1488,
|
||||||
|
976
|
||||||
|
],
|
||||||
|
"id": "d9b6ca21-59ef-44cf-a4f7-a75dcc7eeab4",
|
||||||
|
"name": "ntfy: send notification",
|
||||||
|
"credentials": {
|
||||||
|
"httpHeaderAuth": {
|
||||||
|
"id": "TBPXSWOF63k9mvm8",
|
||||||
|
"name": "ntfy-token"
|
||||||
|
},
|
||||||
|
"httpBearerAuth": {
|
||||||
|
"id": "nCv4CUN7Ef086Ewj",
|
||||||
|
"name": "Bearer Auth account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {},
|
||||||
|
"connections": {
|
||||||
|
"Schedule: ogni 2 min": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "SSH: genome-raw-commit",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SSH: genome-raw-commit": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse result": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "IF: non noop",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"IF: non noop": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Build ntfy",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Build ntfy": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "ntfy: send notification",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": true,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate"
|
||||||
|
},
|
||||||
|
"versionId": "9607be0b-cd8c-4e7a-9ddb-63b6ec22b65d",
|
||||||
|
"meta": {
|
||||||
|
"instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417"
|
||||||
|
},
|
||||||
|
"id": "whyxMpvJMYQ55J1M",
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Genome: run-one-ingest",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"inputSource": "passthrough"
|
|
||||||
},
|
|
||||||
"id": "b1b7ba8e-1e45-4f76-adc0-089180715975",
|
|
||||||
"name": "On ingest request",
|
|
||||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
|
||||||
"typeVersion": 1.1,
|
|
||||||
"position": [
|
|
||||||
224,
|
|
||||||
624
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"mode": "runOnceForEachItem",
|
|
||||||
"jsCode": "// SECURITY chokepoint: every ingest to vm101 passes here. Re-validate inputs (defense in depth:\n// callers + the SSH wrapper also validate) and assemble the exact command. Charset-validated\n// fields are safe inside the single-quoted remote command -> no shell injection.\n// Run Once for Each Item: $json is the current ingest request.\nconst d = $json || {};\nconst genome = String(d.genome || '').toLowerCase().trim();\nconst raw = String(d.raw || '');\nconst mode = String(d.mode || 'ingest');\nconst fb = String(d.feedback_b64 || '');\n\nconst okGenome = /^[a-z0-9][a-z0-9-]{0,63}$/.test(genome);\nconst okMode = (mode === 'ingest' || mode === 'rework');\nconst okRaw = raw.startsWith('raw/') && !raw.includes('..') && /^[A-Za-z0-9._\\/-]+$/.test(raw);\n// feedback_b64 is required only for rework mode; for ingest it can be empty\nconst okFb = (mode === 'ingest') || /^[A-Za-z0-9+/=]+$/.test(fb);\n\nif (!okGenome || !okMode || !okRaw || !okFb) {\n return {\n _ok: false,\n genome,\n mode,\n _reason: `bad input (genome:${okGenome} mode:${okMode} raw:${okRaw} fb:${okFb})`\n };\n}\n\n// Build SSH command: single-quoted remote command prevents shell injection\nconst ssh_cmd = (mode === 'rework')\n ? `ssh vm101 'pi ingest-rework ${genome} ${raw} ${fb}'`\n : `ssh vm101 'pi ingest ${genome} ${raw}'`;\n\nreturn {\n _ok: true,\n ssh_cmd,\n genome,\n raw,\n mode,\n reason: String(d.reason || ''),\n prevPr: String(d.prevPr || '')\n};"
|
|
||||||
},
|
|
||||||
"id": "8e538237-0e0e-4308-b2c8-631a52b31185",
|
|
||||||
"name": "Guard & build cmd",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
448,
|
|
||||||
624
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"conditions": {
|
|
||||||
"options": {
|
|
||||||
"caseSensitive": true,
|
|
||||||
"typeValidation": "loose",
|
|
||||||
"version": 2
|
|
||||||
},
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"id": "4507e3a8b9714c7e",
|
|
||||||
"leftValue": "={{ $json._ok }}",
|
|
||||||
"rightValue": true,
|
|
||||||
"operator": {
|
|
||||||
"type": "boolean",
|
|
||||||
"operation": "true",
|
|
||||||
"singleValue": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"combinator": "and"
|
|
||||||
},
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
"id": "4b249e76-7ab6-4aa3-886d-06b865931cf6",
|
|
||||||
"name": "Input valido?",
|
|
||||||
"type": "n8n-nodes-base.if",
|
|
||||||
"typeVersion": 2.2,
|
|
||||||
"position": [
|
|
||||||
672,
|
|
||||||
624
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"authentication": "privateKey",
|
|
||||||
"command": "={{ $json.ssh_cmd }}"
|
|
||||||
},
|
|
||||||
"id": "8740ae9a-4094-48b2-a9a4-d40d501e09f6",
|
|
||||||
"name": "SSH: ingest",
|
|
||||||
"type": "n8n-nodes-base.ssh",
|
|
||||||
"typeVersion": 1,
|
|
||||||
"position": [
|
|
||||||
880,
|
|
||||||
544
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"sshPrivateKey": {
|
|
||||||
"id": "GJQjKzte7Hjdfz89",
|
|
||||||
"name": "n8n container -> n8n-runner@nexus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"mode": "runOnceForEachItem",
|
|
||||||
"jsCode": "// run-ingest.sh prints one JSON line; the wrapper may instead print {status:busy|error,...}.\n// Take the last {...} line from stdout (logs may precede/follow).\n// Run Once for Each Item: $json is the current SSH result item.\nconst out = String($json.stdout || '').trim();\nconst jsonLines = out\n .split('\\n')\n .map(l => l.trim())\n .filter(l => l.startsWith('{') && l.endsWith('}'));\n\nconst line = jsonLines.pop(); // last JSON object line (command prints JSON last)\n\nlet r;\ntry {\n r = line ? JSON.parse(line) : { status: 'error', reason: 'nessuna riga JSON trovata in stdout', raw: out.substring(0, 500) };\n} catch (e) {\n r = { status: 'error', reason: 'JSON non parsabile', rawLine: line?.substring(0, 1000) };\n}\n\n// Ensure consistent shape for downstream Build ntfy\nreturn {\n status: r.status || 'error',\n reason: r.reason || 'errore sconosciuto',\n pr_url: r.pr_url || '',\n slug: r.slug || '',\n lint_clean: r.lint_clean || false,\n conflict: r.conflict || false,\n stage: r.stage || '',\n detail: r.detail || '',\n log: r.log || '',\n _raw: line?.substring(0, 500)\n};"
|
|
||||||
},
|
|
||||||
"id": "928344e3-0712-42e0-b1a8-f5caff489746",
|
|
||||||
"name": "Parse result",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
1104,
|
|
||||||
544
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"parameters": {
|
|
||||||
"mode": "runOnceForEachItem",
|
|
||||||
"jsCode": "// One builder for ingest + rework outcomes. Title is plain ASCII; the icon comes from Tags\n// (ntfy shortcodes); navigation is via Click (tap) + Actions (button) so it works on every\n// client.\n// Run Once for Each Item: $json is the current parsed result.\n// We read the original request context from the Guard node (same execution, no executeWorkflow in between).\nconst g = $('Guard & build cmd').item.json || {};\nconst verb = (g.mode === 'rework') ? 'rework' : 'ingest';\nconst d = $json || {};\nconst genome = g.genome || 'unknown';\n\n// Build notification based on status\nlet n;\n\nif (g._ok === false) {\n // Input validation failed (Guard & build cmd rejected it)\n n = {\n title: `Errore ${verb}: input non valido`,\n priority: 'high',\n tags: 'rotating_light',\n click: '',\n actions: '',\n body: `Richiesta di ${verb} rifiutata.\\n${g._reason || 'motivo sconosciuto'}`\n };\n} else if (d.status === 'ok') {\n // Success: PR opened\n const pm = (d.pr_url || '').match(/\\/pulls\\/(\\d+)/);\n const num = pm ? `#${pm[1]}` : '';\n const lint = d.lint_clean ? 'lint pulito' : 'lint con avvisi';\n const conflict = d.conflict ? ' · ⚠️ conflitto da risolvere' : '';\n const prevPr = g.prevPr ? ` · sostituisce #${g.prevPr}` : '';\n const reason = (g.reason && verb === 'ingest') ? ` (${g.reason})` : '';\n\n n = {\n title: `${genome} · ${verb} ${d.slug || ''} ${num}`.replace(/\\s+/g, ' ').trim(),\n priority: d.conflict ? 'high' : 'default',\n tags: d.conflict ? 'warning' : 'white_check_mark',\n click: d.pr_url || '',\n actions: d.pr_url ? `view, Apri la PR, ${d.pr_url}` : '',\n body: `**${d.slug || 'sorgente'}** ${verb === 'rework' ? 'rilavorata' : 'ingerita'}`\n + reason + prevPr\n + `.\\n${lint}${conflict}.`\n };\n} else if (d.status === 'busy') {\n // Another ingest is already running on this genome\n n = {\n title: `${genome} · ${verb} in coda`,\n priority: 'min',\n tags: 'hourglass_flowing_sand',\n click: '',\n actions: '',\n body: `Un altro ingest era in corso su questo genoma. La fonte resta pendente e verrà ripresa al prossimo campanello.`\n };\n} else if (d.status === 'pr_failed') {\n // Semantic/lint ok but PR could not be opened\n const detailLine = String(d.detail || '').split('\\n')[0] || 'dettaglio non disponibile';\n n = {\n title: `${genome} · ${d.slug || ''}: PR non aperta`,\n priority: 'high',\n tags: 'warning',\n click: '',\n actions: '',\n body: `Semantic e lint ok, ma la PR non si è aperta.\\n${detailLine}`\n };\n} else {\n // Generic error (including parse errors)\n const stage = d.stage ? ` (stage: ${d.stage})` : '';\n const log = d.log ? `\\nLog: ${d.log}` : '';\n n = {\n title: `${genome} · errore ${verb}`,\n priority: 'high',\n tags: 'rotating_light',\n click: '',\n actions: '',\n body: `${d.reason || 'errore sconosciuto'}${stage}.${log}`\n };\n}\n\nn.topic = 'genome-ingest';\nreturn n;"
|
|
||||||
},
|
|
||||||
"id": "9062dfba-02ba-4abc-8be6-828c0b353114",
|
|
||||||
"name": "Build ntfy",
|
|
||||||
"type": "n8n-nodes-base.code",
|
|
||||||
"typeVersion": 2,
|
|
||||||
"position": [
|
|
||||||
1328,
|
|
||||||
624
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "0c2b4d9b-2700-4815-b47c-8523bc4eb2ff",
|
|
||||||
"name": "ntfy: send",
|
|
||||||
"type": "n8n-nodes-base.httpRequest",
|
|
||||||
"typeVersion": 4.4,
|
|
||||||
"position": [
|
|
||||||
1552,
|
|
||||||
624
|
|
||||||
],
|
|
||||||
"credentials": {
|
|
||||||
"httpHeaderAuth": {
|
|
||||||
"id": "TBPXSWOF63k9mvm8",
|
|
||||||
"name": "ntfy-token"
|
|
||||||
},
|
|
||||||
"httpBearerAuth": {
|
|
||||||
"id": "nCv4CUN7Ef086Ewj",
|
|
||||||
"name": "Bearer Auth account"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pinData": {},
|
|
||||||
"connections": {
|
|
||||||
"On ingest request": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Guard & build cmd",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Guard & build cmd": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Input valido?",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Input valido?": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "SSH: ingest",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Build ntfy",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"SSH: ingest": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Parse result",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Parse result": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "Build ntfy",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Build ntfy": {
|
|
||||||
"main": [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"node": "ntfy: send",
|
|
||||||
"type": "main",
|
|
||||||
"index": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"active": true,
|
|
||||||
"settings": {
|
|
||||||
"executionOrder": "v1",
|
|
||||||
"binaryMode": "separate",
|
|
||||||
"timeSavedMode": "fixed",
|
|
||||||
"errorWorkflow": "7Vws3gCX3QnjM3oD",
|
|
||||||
"callerPolicy": "workflowsFromSameOwner",
|
|
||||||
"availableInMCP": false
|
|
||||||
},
|
|
||||||
"versionId": "fd8c1cf6-c5df-4074-b777-113349e32a03",
|
|
||||||
"meta": {
|
|
||||||
"instanceId": "96b2f0ec76a4400bbd481c617b24b3b87024cc7a913efacccaf9fc85722e7417"
|
|
||||||
},
|
|
||||||
"id": "VIi2ovb5gJxNJLbg",
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
|
|
@ -28,8 +28,7 @@ set -a; . "${HOME}/.config/knowledge-genome.env"; set +a
|
||||||
vault="${GENOME_VAULTS_ROOT}/${genome}"
|
vault="${GENOME_VAULTS_ROOT}/${genome}"
|
||||||
fid="${genome}-public"
|
fid="${genome}-public"
|
||||||
authors_map="${GENOME_VAULTS_ROOT}/.authors.json"
|
authors_map="${GENOME_VAULTS_ROOT}/.authors.json"
|
||||||
# GENOME_PUSH_URL is a test seam: defaults to the Forgejo loopback URL in production.
|
clone_url="http://${FORGEJO_USER}@${FORGEJO_HOST}/${FORGEJO_OWNER}/${genome}.git"
|
||||||
clone_url="${GENOME_PUSH_URL:-http://${FORGEJO_USER}@${FORGEJO_HOST}/${FORGEJO_OWNER}/${genome}.git}"
|
|
||||||
export GIT_ASKPASS=/usr/local/bin/genome-askpass
|
export GIT_ASKPASS=/usr/local/bin/genome-askpass
|
||||||
|
|
||||||
[[ -d "${vault}/.git" ]] || { printf '{"status":"error","reason":"vault absent","genome":"%s"}\n' "$genome"; exit 1; }
|
[[ -d "${vault}/.git" ]] || { printf '{"status":"error","reason":"vault absent","genome":"%s"}\n' "$genome"; exit 1; }
|
||||||
|
|
@ -43,30 +42,8 @@ grep -qxF 'raw/.stfolder' "${vault}/.git/info/exclude" 2>/dev/null || echo 'raw/
|
||||||
|
|
||||||
git add -A -- raw/
|
git add -A -- raw/
|
||||||
git reset -q -- raw/.stignore raw/.stfolder 2>/dev/null || true
|
git reset -q -- raw/.stignore raw/.stfolder 2>/dev/null || true
|
||||||
|
|
||||||
# --- Quiet window: only commit raw files that have STOPPED changing. ----------------
|
|
||||||
# While a note is being written (Obsidian autosave -> Syncthing -> here) its mtime stays
|
|
||||||
# fresh; we leave it UNSTAGED so a half-written note never triggers an ingest. A file is
|
|
||||||
# committed only after it has been still for RAW_QUIET_MINUTES. Deletions (nothing on disk)
|
|
||||||
# are stable by definition and pass straight through. Deterministic — no model in the loop.
|
|
||||||
quiet_min="${RAW_QUIET_MINUTES:-2}"
|
|
||||||
held=0
|
|
||||||
while IFS= read -r f; do
|
|
||||||
[[ -z "$f" ]] && continue
|
|
||||||
# Only an existing file can be "hot"; a staged deletion has nothing on disk to settle.
|
|
||||||
if [[ -e "$f" && -n "$(find "$f" -mmin -"$quiet_min" 2>/dev/null)" ]]; then
|
|
||||||
git reset -q -- "$f" 2>/dev/null || true
|
|
||||||
held=$((held+1))
|
|
||||||
fi
|
|
||||||
done < <(git diff --cached --name-only -- raw/)
|
|
||||||
|
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
if [[ "$held" -gt 0 ]]; then
|
|
||||||
printf '{"status":"noop","reason":"raw still settling","genome":"%s","held":%d,"quiet_minutes":%d}\n' \
|
|
||||||
"$genome" "$held" "$quiet_min"
|
|
||||||
else
|
|
||||||
printf '{"status":"noop","genome":"%s"}\n' "$genome"
|
printf '{"status":"noop","genome":"%s"}\n' "$genome"
|
||||||
fi
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,29 +79,6 @@ case "$cmd" in
|
||||||
# MECHANICAL step: validate manifest -> index/log/scoped-lint/commit/PR -> 1 JSON line
|
# MECHANICAL step: validate manifest -> index/log/scoped-lint/commit/PR -> 1 JSON line
|
||||||
exec "${HOME}/.pi/agent/skills/ingest/scripts/run-ingest.sh" "${genome}"
|
exec "${HOME}/.pi/agent/skills/ingest/scripts/run-ingest.sh" "${genome}"
|
||||||
;;
|
;;
|
||||||
"pi prune "*)
|
|
||||||
# Pota le source orfane. Stesso lock dell'ingest (serializza le scritture per genoma),
|
|
||||||
# clean_start, poi run-prune.sh (che ri-deriva gli orfani e apre una PR gated).
|
|
||||||
genome="${cmd#pi prune }"
|
|
||||||
case "$genome" in ""|*[!a-z0-9-]*) echo '{"status":"error","reason":"invalid genome name"}'; exit 1;; esac
|
|
||||||
logger -t n8n-pi-wrap "ok: pi prune ${genome}"
|
|
||||||
|
|
||||||
exec 9>"/run/lock/kg-ingest-${genome}.lock" 2>/dev/null || exec 9>"/tmp/kg-ingest-${genome}.lock"
|
|
||||||
if ! flock -n 9; then
|
|
||||||
echo '{"status":"busy","reason":"another ingest/prune is running for this genome","genome":"'"$genome"'"}'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a; . "${HOME}/.config/knowledge-genome.env"; set +a
|
|
||||||
cd "${GENOMES_ROOT}/${genome}" || { echo '{"status":"error","reason":"unknown genome"}'; exit 1; }
|
|
||||||
|
|
||||||
: "${KG_LIB_DIR:=${HOME}/knowledge-genome-orchestrator/lib}"
|
|
||||||
source "${KG_LIB_DIR}/clean-start.sh" 2>/dev/null \
|
|
||||||
|| { echo '{"status":"error","reason":"clean-start.sh not found"}'; exit 1; }
|
|
||||||
clean_start || { echo '{"status":"error","reason":"clean-start failed"}'; exit 1; }
|
|
||||||
|
|
||||||
exec "${HOME}/.pi/agent/skills/ingest/scripts/run-prune.sh" "${genome}"
|
|
||||||
;;
|
|
||||||
"pi ingest-rework "*)
|
"pi ingest-rework "*)
|
||||||
# args: <genome> <raw_path> <feedback_base64> (3 token).
|
# args: <genome> <raw_path> <feedback_base64> (3 token).
|
||||||
# Feedback in base64 nell'argv: il nodo SSH di n8n non passa stdin, e cosi' i metacaratteri
|
# Feedback in base64 nell'argv: il nodo SSH di n8n non passa stdin, e cosi' i metacaratteri
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# skills/ingest/scripts/index-append.py
|
# skills/ingest/scripts/index-append.py
|
||||||
# Insert OR remove an entry line in wiki/index.md, keeping the target section
|
# Insert an entry line into the correct section of wiki/index.md and keep that
|
||||||
# alphabetically ordered. Bumps frontmatter last_updated.
|
# section's entries alphabetically ordered. Bumps frontmatter last_updated.
|
||||||
#
|
#
|
||||||
# index-append.py --section Sources \
|
# index-append.py --section Sources \
|
||||||
# --entry '- [[sources/foo]] — One-line summary. `maturity: draft`'
|
# --entry '- [[sources/foo]] — One-line summary. `maturity: draft`'
|
||||||
# index-append.py --remove 'sources/foo' # delete the entry by wikilink
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
import argparse
|
import argparse
|
||||||
import datetime
|
import datetime
|
||||||
|
|
@ -18,116 +17,14 @@ LINK_RE = re.compile(r"^- \[\[([^\]]+)\]\]")
|
||||||
HEADER_RE = re.compile(r"^## ")
|
HEADER_RE = re.compile(r"^## ")
|
||||||
|
|
||||||
|
|
||||||
def bump_last_updated(lines, today):
|
|
||||||
"""Bump (or self-heal) last_updated inside the first frontmatter block."""
|
|
||||||
fm_open = False
|
|
||||||
fm_close_idx = None
|
|
||||||
bumped = False
|
|
||||||
for i, ln in enumerate(lines):
|
|
||||||
if ln.strip() == "---":
|
|
||||||
if not fm_open:
|
|
||||||
fm_open = True
|
|
||||||
continue
|
|
||||||
fm_close_idx = i
|
|
||||||
break
|
|
||||||
if fm_open and ln.startswith("last_updated:"):
|
|
||||||
lines[i] = f"last_updated: {today}"
|
|
||||||
bumped = True
|
|
||||||
if not fm_open:
|
|
||||||
print("index-append: warning: no frontmatter found, last_updated not bumped",
|
|
||||||
file=sys.stderr)
|
|
||||||
elif not bumped and fm_close_idx is not None:
|
|
||||||
lines.insert(fm_close_idx, f"last_updated: {today}")
|
|
||||||
print("index-append: last_updated key was missing — inserted", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def do_remove(lines, link, today):
|
|
||||||
"""Remove every entry line whose wikilink == link. Idempotent."""
|
|
||||||
bump_last_updated(lines, today)
|
|
||||||
kept = []
|
|
||||||
removed = 0
|
|
||||||
for ln in lines:
|
|
||||||
m = LINK_RE.match(ln)
|
|
||||||
if m and m.group(1) == link:
|
|
||||||
removed += 1
|
|
||||||
continue
|
|
||||||
kept.append(ln)
|
|
||||||
if removed:
|
|
||||||
print(f"index-append: removed [[{link}]] ({removed} line(s))")
|
|
||||||
else:
|
|
||||||
# Idempotent: the goal state (entry absent) already holds.
|
|
||||||
print(f"index-append: [[{link}]] not present, nothing to remove")
|
|
||||||
return kept
|
|
||||||
|
|
||||||
|
|
||||||
def do_append(lines, section, entry, today):
|
|
||||||
bump_last_updated(lines, today)
|
|
||||||
# Locate the target section [start, end)
|
|
||||||
start = None
|
|
||||||
for i, ln in enumerate(lines):
|
|
||||||
if HEADER_RE.match(ln) and ln[3:].startswith(section):
|
|
||||||
start = i
|
|
||||||
break
|
|
||||||
if start is None:
|
|
||||||
print(f"index-append: section '{section}' not found", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
end = len(lines)
|
|
||||||
for i in range(start + 1, len(lines)):
|
|
||||||
if HEADER_RE.match(lines[i]):
|
|
||||||
end = i
|
|
||||||
break
|
|
||||||
|
|
||||||
body = lines[start + 1:end]
|
|
||||||
intro = [ln for ln in body if not ENTRY_RE.match(ln)]
|
|
||||||
entries = [ln for ln in body if ENTRY_RE.match(ln)]
|
|
||||||
|
|
||||||
new_m = LINK_RE.match(entry)
|
|
||||||
new_link = new_m.group(1) if new_m else None
|
|
||||||
|
|
||||||
if new_link is not None:
|
|
||||||
replaced = False
|
|
||||||
for idx, ln in enumerate(entries):
|
|
||||||
m = LINK_RE.match(ln)
|
|
||||||
if m and m.group(1) == new_link:
|
|
||||||
if ln == entry:
|
|
||||||
print("index-append: entry already present, skipping")
|
|
||||||
return lines
|
|
||||||
entries[idx] = entry
|
|
||||||
replaced = True
|
|
||||||
break
|
|
||||||
if not replaced:
|
|
||||||
entries.append(entry)
|
|
||||||
else:
|
|
||||||
if entry in entries:
|
|
||||||
print("index-append: entry already present, skipping")
|
|
||||||
return lines
|
|
||||||
entries.append(entry)
|
|
||||||
|
|
||||||
entries.sort(key=str.casefold)
|
|
||||||
while intro and intro[-1].strip() == "":
|
|
||||||
intro.pop()
|
|
||||||
new_section = intro + [""] + entries + [""]
|
|
||||||
print(f"index-append: added to {section}")
|
|
||||||
return lines[:start + 1] + new_section + lines[end:]
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--section", help="Section name (required with --entry)")
|
ap.add_argument("--section", required=True,
|
||||||
ap.add_argument("--entry", help="Full index line to insert")
|
help="Section name, e.g. Sources / Entities / Concepts / Queries / Conflicts")
|
||||||
ap.add_argument("--remove", metavar="WIKILINK",
|
ap.add_argument("--entry", required=True, help="Full index line to insert")
|
||||||
help="Remove the entry with this wikilink, e.g. sources/foo")
|
|
||||||
ap.add_argument("--file", default="wiki/index.md")
|
ap.add_argument("--file", default="wiki/index.md")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if bool(args.remove) == bool(args.entry):
|
|
||||||
print("index-append: provide exactly one of --entry or --remove", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
if args.entry and not args.section:
|
|
||||||
print("index-append: --entry requires --section", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(args.file, encoding="utf-8") as fh:
|
with open(args.file, encoding="utf-8") as fh:
|
||||||
lines = fh.read().splitlines()
|
lines = fh.read().splitlines()
|
||||||
|
|
@ -136,15 +33,90 @@ def main() -> int:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
today = datetime.date.today().isoformat()
|
today = datetime.date.today().isoformat()
|
||||||
if args.remove:
|
|
||||||
out = do_remove(lines, args.remove, today)
|
# 1. Bump last_updated inside the first frontmatter block
|
||||||
else:
|
fm_open = False
|
||||||
out = do_append(lines, args.section, args.entry, today)
|
fm_close_idx = None
|
||||||
if out is None:
|
bumped = False
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if ln.strip() == "---":
|
||||||
|
if not fm_open:
|
||||||
|
fm_open = True
|
||||||
|
continue
|
||||||
|
fm_close_idx = i # the closing ---
|
||||||
|
break
|
||||||
|
if fm_open and ln.startswith("last_updated:"):
|
||||||
|
lines[i] = f"last_updated: {today}"
|
||||||
|
bumped = True
|
||||||
|
|
||||||
|
if not fm_open:
|
||||||
|
print("index-append: warning: no frontmatter found, last_updated not bumped",
|
||||||
|
file=sys.stderr)
|
||||||
|
elif not bumped and fm_close_idx is not None:
|
||||||
|
# self-heal: frontmatter present but missing the key — insert it before the close
|
||||||
|
lines.insert(fm_close_idx, f"last_updated: {today}")
|
||||||
|
print("index-append: last_updated key was missing — inserted", file=sys.stderr)
|
||||||
|
|
||||||
|
# 2. Locate the target section [start, end)
|
||||||
|
start = None
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if HEADER_RE.match(ln) and ln[3:].startswith(args.section):
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
if start is None:
|
||||||
|
print(f"index-append: section '{args.section}' not found in {args.file}",
|
||||||
|
file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
end = len(lines)
|
||||||
|
for i in range(start + 1, len(lines)):
|
||||||
|
if HEADER_RE.match(lines[i]):
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# 3. Split the section body into intro (non-entry) and entries
|
||||||
|
body = lines[start + 1:end]
|
||||||
|
intro = [ln for ln in body if not ENTRY_RE.match(ln)]
|
||||||
|
entries = [ln for ln in body if ENTRY_RE.match(ln)]
|
||||||
|
|
||||||
|
# Deduplicate by wikilink PATH, not by exact line: a re-ingest with a changed
|
||||||
|
# summary/maturity should UPDATE the existing entry, not add a duplicate line.
|
||||||
|
new_m = LINK_RE.match(args.entry)
|
||||||
|
new_link = new_m.group(1) if new_m else None
|
||||||
|
|
||||||
|
if new_link is not None:
|
||||||
|
replaced = False
|
||||||
|
for idx, ln in enumerate(entries):
|
||||||
|
m = LINK_RE.match(ln)
|
||||||
|
if m and m.group(1) == new_link:
|
||||||
|
if ln == args.entry:
|
||||||
|
print("index-append: entry already present, skipping")
|
||||||
|
return 0
|
||||||
|
entries[idx] = args.entry # same page, refreshed text
|
||||||
|
replaced = True
|
||||||
|
break
|
||||||
|
if not replaced:
|
||||||
|
entries.append(args.entry)
|
||||||
|
else:
|
||||||
|
# No parseable wikilink — fall back to exact-line dedup.
|
||||||
|
if args.entry in entries:
|
||||||
|
print("index-append: entry already present, skipping")
|
||||||
|
return 0
|
||||||
|
entries.append(args.entry)
|
||||||
|
|
||||||
|
entries.sort(key=str.casefold)
|
||||||
|
|
||||||
|
# Normalise intro: drop trailing blanks, keep header + comment(s)
|
||||||
|
while intro and intro[-1].strip() == "":
|
||||||
|
intro.pop()
|
||||||
|
|
||||||
|
new_section = intro + [""] + entries + [""]
|
||||||
|
lines = lines[:start + 1] + new_section + lines[end:]
|
||||||
|
|
||||||
with open(args.file, "w", encoding="utf-8") as fh:
|
with open(args.file, "w", encoding="utf-8") as fh:
|
||||||
fh.write("\n".join(out) + "\n")
|
fh.write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
print(f"index-append: added to {args.section}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,15 +60,6 @@ with open(raw_rel, "r", encoding="utf-8") as fh:
|
||||||
if not source_text.strip():
|
if not source_text.strip():
|
||||||
die("preflight", "source is empty: " + raw_rel)
|
die("preflight", "source is empty: " + raw_rel)
|
||||||
|
|
||||||
# --- pre-flight check: if the prompt exceeds context window, exit cleanly with stage:input ---
|
|
||||||
# Conservative estimate: ~4 chars/token for mixed IT/EN text
|
|
||||||
SAFETY_MARGIN = 4096 # room for system prompt + JSON response
|
|
||||||
MAX_SOURCE_TOKENS = NUM_CTX - SAFETY_MARGIN
|
|
||||||
MAX_SOURCE_CHARS = MAX_SOURCE_TOKENS * 4
|
|
||||||
|
|
||||||
if len(source_text) > MAX_SOURCE_CHARS:
|
|
||||||
die("input", f"source too large ({len(source_text)} chars, limit ~{MAX_SOURCE_CHARS}). "
|
|
||||||
f"Use the SPLIT directive or divide the document.")
|
|
||||||
|
|
||||||
# --- read existing index to avoid duplicate slugs ---
|
# --- read existing index to avoid duplicate slugs ---
|
||||||
existing_entities = set()
|
existing_entities = set()
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,10 @@ set -euo pipefail
|
||||||
: "${FORGEJO_USER:?missing FORGEJO_USER}"
|
: "${FORGEJO_USER:?missing FORGEJO_USER}"
|
||||||
: "${FORGEJO_TOKEN:?missing FORGEJO_TOKEN}"
|
: "${FORGEJO_TOKEN:?missing FORGEJO_TOKEN}"
|
||||||
|
|
||||||
slug="" title="" body_file="" base="main" label="" branch=""
|
slug="" title="" body_file="" base="main" label=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--slug) slug="$2"; shift 2 ;;
|
--slug) slug="$2"; shift 2 ;;
|
||||||
--branch) branch="$2"; shift 2 ;;
|
|
||||||
--title) title="$2"; shift 2 ;;
|
--title) title="$2"; shift 2 ;;
|
||||||
--body-file) body_file="$2"; shift 2 ;;
|
--body-file) body_file="$2"; shift 2 ;;
|
||||||
--base) base="$2"; shift 2 ;;
|
--base) base="$2"; shift 2 ;;
|
||||||
|
|
@ -29,23 +28,16 @@ while [[ $# -gt 0 ]]; do
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
: "${slug:?--slug required}"
|
||||||
: "${title:?--title required}"
|
: "${title:?--title required}"
|
||||||
: "${body_file:?--body-file required}"
|
: "${body_file:?--body-file required}"
|
||||||
[[ -f "$body_file" ]] || { echo "open-pr: body file not found: $body_file" >&2; exit 1; }
|
[[ -f "$body_file" ]] || { echo "open-pr: body file not found: $body_file" >&2; exit 1; }
|
||||||
|
|
||||||
# --branch overrides the default; otherwise derive the ingest branch from --slug.
|
|
||||||
# (run-prune passes its own chore/prune-orphans-* branch; run-ingest passes --slug.)
|
|
||||||
if [[ -z "$branch" ]]; then
|
|
||||||
: "${slug:?--slug or --branch required}"
|
|
||||||
branch="feat/ai-ingest-${slug}"
|
branch="feat/ai-ingest-${slug}"
|
||||||
fi
|
|
||||||
repo="$(basename -s .git "$(git config --get remote.origin.url)")"
|
repo="$(basename -s .git "$(git config --get remote.origin.url)")"
|
||||||
|
|
||||||
# 1. Branch + commit + push (AGENTS.md rule 5: never commit to main)
|
# 1. Branch + commit + push (AGENTS.md rule 5: never commit to main)
|
||||||
# Rolling PR: -C force-resets the branch label to the current base (we are on it after
|
git switch -c "$branch" 2>/dev/null || git switch "$branch"
|
||||||
# clean_start) and CARRIES the freshly-written wiki/ changes, so a re-ingest of the same
|
|
||||||
# source rebuilds the branch cleanly instead of hitting a dirty-switch refusal.
|
|
||||||
git switch -C "$branch"
|
|
||||||
git add wiki/
|
git add wiki/
|
||||||
# Scope BOTH the emptiness check and the commit to wiki/ — never commit anything that
|
# Scope BOTH the emptiness check and the commit to wiki/ — never commit anything that
|
||||||
# happened to be staged outside wiki/ (a stray hook, an aborted prior run, etc.).
|
# happened to be staged outside wiki/ (a stray hook, an aborted prior run, etc.).
|
||||||
|
|
@ -54,10 +46,7 @@ if git diff --cached --quiet -- wiki/; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
git commit -m "$title" -- wiki/
|
git commit -m "$title" -- wiki/
|
||||||
# Try a normal push (new branch / fast-forward). If the branch was rebuilt from base and
|
git push -u origin "$branch"
|
||||||
# diverged, force-with-lease updates the open PR in place — the lease refuses to clobber if
|
|
||||||
# origin moved unexpectedly since our fetch, so concurrent work is never lost.
|
|
||||||
git push -u origin "$branch" 2>/dev/null || git push -u --force-with-lease origin "$branch"
|
|
||||||
|
|
||||||
# DRY_RUN: local git work done; skip the Forgejo API (offline tests).
|
# DRY_RUN: local git work done; skip the Forgejo API (offline tests).
|
||||||
if [[ -n "${DRY_RUN:-}" ]]; then
|
if [[ -n "${DRY_RUN:-}" ]]; then
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# =============================================================================
|
|
||||||
# skills/ingest/scripts/run-prune.sh
|
|
||||||
# Symmetric companion to run-ingest: prune source pages whose raw source no
|
|
||||||
# longer exists. RE-DERIVES the orphan set itself (mirrors orphan-wiki.sh) — it
|
|
||||||
# never trusts a list handed in by n8n, so there is no "detected-vs-pruned"
|
|
||||||
# race. Removes ONLY the pages it derived plus their index entries, commits
|
|
||||||
# ONLY wiki/ on chore/prune-orphans-<date>, and opens a GATED removal PR (the
|
|
||||||
# operator approves the deletion; principle 2). Never deletes of its own accord.
|
|
||||||
#
|
|
||||||
# Runs OUTSIDE the model, on vm101, cwd = genome checkout. The wrapper (`pi
|
|
||||||
# prune`) has already taken the per-genome lock and done clean_start, exactly
|
|
||||||
# like `pi ingest` — so this script does neither.
|
|
||||||
#
|
|
||||||
# run-prune.sh <genome>
|
|
||||||
#
|
|
||||||
# Emits a single JSON result line on stdout for n8n to parse.
|
|
||||||
# =============================================================================
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
genome="${1:?usage: run-prune.sh <genome>}"
|
|
||||||
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
jq -nc --arg stage "$1" --arg reason "$2" '{status:"error", stage:$stage, reason:$reason}'
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
command -v jq >/dev/null 2>&1 || { echo '{"status":"error","reason":"jq missing"}'; exit 1; }
|
|
||||||
command -v python3 >/dev/null 2>&1 || fail "deps" "python3 missing (needed by index-append.py)"
|
|
||||||
|
|
||||||
# --- re-derive orphans (same rule as orphan-wiki.sh; computed fresh, here, now) ---
|
|
||||||
# A wiki/sources/*.md page is orphaned when its frontmatter source_path points at
|
|
||||||
# a raw file that no longer exists. Legacy pages without source_path are ignored.
|
|
||||||
declare -a ORPH=()
|
|
||||||
for page in wiki/sources/*.md; do
|
|
||||||
[[ -e "$page" ]] || continue
|
|
||||||
sp="$(sed -n 's/^source_path:[[:space:]]*//p' "$page" | tr -d '\r' | head -n1)"
|
|
||||||
[[ -n "$sp" ]] || continue
|
|
||||||
[[ -f "$sp" ]] || ORPH+=("$page")
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ${#ORPH[@]} -eq 0 ]]; then
|
|
||||||
jq -nc '{status:"ok", count:0, pruned:[], detail:"no orphans"}'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- remove each orphan page + its index entry (anti-traversal, wiki/-only) ---
|
|
||||||
declare -a PRUNED=()
|
|
||||||
for page in "${ORPH[@]}"; do
|
|
||||||
case "$page" in
|
|
||||||
wiki/*) : ;;
|
|
||||||
*) fail "prune" "refusing to remove outside wiki/: ${page}" ;;
|
|
||||||
esac
|
|
||||||
case "$page" in *..*) fail "prune" "path traversal in page: ${page}" ;; esac
|
|
||||||
[[ -f "$page" ]] || continue
|
|
||||||
rm -f "$page"
|
|
||||||
link="${page#wiki/}"; link="${link%.md}" # e.g. sources/foo
|
|
||||||
python3 "${SCRIPTS}/index-append.py" --remove "$link" \
|
|
||||||
|| fail "index" "index-append --remove failed for ${link}"
|
|
||||||
PRUNED+=("$link")
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- assemble the PR body ---
|
|
||||||
date_tag="$(date +%F)"
|
|
||||||
body="$(mktemp)"
|
|
||||||
trap 'rm -f "$body"' EXIT
|
|
||||||
{
|
|
||||||
echo "## Prune orphaned sources"
|
|
||||||
echo ""
|
|
||||||
echo "These source pages reference a \`source_path\` whose raw file no longer exists"
|
|
||||||
echo "in \`raw/\`. Removing them keeps the wiki in sync with git (the source of truth)."
|
|
||||||
echo ""
|
|
||||||
echo "| Removed page |"
|
|
||||||
echo "|--------------|"
|
|
||||||
for l in "${PRUNED[@]}"; do echo "| \`wiki/${l}.md\` |"; done
|
|
||||||
} > "$body"
|
|
||||||
|
|
||||||
# --- open the GATED removal PR on a chore/ branch (open-pr --branch override) ---
|
|
||||||
branch="chore/prune-orphans-${date_tag}"
|
|
||||||
pr_out="$( bash "${SCRIPTS}/open-pr.sh" \
|
|
||||||
--branch "$branch" \
|
|
||||||
--title "chore: prune ${#PRUNED[@]} orphaned source(s)" \
|
|
||||||
--body-file "$body" --base "${INGEST_BASE:-main}" 2>&1 )" && pr_rc=0 || pr_rc=$?
|
|
||||||
pr_url="$(printf '%s\n' "$pr_out" | sed -n 's/^PR opened: //p' | head -n1)"
|
|
||||||
|
|
||||||
# --- result line for n8n ---
|
|
||||||
jq -nc \
|
|
||||||
--arg status "$([[ $pr_rc -eq 0 ]] && echo ok || echo pr_failed)" \
|
|
||||||
--argjson count "${#PRUNED[@]}" \
|
|
||||||
--arg pr_url "$pr_url" \
|
|
||||||
--arg detail "$pr_out" \
|
|
||||||
--argjson pruned "$(printf '%s\n' "${PRUNED[@]}" | jq -R . | jq -s .)" \
|
|
||||||
'{status:$status, count:$count, pr_url:$pr_url, pruned:$pruned, detail:$detail}'
|
|
||||||
|
|
||||||
[[ $pr_rc -eq 0 ]] || exit 1
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
#!/usr/bin/env bats
|
|
||||||
# tests/index-remove.bats — index-append.py --remove mode.
|
|
||||||
setup() {
|
|
||||||
load 'helpers'
|
|
||||||
export GENOMES_ROOT="${BATS_TEST_TMPDIR}"
|
|
||||||
g_src="$(make_fixture_genome)"; export g="$g_src"
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "index --remove: deletes the matching entry, keeps the others" {
|
|
||||||
cd "$g"
|
|
||||||
python3 "$SKILL_SCRIPTS/index-append.py" --section Sources --entry '- [[sources/a]] — A. `maturity: draft`'
|
|
||||||
python3 "$SKILL_SCRIPTS/index-append.py" --section Sources --entry '- [[sources/b]] — B. `maturity: draft`'
|
|
||||||
grep -q 'sources/a' wiki/index.md
|
|
||||||
grep -q 'sources/b' wiki/index.md
|
|
||||||
|
|
||||||
run python3 "$SKILL_SCRIPTS/index-append.py" --remove 'sources/a'
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
! grep -q '\[\[sources/a\]\]' wiki/index.md
|
|
||||||
grep -q 'sources/b' wiki/index.md
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "index --remove: idempotent when the entry is absent" {
|
|
||||||
cd "$g"
|
|
||||||
run python3 "$SKILL_SCRIPTS/index-append.py" --remove 'sources/does-not-exist'
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *'nothing to remove'* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "index --remove: bumps last_updated" {
|
|
||||||
cd "$g"
|
|
||||||
python3 "$SKILL_SCRIPTS/index-append.py" --section Sources --entry '- [[sources/a]] — A. `maturity: draft`'
|
|
||||||
# set last_updated to an old date, then remove and check it moved
|
|
||||||
sed -i 's/^last_updated:.*/last_updated: 2000-01-01/' wiki/index.md
|
|
||||||
run python3 "$SKILL_SCRIPTS/index-append.py" --remove 'sources/a'
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
! grep -q '2000-01-01' wiki/index.md
|
|
||||||
grep -q "last_updated: $(date +%F)" wiki/index.md
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "index --remove: rejects passing both --entry and --remove" {
|
|
||||||
cd "$g"
|
|
||||||
run python3 "$SKILL_SCRIPTS/index-append.py" --section Sources --entry '- [[sources/a]] — x' --remove 'sources/a'
|
|
||||||
[ "$status" -eq 2 ]
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
#!/usr/bin/env bats
|
|
||||||
# open-pr-rolling.bats — a re-ingest of the same slug updates the OPEN PR's branch
|
|
||||||
# (force-with-lease) instead of failing. Uses the local bare remote from make_fixture_genome.
|
|
||||||
load helpers
|
|
||||||
setup_file() { :; }
|
|
||||||
|
|
||||||
@test "open-pr: re-ingest of the same slug rolls the branch forward (force-with-lease)" {
|
|
||||||
command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
||||||
G="$(make_fixture_genome)"; cd "$G"
|
|
||||||
export FORGEJO_URL="http://forgejo.local" FORGEJO_USER=u FORGEJO_TOKEN=t DRY_RUN=1
|
|
||||||
body="$(mktemp)"; echo body > "$body"
|
|
||||||
|
|
||||||
# first ingest of slug x (v1)
|
|
||||||
mkdir -p wiki/sources; printf 'v1\n' > wiki/sources/x.md
|
|
||||||
run bash "$SKILL_SCRIPTS/open-pr.sh" --slug x --title "feat: ingest x" --body-file "$body" --base main
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
git rev-parse --verify feat/ai-ingest-x
|
|
||||||
first="$(git rev-parse feat/ai-ingest-x)"
|
|
||||||
|
|
||||||
# simulate clean_start back to base, then an edited re-ingest (v2)
|
|
||||||
git switch -q main; git reset -q --hard origin/main; git clean -q -fd
|
|
||||||
printf 'v2-edited\n' > wiki/sources/x.md
|
|
||||||
run bash "$SKILL_SCRIPTS/open-pr.sh" --slug x --title "feat: ingest x" --body-file "$body" --base main
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
second="$(git rev-parse feat/ai-ingest-x)"
|
|
||||||
|
|
||||||
# the branch was REBUILT from base (diverged), not appended: second is not a descendant of first
|
|
||||||
run git merge-base --is-ancestor "$first" "$second"
|
|
||||||
[ "$status" -ne 0 ]
|
|
||||||
|
|
||||||
# origin received the v2 content (force-with-lease pushed the rebuilt branch)
|
|
||||||
git fetch -q origin
|
|
||||||
run git show "origin/feat/ai-ingest-x:wiki/sources/x.md"
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *"v2-edited"* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "open-pr: prune branch override still works after the rolling change" {
|
|
||||||
command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
||||||
G="$(make_fixture_genome)"; cd "$G"
|
|
||||||
export FORGEJO_URL="http://forgejo.local" FORGEJO_USER=u FORGEJO_TOKEN=t DRY_RUN=1
|
|
||||||
body="$(mktemp)"; echo body > "$body"
|
|
||||||
mkdir -p wiki/sources; printf 'p\n' > wiki/sources/p.md
|
|
||||||
run bash "$SKILL_SCRIPTS/open-pr.sh" --branch "chore/prune-orphans-2026-06-30" \
|
|
||||||
--title "chore: prune 1 orphaned source(s)" --body-file "$body" --base main
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
git rev-parse --verify "chore/prune-orphans-2026-06-30"
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
#!/usr/bin/env bats
|
|
||||||
# raw-commit-quiet.bats — quiet-window behaviour of genome-raw-commit.sh.
|
|
||||||
# No Syncthing (no API key -> default author); pushes to a local bare repo via GENOME_PUSH_URL.
|
|
||||||
setup() {
|
|
||||||
SCRIPT="${BATS_TEST_DIRNAME}/../deploy/nexus/genome-raw-commit.sh"
|
|
||||||
export HOME="${BATS_TEST_TMPDIR}/home"; mkdir -p "$HOME/.config"
|
|
||||||
root="${BATS_TEST_TMPDIR}/vaults"; mkdir -p "$root"
|
|
||||||
bare="${BATS_TEST_TMPDIR}/origin.git"; git init -q --bare "$bare"
|
|
||||||
cat > "$HOME/.config/knowledge-genome.env" <<EOF
|
|
||||||
GENOME_VAULTS_ROOT=$root
|
|
||||||
GENOME_BASE=main
|
|
||||||
FORGEJO_USER=n8n-bot
|
|
||||||
FORGEJO_HOST=127.0.0.1:3001
|
|
||||||
FORGEJO_OWNER=Keru
|
|
||||||
COMMITTER_NAME=n8n-bot
|
|
||||||
COMMITTER_EMAIL=n8n-bot@homelab
|
|
||||||
DEFAULT_AUTHOR_NAME=Tester
|
|
||||||
DEFAULT_AUTHOR_EMAIL=tester@local
|
|
||||||
EOF
|
|
||||||
export g="genome-test"; export vault="$root/$g"
|
|
||||||
git clone -q "$bare" "$vault" 2>/dev/null || mkdir -p "$vault"
|
|
||||||
( cd "$vault"
|
|
||||||
git init -q 2>/dev/null || true
|
|
||||||
git config user.name n8n-bot; git config user.email n8n-bot@homelab; git config commit.gpgsign false
|
|
||||||
git checkout -q -b main 2>/dev/null || git switch -q main
|
|
||||||
mkdir -p raw/articles; echo seed > raw/articles/.gitkeep
|
|
||||||
git add -A; git commit -q -m init
|
|
||||||
git remote add origin "$bare" 2>/dev/null || git remote set-url origin "$bare"
|
|
||||||
git push -q -u origin main )
|
|
||||||
export GENOME_PUSH_URL="$bare" # test seam -> push to the local bare repo
|
|
||||||
}
|
|
||||||
files() { ( cd "$vault" && git ls-files raw/ ) > "${BATS_TEST_TMPDIR}/f.txt"; }
|
|
||||||
|
|
||||||
@test "raw-commit: holds a freshly-written raw, commits it once it settles" {
|
|
||||||
command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
||||||
echo "still typing" > "$vault/raw/articles/hot.md" # fresh -> hot
|
|
||||||
echo "finished" > "$vault/raw/articles/stable.md"
|
|
||||||
touch -d "10 minutes ago" "$vault/raw/articles/stable.md" # settled
|
|
||||||
|
|
||||||
run bash "$SCRIPT" "$g"
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
echo "$output" | jq -e '.status=="ok"'
|
|
||||||
files
|
|
||||||
grep -q 'raw/articles/stable.md' "${BATS_TEST_TMPDIR}/f.txt" # committed
|
|
||||||
! grep -q 'raw/articles/hot.md' "${BATS_TEST_TMPDIR}/f.txt" # held back
|
|
||||||
|
|
||||||
touch -d "10 minutes ago" "$vault/raw/articles/hot.md" # now it settles
|
|
||||||
run bash "$SCRIPT" "$g"
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
files
|
|
||||||
grep -q 'raw/articles/hot.md' "${BATS_TEST_TMPDIR}/f.txt" # now committed
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "raw-commit: noop with held count while everything is still settling" {
|
|
||||||
command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
||||||
echo "typing" > "$vault/raw/articles/wip.md" # fresh -> hot
|
|
||||||
run bash "$SCRIPT" "$g"
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
echo "$output" | jq -e '.status=="noop"'
|
|
||||||
echo "$output" | jq -e '.held==1'
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "raw-commit: a deletion is committed immediately (not subject to the quiet window)" {
|
|
||||||
command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
||||||
# commit a settled file first
|
|
||||||
echo done > "$vault/raw/articles/old.md"; touch -d "10 minutes ago" "$vault/raw/articles/old.md"
|
|
||||||
run bash "$SCRIPT" "$g"; [ "$status" -eq 0 ]
|
|
||||||
files; grep -q 'raw/articles/old.md' "${BATS_TEST_TMPDIR}/f.txt"
|
|
||||||
# now delete it -> should commit the removal even though "just changed"
|
|
||||||
rm "$vault/raw/articles/old.md"
|
|
||||||
run bash "$SCRIPT" "$g"
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
echo "$output" | jq -e '.status=="ok"'
|
|
||||||
files; ! grep -q 'raw/articles/old.md' "${BATS_TEST_TMPDIR}/f.txt"
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
#!/usr/bin/env bats
|
|
||||||
# tests/run-prune.bats — prune orphaned sources (no LLM, no network; DRY_RUN).
|
|
||||||
setup() {
|
|
||||||
load 'helpers'
|
|
||||||
export PRUNE="${SKILL_SCRIPTS}/run-prune.sh"
|
|
||||||
export GENOMES_ROOT="${BATS_TEST_TMPDIR}"
|
|
||||||
export INGEST_BASE="main"
|
|
||||||
export KG_LIB_DIR="${LIB_DIR}"
|
|
||||||
export FORGEJO_URL="http://forgejo.local" FORGEJO_USER="u" FORGEJO_TOKEN="t"
|
|
||||||
export DRY_RUN=1
|
|
||||||
g_src="$(make_fixture_genome)"; export g_name="fixture-genome"
|
|
||||||
mv "$g_src" "${GENOMES_ROOT}/${g_name}"; export g="${GENOMES_ROOT}/${g_name}"
|
|
||||||
( cd "$g" && rm -f raw/articles/test.md && git add -A && git commit -q -m clear && git push -q )
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "run-prune: removes only the orphaned source + its index entry, opens a dry PR" {
|
|
||||||
command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
||||||
cd "$g"
|
|
||||||
# kept: raw exists. orphan: raw missing.
|
|
||||||
echo content > raw/articles/kept.md
|
|
||||||
h="$(sha256sum raw/articles/kept.md | cut -d' ' -f1)"
|
|
||||||
printf -- '---\nsource_path: raw/articles/kept.md\nsource_sha256: %s\n---\nbody\n' "$h" > wiki/sources/kept.md
|
|
||||||
printf -- '---\nsource_path: raw/articles/gone.md\nsource_sha256: abc\n---\nbody\n' > wiki/sources/orphan.md
|
|
||||||
python3 "$SKILL_SCRIPTS/index-append.py" --section Sources --entry '- [[sources/kept]] — kept. `maturity: draft`'
|
|
||||||
python3 "$SKILL_SCRIPTS/index-append.py" --section Sources --entry '- [[sources/orphan]] — orphan. `maturity: draft`'
|
|
||||||
git add -A && git commit -q -m setup && git push -q
|
|
||||||
|
|
||||||
run bash "$PRUNE" "$g_name"
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *'"status":"ok"'* ]]
|
|
||||||
[[ "$output" == *'"count":1'* ]]
|
|
||||||
|
|
||||||
# only the orphan page is gone
|
|
||||||
[ ! -f wiki/sources/orphan.md ]
|
|
||||||
[ -f wiki/sources/kept.md ]
|
|
||||||
# index reflects the removal
|
|
||||||
! grep -q 'sources/orphan' wiki/index.md
|
|
||||||
grep -q 'sources/kept' wiki/index.md
|
|
||||||
# committed on a chore/ branch (NOT feat/ai-ingest-*)
|
|
||||||
git rev-parse --verify "chore/prune-orphans-$(date +%F)"
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "run-prune: no orphans -> count 0 and no PR/branch" {
|
|
||||||
command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
||||||
cd "$g"
|
|
||||||
echo content > raw/articles/kept.md
|
|
||||||
h="$(sha256sum raw/articles/kept.md | cut -d' ' -f1)"
|
|
||||||
printf -- '---\nsource_path: raw/articles/kept.md\nsource_sha256: %s\n---\nbody\n' "$h" > wiki/sources/kept.md
|
|
||||||
git add -A && git commit -q -m setup && git push -q
|
|
||||||
|
|
||||||
run bash "$PRUNE" "$g_name"
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *'"count":0'* ]]
|
|
||||||
run git rev-parse --verify "chore/prune-orphans-$(date +%F)"
|
|
||||||
[ "$status" -ne 0 ]
|
|
||||||
}
|
|
||||||
|
|
||||||
@test "run-prune: refuses when an orphan path would escape wiki/ (defense in depth)" {
|
|
||||||
command -v jq >/dev/null 2>&1 || skip "jq not installed"
|
|
||||||
cd "$g"
|
|
||||||
# legacy page without source_path is ignored; a page with a missing raw is the orphan.
|
|
||||||
printf -- '---\nsource_path: raw/articles/gone.md\nsource_sha256: abc\n---\nbody\n' > wiki/sources/orphan.md
|
|
||||||
git add -A && git commit -q -m setup && git push -q
|
|
||||||
run bash "$PRUNE" "$g_name"
|
|
||||||
[ "$status" -eq 0 ]
|
|
||||||
[[ "$output" == *'"count":1'* ]]
|
|
||||||
[ ! -f wiki/sources/orphan.md ]
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue