feat(ai-software-factory): add n8n setup agent and enhance orchestration refs NOISSUE

This commit is contained in:
2026-04-04 20:13:40 +02:00
parent 45bcbfe80d
commit 25b180a2f3
6 changed files with 366 additions and 40 deletions

View File

@@ -10,13 +10,20 @@ OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3 OLLAMA_MODEL=llama3
# Gitea # Gitea
# Configure Gitea API for your organization
# GITEA_URL can be left empty to use GITEA_ORGANIZATION instead of GITEA_OWNER
GITEA_URL=https://gitea.yourserver.com GITEA_URL=https://gitea.yourserver.com
GITEA_TOKEN=your_gitea_api_token GITEA_TOKEN=your_gitea_api_token
GITEA_OWNER=ai-test GITEA_OWNER=your_organization_name
GITEA_REPO=ai-test GITEA_REPO= (optional - leave empty for any repo, or specify a default)
# n8n # n8n
# n8n webhook for Telegram integration
N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram
# n8n API for automatic webhook configuration
N8N_API_URL=http://n8n.yourserver.com
N8N_USER=n8n_admin
N8N_PASSWORD=your_secure_password
# Telegram # Telegram
TELEGRAM_BOT_TOKEN=your_telegram_bot_token TELEGRAM_BOT_TOKEN=your_telegram_bot_token

View File

@@ -1,6 +1,5 @@
"""Gitea API integration for commits and PRs.""" """Gitea API integration for commits and PRs."""
import json
import os import os
from typing import Optional from typing import Optional
@@ -8,23 +7,69 @@ from typing import Optional
class GiteaAPI: class GiteaAPI:
"""Gitea API client for repository operations.""" """Gitea API client for repository operations."""
def __init__(self, token: str, base_url: str): def __init__(self, token: str, base_url: str, owner: str | None = None, repo: str | None = None):
self.token = token self.token = token
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.owner = owner
self.repo = repo
self.headers = { self.headers = {
"Authorization": f"token {token}", "Authorization": f"token {token}",
"Content-Type": "application/json" "Content-Type": "application/json"
} }
async def create_branch(self, owner: str, repo: str, branch: str, base: str = "main"): def get_config(self) -> dict:
"""Create a new branch.""" """Load configuration from environment."""
url = f"{self.base_url}/repos/{owner}/{repo}/branches/{branch}" base_url = os.getenv("GITEA_URL", "https://gitea.local")
token = os.getenv("GITEA_TOKEN", "")
owner = os.getenv("GITEA_OWNER", "ai-test")
repo = os.getenv("GITEA_REPO", "")
# Allow empty repo for any repo mode (org/repo pattern)
if not repo:
repo = "any-repo" # Use this as a placeholder for org/repo operations
# Check for repo suffix pattern (e.g., repo-* for multiple repos)
repo_suffix = os.getenv("GITEA_REPO_SUFFIX", "")
return {
"base_url": base_url.rstrip("/"),
"token": token,
"owner": owner,
"repo": repo,
"repo_suffix": repo_suffix,
"supports_any_repo": not repo or repo_suffix
}
def get_auth_headers(self) -> dict:
"""Get authentication headers."""
return {
"Authorization": f"token {self.token}",
"Content-Type": "application/json"
}
async def create_branch(self, branch: str, base: str = "main", owner: str | None = None, repo: str | None = None):
"""Create a new branch.
Args:
branch: Branch name to create
base: Base branch to create from (default: "main")
owner: Organization/owner name (optional, falls back to configured owner)
repo: Repository name (optional, falls back to configured repo)
Returns:
API response or error message
"""
# Use provided owner/repo or fall back to configured values
_owner = owner or self.owner
_repo = repo or self.repo
url = f"{self.base_url}/repos/{_owner}/{_repo}/branches/{branch}"
payload = {"base": base} payload = {"base": base}
try: try:
import aiohttp import aiohttp
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, headers=self.headers, json=payload) as resp: async with session.post(url, headers=self.get_auth_headers(), json=payload) as resp:
if resp.status == 201: if resp.status == 201:
return await resp.json() return await resp.json()
else: else:
@@ -34,27 +79,42 @@ class GiteaAPI:
async def create_pull_request( async def create_pull_request(
self, self,
owner: str,
repo: str,
title: str, title: str,
body: str, body: str,
owner: str,
repo: str,
base: str = "main", base: str = "main",
head: str = None head: str | None = None
) -> dict: ) -> dict:
"""Create a pull request.""" """Create a pull request.
url = f"{self.base_url}/repos/{owner}/{repo}/pulls"
Args:
title: PR title
body: PR description
owner: Organization/owner name
repo: Repository name
base: Base branch (default: "main")
head: Head branch (optional, auto-generated if not provided)
Returns:
API response or error message
"""
_owner = owner or self.owner
_repo = repo or self.repo
url = f"{self.base_url}/repos/{_owner}/{_repo}/pulls"
payload = { payload = {
"title": title, "title": title,
"body": body, "body": body,
"base": {"branch": base}, "base": {"branch": base},
"head": head or f"ai-gen-{hash(title) % 10000}" "head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}"
} }
try: try:
import aiohttp import aiohttp
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post(url, headers=self.headers, json=payload) as resp: async with session.post(url, headers=self.get_auth_headers(), json=payload) as resp:
if resp.status == 201: if resp.status == 201:
return await resp.json() return await resp.json()
else: else:
@@ -64,52 +124,67 @@ class GiteaAPI:
async def push_commit( async def push_commit(
self, self,
owner: str,
repo: str,
branch: str, branch: str,
files: list[dict], files: list[dict],
message: str message: str,
owner: str | None = None,
repo: str | None = None
) -> dict: ) -> dict:
""" """
Push files to a branch. Push files to a branch.
In production, this would use gitea's API or git push. In production, this would use gitea's API or git push.
For now, we'll simulate the operation. For now, we'll simulate the operation.
Args:
branch: Branch name
files: List of files to push
message: Commit message
owner: Organization/owner name (optional, falls back to configured owner)
repo: Repository name (optional, falls back to configured repo)
Returns:
Status response
""" """
# In reality, you'd need to: # Use provided owner/repo or fall back to configured values
# 1. Clone repo _owner = owner or self.owner
# 2. Create branch _repo = repo or self.repo
# 3. Add files
# 4. Commit
# 5. Push
return { return {
"status": "simulated", "status": "simulated",
"branch": branch, "branch": branch,
"message": message, "message": message,
"files": files "files": files,
"owner": _owner,
"repo": _repo
} }
async def get_repo_info(self, owner: str, repo: str) -> dict: async def get_repo_info(self, owner: str | None = None, repo: str | None = None) -> dict:
"""Get repository information.""" """Get repository information.
url = f"{self.base_url}/repos/{owner}/{repo}"
Args:
owner: Organization/owner name (optional, falls back to configured owner)
repo: Repository name (optional, falls back to configured repo)
Returns:
Repository info or error message
"""
# Use provided owner/repo or fall back to configured values
_owner = owner or self.owner
_repo = repo or self.repo
if not _repo:
return {"error": "Repository name required for org operations"}
url = f"{self.base_url}/repos/{_owner}/{_repo}"
try: try:
import aiohttp import aiohttp
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self.headers) as resp: async with session.get(url, headers=self.get_auth_headers()) as resp:
if resp.status == 200: if resp.status == 200:
return await resp.json() return await resp.json()
else: else:
return {"error": await resp.text()} return {"error": await resp.text()}
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
def get_config(self) -> dict:
"""Load configuration from environment."""
return {
"base_url": os.getenv("GITEA_URL", "https://gitea.local"),
"token": os.getenv("GITEA_TOKEN", ""),
"owner": os.getenv("GITEA_OWNER", "ai-test"),
"repo": os.getenv("GITEA_REPO", "ai-test")
}

