diff --git a/ai_software_factory/.env.example b/ai_software_factory/.env.example index 8171ede..66aee96 100644 --- a/ai_software_factory/.env.example +++ b/ai_software_factory/.env.example @@ -10,13 +10,20 @@ OLLAMA_URL=http://localhost:11434 OLLAMA_MODEL=llama3 # 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_TOKEN=your_gitea_api_token -GITEA_OWNER=ai-test -GITEA_REPO=ai-test +GITEA_OWNER=your_organization_name +GITEA_REPO= (optional - leave empty for any repo, or specify a default) # n8n +# n8n webhook for Telegram integration 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_BOT_TOKEN=your_telegram_bot_token diff --git a/ai_software_factory/agents/gitea.py b/ai_software_factory/agents/gitea.py index 09e4d33..f218bae 100644 --- a/ai_software_factory/agents/gitea.py +++ b/ai_software_factory/agents/gitea.py @@ -1,6 +1,5 @@ """Gitea API integration for commits and PRs.""" -import json import os from typing import Optional @@ -8,23 +7,69 @@ from typing import Optional class GiteaAPI: """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.base_url = base_url.rstrip("/") + self.owner = owner + self.repo = repo self.headers = { "Authorization": f"token {token}", "Content-Type": "application/json" } - async def create_branch(self, owner: str, repo: str, branch: str, base: str = "main"): - """Create a new branch.""" - url = f"{self.base_url}/repos/{owner}/{repo}/branches/{branch}" + def get_config(self) -> dict: + """Load configuration from environment.""" + 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} try: import aiohttp 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: return await resp.json() else: @@ -34,27 +79,42 @@ class GiteaAPI: async def create_pull_request( self, - owner: str, - repo: str, title: str, body: str, + owner: str, + repo: str, base: str = "main", - head: str = None + head: str | None = None ) -> dict: - """Create a pull request.""" - url = f"{self.base_url}/repos/{owner}/{repo}/pulls" + """Create a pull request. + + 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 = { "title": title, "body": body, "base": {"branch": base}, - "head": head or f"ai-gen-{hash(title) % 10000}" + "head": head or f"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}" } try: import aiohttp 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: return await resp.json() else: @@ -64,52 +124,67 @@ class GiteaAPI: async def push_commit( self, - owner: str, - repo: str, branch: str, files: list[dict], - message: str + message: str, + owner: str | None = None, + repo: str | None = None ) -> dict: """ Push files to a branch. In production, this would use gitea's API or git push. 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: - # 1. Clone repo - # 2. Create branch - # 3. Add files - # 4. Commit - # 5. Push + # Use provided owner/repo or fall back to configured values + _owner = owner or self.owner + _repo = repo or self.repo return { "status": "simulated", "branch": branch, "message": message, - "files": files + "files": files, + "owner": _owner, + "repo": _repo } - async def get_repo_info(self, owner: str, repo: str) -> dict: - """Get repository information.""" - url = f"{self.base_url}/repos/{owner}/{repo}" + async def get_repo_info(self, owner: str | None = None, repo: str | None = None) -> dict: + """Get repository information. + + 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: import aiohttp 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: return await resp.json() else: return {"error": await resp.text()} except Exception as 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") - } + return {"error": str(e)} \ No newline at end of file diff --git a/ai_software_factory/agents/n8n_setup.py b/ai_software_factory/agents/n8n_setup.py new file mode 100644 index 0000000..ca2ce1b --- /dev/null +++ b/ai_software_factory/agents/n8n_setup.py @@ -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 diff --git a/ai_software_factory/agents/orchestrator.py b/ai_software_factory/agents/orchestrator.py index a435c02..71ef737 100644 --- a/ai_software_factory/agents/orchestrator.py +++ b/ai_software_factory/agents/orchestrator.py @@ -42,7 +42,9 @@ class AgentOrchestrator: self.ui_manager = UIManager(project_id) self.gitea_api = GiteaAPI( 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 diff --git a/ai_software_factory/config.py b/ai_software_factory/config.py index fef5ce7..76726c1 100644 --- a/ai_software_factory/config.py +++ b/ai_software_factory/config.py @@ -27,6 +27,9 @@ class Settings(BaseSettings): # n8n settings N8N_WEBHOOK_URL: str = "" + N8N_API_URL: str = "" + N8N_USER: str = "" + N8N_PASSWORD: str = "" # Telegram settings TELEGRAM_BOT_TOKEN: str = "" diff --git a/ai_software_factory/docker-compose.yml b/ai_software_factory/docker-compose.yml index bfae090..8a2c962 100644 --- a/ai_software_factory/docker-compose.yml +++ b/ai_software_factory/docker-compose.yml @@ -17,6 +17,9 @@ services: - GITEA_OWNER=${GITEA_OWNER:-ai-test} - GITEA_REPO=${GITEA_REPO:-ai-test} - 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_CHAT_ID=${TELEGRAM_CHAT_ID:-} - POSTGRES_HOST=postgres