diff --git a/.github/workflows/validate-workflows.yml b/.github/workflows/validate-workflows.yml new file mode 100644 index 0000000..497139f --- /dev/null +++ b/.github/workflows/validate-workflows.yml @@ -0,0 +1,133 @@ +name: Validate n8n Workflows + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + workflow_dispatch: # Allow manual triggering + +jobs: + validate-workflows: + name: Validate n8n Workflows + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + working-directory: ./lib + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Run workflow validation + id: validate + working-directory: ./lib + run: | + # Run the validator on all JSON files in the repository + # This will fail if any workflow is invalid + echo "Validating all n8n workflows..." + if ! n8n-validate ..; then + echo "::error::One or more workflow validations failed" + exit 1 + fi + echo "All workflows are valid!" + + - name: Create visualization artifacts + if: always() # Run this step even if validation fails + working-directory: ./lib + run: | + echo "Creating visualizations for all workflows..." + mkdir -p ../workflow-visualizations + + # Find all JSON files that might be n8n workflows + find .. -type f -name "*.json" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/workflow-visualizations/*" | while read -r file; do + # Try to validate the file first + if n8n-validate "$file" 2>/dev/null; then + # If validation passes, create a visualization + echo "Creating visualization for $file" + filename=$(basename "$file" .json) + output_file="../workflow-visualizations/${filename}.png" + if ! n8n-visualize "$file" -o "$output_file" --no-show 2>/dev/null; then + echo "::warning::Failed to create visualization for $file" + fi + fi + done + + # Count the number of visualizations created + VIS_COUNT=$(find ../workflow-visualizations -type f -name "*.png" | wc -l) + echo "Created $VIS_COUNT workflow visualizations" + + # Set an output with the visualization count + echo "visualization_count=$VIS_COUNT" >> $GITHUB_OUTPUT + + - name: Upload workflow visualizations + if: always() && steps.validate.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: workflow-visualizations + path: workflow-visualizations/ + if-no-files-found: ignore + retention-days: 7 + + - name: Comment on PR with validation results + if: github.event_name == 'pull_request' && steps.validate.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const { execSync } = require('child_process'); + + // Get the list of workflow files that were validated + const workflowFiles = execSync('find .. -type f -name "*.json" -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/workflow-visualizations/*"') + .toString() + .split('\n') + .filter(Boolean); + + // Count visualizations + let visCount = 0; + try { + visCount = fs.readdirSync('../workflow-visualizations').length; + } catch (e) { + // Directory might not exist if no visualizations were created + } + + // Create a comment + const comment = `āœ… All ${workflowFiles.length} n8n workflow files are valid!\n` + + `šŸ“Š ${visCount} workflow visualizations were generated and attached as artifacts.`; + + // Add a comment to the PR + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.login === 'github-actions[bot]' && + comment.body.includes('n8n workflow') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment, + }); + } diff --git a/lib/examples/visualize_example.py b/lib/examples/visualize_example.py new file mode 100644 index 0000000..a43bcb3 --- /dev/null +++ b/lib/examples/visualize_example.py @@ -0,0 +1,170 @@ +""" +Example script demonstrating how to use the n8n_utils visualization. +""" +import json +import os +import sys +from pathlib import Path + +# Add the lib directory to the path so we can import n8n_utils +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from n8n_utils.visualization import visualize_workflow + +def create_sample_workflow(): + """Create a sample n8n workflow for demonstration.""" + return { + "name": "Sample Workflow", + "nodes": [ + { + "id": "1", + "name": "Start", + "type": "n8n-nodes-base.start", + "typeVersion": 1, + "position": [250, 300], + "parameters": {} + }, + { + "id": "2", + "name": "HTTP Request", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 1, + "position": [450, 200], + "parameters": { + "url": "https://api.example.com/data", + "method": "GET" + } + }, + { + "id": "3", + "name": "Process Data", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [650, 300], + "parameters": { + "functionCode": "// Process the data here\nreturn items;" + } + }, + { + "id": "4", + "name": "Condition", + "type": "n8n-nodes-base.if", + "typeVersion": 1, + "position": [850, 300], + "parameters": { + "conditions": { + "string": [ + { + "value1": "={{ $json.someField }}", + "operation": "exists" + } + ] + } + } + }, + { + "id": "5", + "name": "Send Email", + "type": "n8n-nodes-base.emailSend", + "typeVersion": 1, + "position": [1050, 200], + "parameters": { + "to": "user@example.com", + "subject": "Processing Complete", + "text": "The data has been processed successfully." + } + }, + { + "id": "6", + "name": "Log Error", + "type": "n8n-nodes-base.function", + "typeVersion": 1, + "position": [1050, 400], + "parameters": { + "functionCode": "console.log('Error processing data:', items);\nreturn items;" + } + } + ], + "connections": { + "1": { + "main": [ + [ + { + "node": "2", + "type": "main", + "index": 0 + } + ] + ] + }, + "2": { + "main": [ + [ + { + "node": "3", + "type": "main", + "index": 0 + } + ] + ] + }, + "3": { + "main": [ + [ + { + "node": "4", + "type": "main", + "index": 0 + } + ] + ] + }, + "4": { + "main": [ + [ + { + "node": "5", + "type": "main", + "index": 0 + } + ] + ] + }, + "4-1": { + "main": [ + [ + { + "node": "6", + "type": "main", + "index": 0 + } + ] + ] + } + } + } + +def main(): + """Run the example.""" + # Create output directory if it doesn't exist + output_dir = Path(__file__).parent / "output" + output_dir.mkdir(exist_ok=True) + + # Create a sample workflow + workflow = create_sample_workflow() + + # Save the workflow as JSON + workflow_file = output_dir / "sample_workflow.json" + with open(workflow_file, 'w', encoding='utf-8') as f: + json.dump(workflow, f, indent=2) + + print(f"Created sample workflow at: {workflow_file}") + + # Visualize the workflow + output_image = output_dir / "workflow_visualization.png" + from n8n_utils.visualization.visualizer import visualize_workflow + visualize_workflow(workflow, output_file=str(output_image), show=True) + print(f"Workflow visualization saved to: {output_image}") + +if __name__ == "__main__": + main() diff --git a/lib/n8n_utils/__init__.py b/lib/n8n_utils/__init__.py new file mode 100644 index 0000000..fd7bc1d --- /dev/null +++ b/lib/n8n_utils/__init__.py @@ -0,0 +1,8 @@ +""" +n8n Utils + +A collection of utilities for working with n8n workflows, +including validation and visualization tools. +""" + +__version__ = "0.1.0" diff --git a/lib/n8n_utils/ci/__init__.py b/lib/n8n_utils/ci/__init__.py new file mode 100644 index 0000000..3e15fa3 --- /dev/null +++ b/lib/n8n_utils/ci/__init__.py @@ -0,0 +1,15 @@ +""" +n8n CI Utilities + +This package provides tools for CI/CD integration with n8n workflows, +including validation and testing utilities. +""" + +from .validator import validate_workflow, validate_workflow_file, validate_all_workflows, ValidationError + +__all__ = [ + 'validate_workflow', + 'validate_workflow_file', + 'validate_all_workflows', + 'ValidationError', +] diff --git a/lib/n8n_utils/ci/validator.py b/lib/n8n_utils/ci/validator.py new file mode 100644 index 0000000..1d868f0 --- /dev/null +++ b/lib/n8n_utils/ci/validator.py @@ -0,0 +1,158 @@ +""" +n8n Workflow Validator + +This module provides functionality to validate n8n workflow JSON files +against a schema to ensure they are properly formatted. +""" +import json +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional, Union + +import jsonschema +from jsonschema import validate + +# Define the n8n workflow JSON schema +N8N_WORKFLOW_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["nodes", "connections"], + "properties": { + "name": {"type": "string"}, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name", "type", "typeVersion", "position", "parameters"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "typeVersion": {"type": ["number", "string"]}, + "position": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2 + }, + "parameters": {"type": "object"}, + }, + }, + }, + "connections": {"type": "object"}, + }, +} + +class ValidationError(Exception): + """Custom exception for validation errors.""" + pass + +def validate_workflow(workflow_data: Dict) -> List[str]: + """ + Validate an n8n workflow against the schema. + + Args: + workflow_data: The parsed JSON data of the workflow + + Returns: + List of error messages, empty if valid + """ + try: + validate(instance=workflow_data, schema=N8N_WORKFLOW_SCHEMA) + return [] + except jsonschema.exceptions.ValidationError as e: + return [f"Validation error: {e.message} at {'.'.join(map(str, e.path))}"] + +def validate_workflow_file(file_path: Union[str, Path]) -> List[str]: + """ + Validate an n8n workflow file. + + Args: + file_path: Path to the JSON file to validate + + Returns: + List of error messages, empty if valid + """ + try: + with open(file_path, 'r', encoding='utf-8') as f: + try: + workflow_data = json.load(f) + except json.JSONDecodeError as e: + return [f"Invalid JSON in {file_path}: {str(e)}"] + + return validate_workflow(workflow_data) + except Exception as e: + return [f"Error reading {file_path}: {str(e)}"] + +def find_workflow_files(directory: Union[str, Path]) -> List[Path]: + """ + Recursively find all JSON files in a directory that might be n8n workflows. + + Args: + directory: Directory to search in + + Returns: + List of Path objects to potential workflow files + """ + directory = Path(directory) + return list(directory.glob("**/*.json")) + +def validate_all_workflows(directory: Union[str, Path]) -> Dict[str, List[str]]: + """ + Validate all JSON files in a directory and its subdirectories. + + Args: + directory: Directory to search for workflow files + + Returns: + Dictionary mapping file paths to lists of error messages + """ + workflow_files = find_workflow_files(directory) + results = {} + + for file_path in workflow_files: + errors = validate_workflow_file(file_path) + if errors: + results[str(file_path)] = errors + + return results + +def main(): + """Command-line interface for the validator.""" + import argparse + + parser = argparse.ArgumentParser(description="Validate n8n workflow files.") + parser.add_argument( + "directory", + nargs="?", + default=".", + help="Directory containing n8n workflow files (default: current directory)", + ) + args = parser.parse_args() + + directory = Path(args.directory).resolve() + if not directory.exists(): + print(f"Error: Directory '{directory}' does not exist") + sys.exit(1) + + print(f"Validating n8n workflows in: {directory}") + results = validate_all_workflows(directory) + + if not results: + print("āœ… All workflow files are valid!") + sys.exit(0) + + # Print errors + error_count = 0 + for file_path, errors in results.items(): + print(f"\nāŒ {file_path}:") + for error in errors: + print(f" - {error}") + error_count += 1 + + print(f"\nFound {error_count} error(s) in {len(results)} file(s)") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/lib/n8n_utils/tests/test_workflow_validation.py b/lib/n8n_utils/tests/test_workflow_validation.py new file mode 100644 index 0000000..64fd9aa --- /dev/null +++ b/lib/n8n_utils/tests/test_workflow_validation.py @@ -0,0 +1,79 @@ +"""Tests for n8n workflow validation.""" +import json +import os +import tempfile +import unittest +from pathlib import Path + +from n8n_utils.ci.validator import validate_workflow, ValidationError + +class TestWorkflowValidation(unittest.TestCase): + """Test cases for workflow validation.""" + + def setUp(self): + """Set up test fixtures.""" + self.valid_workflow = { + "name": "Test Workflow", + "nodes": [ + { + "id": "1", + "name": "Start", + "type": "n8n-nodes-base.start", + "typeVersion": 1, + "position": [250, 300], + "parameters": {} + }, + { + "id": "2", + "name": "HTTP Request", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 1, + "position": [450, 300], + "parameters": { + "url": "https://example.com", + "method": "GET" + } + } + ], + "connections": { + "1": { + "main": [ + [ + { + "node": "2", + "type": "main", + "index": 0 + } + ] + ] + } + } + } + + def test_valid_workflow(self): + """Test validation of a valid workflow.""" + errors = validate_workflow(self.valid_workflow) + self.assertEqual(len(errors), 0) + + def test_missing_required_field(self): + """Test validation of a workflow with a missing required field.""" + # Remove a required field + invalid_workflow = self.valid_workflow.copy() + del invalid_workflow["nodes"][0]["id"] + + errors = validate_workflow(invalid_workflow) + self.assertGreater(len(errors), 0) + self.assertIn("id", errors[0]) + + def test_invalid_node_structure(self): + """Test validation of a workflow with invalid node structure.""" + invalid_workflow = self.valid_workflow.copy() + # Make position an invalid type + invalid_workflow["nodes"][0]["position"] = "not an array" + + errors = validate_workflow(invalid_workflow) + self.assertGreater(len(errors), 0) + self.assertIn("position", errors[0]) + +if __name__ == "__main__": + unittest.main() diff --git a/lib/requirements.txt b/lib/requirements.txt new file mode 100644 index 0000000..4b0c0e9 --- /dev/null +++ b/lib/requirements.txt @@ -0,0 +1,7 @@ +jsonschema>=4.0.0 +networkx>=2.6.3 +matplotlib>=3.4.3 +pytest>=7.0.0 +pytest-cov>=3.0.0 +click>=8.0.0 +python-dotenv>=0.19.0 diff --git a/lib/setup.py b/lib/setup.py new file mode 100644 index 0000000..eb0b1fe --- /dev/null +++ b/lib/setup.py @@ -0,0 +1,39 @@ +from setuptools import setup, find_packages +import os + +# Get the long description from the README file +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, '..', 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +setup( + name="n8n_utils", + version="0.1.0", + packages=find_packages(where='.'), + package_dir={'': '.'}, + install_requires=[ + "jsonschema>=4.0.0", + "networkx>=2.6.3", + "matplotlib>=3.4.3", + "click>=8.0.0", + "python-dotenv>=0.19.0", + ], + entry_points={ + 'console_scripts': [ + 'n8n-validate=n8n_utils.ci.validator:main', + 'n8n-visualize=n8n_utils.visualization.visualizer:main', + ], + }, + python_requires='>=3.8', + author="Your Name", + author_email="your.email@example.com", + description="Utilities for n8n workflow validation and visualization", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/yourusername/n8n-free-templates", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], +)