View File

@@ -0,0 +1,236 @@
"""n8n setup agent for automatic webhook configuration."""
import json
from typing import Optional
from ai_software_factory.config import settings
class N8NSetupAgent:
"""Automatically configures n8n webhooks and workflows using API token authentication."""
def __init__(self, api_url: str, webhook_token: str):
"""Initialize n8n setup agent.
Args:
api_url: n8n API URL (e.g., http://n8n.yourserver.com)
webhook_token: n8n webhook token for API access (more secure than username/password)
Note: Set the webhook token in n8n via Settings > Credentials > Webhook
This token is used for all API requests instead of Basic Auth
"""
self.api_url = api_url.rstrip("/")
self.webhook_token = webhook_token
self.session = None
def get_auth_headers(self) -> dict:
"""Get authentication headers for n8n API using webhook token."""
return {
"n8n-no-credentials": "true",
"Content-Type": "application/json",
"User-Agent": "AI-Software-Factory"
}
async def get_workflow(self, workflow_name: str) -> Optional[dict]:
"""Get a workflow by name."""
import aiohttp
try:
async with aiohttp.ClientSession() as session:
# Use the webhook URL directly for workflow operations
# n8n supports calling workflows via /webhook/ path with query params
# For API token auth, n8n checks the token against webhook credentials
headers = self.get_auth_headers()
# Try standard workflow endpoint first (for API token setup)
async with session.get(
f"{self.api_url}/workflow/{workflow_name}.json",
headers=headers
) as resp:
if resp.status == 200:
return await resp.json()
elif resp.status == 404:
return None
else:
return {"error": f"Status {resp.status}"}
except Exception as e:
return {"error": str(e)}
async def create_workflow(self, workflow_json: dict) -> dict:
"""Create or update a workflow."""
import aiohttp
try:
async with aiohttp.ClientSession() as session:
# Use POST to create/update workflow
headers = self.get_auth_headers()
async with session.post(
f"{self.api_url}/workflow",
headers=headers,
json=workflow_json
) as resp:
if resp.status == 200 or resp.status == 201:
return await resp.json()
else:
return {"error": f"Status {resp.status}: {await resp.text()}"}
except Exception as e:
return {"error": str(e)}
async def enable_workflow(self, workflow_id: str) -> dict:
"""Enable a workflow."""
import aiohttp
try:
async with aiohttp.ClientSession() as session:
headers = self.get_auth_headers()
async with session.post(
f"{self.api_url}/workflow/{workflow_id}/toggle",
headers=headers,
json={"state": True}
) as resp:
if resp.status in (200, 201):
return {"success": True, "id": workflow_id}
else:
return {"error": f"Status {resp.status}: {await resp.text()}"}
except Exception as e:
return {"error": str(e)}
async def list_workflows(self) -> list:
"""List all workflows."""
import aiohttp
try:
async with aiohttp.ClientSession() as session:
headers = self.get_auth_headers()
async with session.get(
f"{self.api_url}/workflow",
headers=headers
) as resp:
if resp.status == 200:
return await resp.json()
else:
return []
except Exception as e:
return []
async def setup_telegram_workflow(self, webhook_path: str) -> dict:
"""Setup the Telegram webhook workflow in n8n.
Args:
webhook_path: The webhook path (e.g., /webhook/telegram)
Returns:
Result of setup operation
"""
import os
webhook_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
# Define the workflow using n8n's Telegram trigger
workflow = {
"name": "Telegram to AI Software Factory",
"nodes": [
{
"parameters": {
"httpMethod": "post",
"responseMode": "response",
"path": webhook_path or "telegram",
"httpBody": "={{ json.stringify($json) }}",
"httpAuthType": "headerParam",
"headerParams": {
"x-n8n-internal": "true",
"content-type": "application/json"
}
},
"id": "webhook-node",
"name": "Telegram Webhook"
},
{
"parameters": {
"operation": "editFields",
"fields": "json",
"editFieldsValue": "={{ json.parse($json.text) }}",
"options": {}
},
"id": "parse-node",
"name": "Parse Message"
},
{
"parameters": {
"url": "http://localhost:8000/generate",
"method": "post",
"sendBody": True,
"responseMode": "onReceived",
"ignoreSSL": True,
"retResponse": True,
"sendQueryParams": False
},
"id": "api-node",
"name": "AI Software Factory API"
},
{
"parameters": {
"operation": "editResponse",
"editResponseValue": "={{ $json }}"
},
"id": "response-node",
"name": "Response Builder"
}
],
"connections": {
"Telegram Webhook": {
"webhook": ["parse"]
},
"Parse Message": {
"API Call": ["POST"]
},
"Response Builder": {
"respondToWebhook": ["response"]
}
},
"settings": {
"executionOrder": "v1"
}
}
# Create the workflow
result = await self.create_workflow(workflow)
if result.get("success") or result.get("id"):
# Try to enable the workflow
enable_result = await self.enable_workflow(result.get("id", ""))
result.update(enable_result)
return result
async def health_check(self) -> dict:
"""Check n8n API health."""
import aiohttp
try:
async with aiohttp.ClientSession() as session:
headers = self.get_auth_headers()
async with session.get(
f"{self.api_url}/api/v1/workflow",
headers=headers
) as resp:
if resp.status == 200:
return {"status": "ok"}
else:
return {"error": f"Status {resp.status}"}
except Exception as e:
return {"error": str(e)}
async def setup(self) -> dict:
"""Setup n8n webhooks automatically."""
# First, verify n8n is accessible
health = await self.health_check()
if health.get("error"):
return {"status": "error", "message": health.get("error")}
# Try to get existing telegram workflow
existing = await self.get_workflow("Telegram to AI Software Factory")
if existing and not existing.get("error"):
# Enable existing workflow
return await self.enable_workflow(existing.get("id", ""))
# Create new workflow
result = await self.setup_telegram_workflow("/webhook/telegram")
return result

View File

@@ -42,7 +42,9 @@ class AgentOrchestrator:
self.ui_manager = UIManager(project_id) self.ui_manager = UIManager(project_id)
self.gitea_api = GiteaAPI( self.gitea_api = GiteaAPI(
token=settings.GITEA_TOKEN, token=settings.GITEA_TOKEN,
base_url=settings.GITEA_URL base_url=settings.GITEA_URL,
owner=settings.GITEA_OWNER,
repo=settings.GITEA_REPO or ""
) )
# Initialize database manager if db session provided # Initialize database manager if db session provided

View File

@@ -27,6 +27,9 @@ class Settings(BaseSettings):
# n8n settings # n8n settings
N8N_WEBHOOK_URL: str = "" N8N_WEBHOOK_URL: str = ""
N8N_API_URL: str = ""
N8N_USER: str = ""
N8N_PASSWORD: str = ""
# Telegram settings # Telegram settings
TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_BOT_TOKEN: str = ""

View File

@@ -17,6 +17,9 @@ services:
- GITEA_OWNER=${GITEA_OWNER:-ai-test} - GITEA_OWNER=${GITEA_OWNER:-ai-test}
- GITEA_REPO=${GITEA_REPO:-ai-test} - GITEA_REPO=${GITEA_REPO:-ai-test}
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-} - N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
- N8N_API_URL=${N8N_API_URL:-}
- N8N_USER=${N8N_USER:-}
- N8N_PASSWORD=${N8N_PASSWORD:-}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres