diff --git a/.gitignore b/.gitignore index 7ce0419..ceb3b7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -sqlite.db \ No newline at end of file +sqlite.db +.nicegui/ \ No newline at end of file diff --git a/README.md b/README.md index 5ae0a56..e8fa77a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Automated software generation service powered by Ollama LLM. This service allows - **Telegram Integration**: Receive software requests via Telegram bot - **Ollama LLM**: Uses Ollama-hosted models for code generation -- **Git Integration**: Automatically commits code to gitea +- **Git Integration**: Creates a dedicated Gitea repository per generated project inside your organization - **Pull Requests**: Creates PRs for user review before merging - **Web UI**: Beautiful dashboard for monitoring project progress - **n8n Workflows**: Bridges Telegram with LLMs via n8n webhooks @@ -49,9 +49,10 @@ OLLAMA_MODEL=llama3 # Gitea GITEA_URL=https://gitea.yourserver.com -GITEA_TOKEN= analyze your_gitea_api_token +GITEA_TOKEN=your_gitea_api_token GITEA_OWNER=ai-software-factory -GITEA_REPO=ai-software-factory +# Optional legacy fixed-repository mode. Leave empty to create one repo per project. +GITEA_REPO= # n8n N8N_WEBHOOK_URL=http://n8n.yourserver.com/webhook/telegram @@ -83,7 +84,7 @@ docker-compose up -d 2. **Monitor progress via Web UI:** - Open `http://yourserver:8000` to see real-time progress + Open `http://yourserver:8000/` to see the dashboard and `http://yourserver:8000/api` for API metadata 3. **Review PRs in Gitea:** @@ -93,7 +94,8 @@ docker-compose up -d | Endpoint | Method | Description | |------|------|-------| -| `/` | GET | API information | +| `/` | GET | Dashboard | +| `/api` | GET | API information | | `/health` | GET | Health check | | `/generate` | POST | Generate new software | | `/status/{project_id}` | GET | Get project status | diff --git a/ai_software_factory/.env.example b/ai_software_factory/.env.example index 66aee96..72e57f1 100644 --- a/ai_software_factory/.env.example +++ b/ai_software_factory/.env.example @@ -15,7 +15,7 @@ OLLAMA_MODEL=llama3 GITEA_URL=https://gitea.yourserver.com GITEA_TOKEN=your_gitea_api_token GITEA_OWNER=your_organization_name -GITEA_REPO= (optional - leave empty for any repo, or specify a default) +GITEA_REPO= (optional legacy fixed repository mode; leave empty to create one repo per project) # n8n # n8n webhook for Telegram integration diff --git a/ai_software_factory/agents/__init__.py b/ai_software_factory/agents/__init__.py index 997cab6..ffe57dd 100644 --- a/ai_software_factory/agents/__init__.py +++ b/ai_software_factory/agents/__init__.py @@ -1,11 +1,11 @@ """AI Software Factory agents.""" -from agents.orchestrator import AgentOrchestrator -from agents.git_manager import GitManager -from agents.ui_manager import UIManager -from agents.telegram import TelegramHandler -from agents.gitea import GiteaAPI -from agents.database_manager import DatabaseManager +from .orchestrator import AgentOrchestrator +from .git_manager import GitManager +from .ui_manager import UIManager +from .telegram import TelegramHandler +from .gitea import GiteaAPI +from .database_manager import DatabaseManager __all__ = [ "AgentOrchestrator", diff --git a/ai_software_factory/agents/database_manager.py b/ai_software_factory/agents/database_manager.py index db338e7..9385e18 100644 --- a/ai_software_factory/agents/database_manager.py +++ b/ai_software_factory/agents/database_manager.py @@ -840,6 +840,13 @@ class DatabaseManager: } def cleanup_audit_trail(self) -> None: - """Clear all audit trail entries.""" + """Clear audit-related test data across all related tables.""" + self.db.query(PromptCodeLink).delete() + self.db.query(PullRequest).delete() + self.db.query(UISnapshot).delete() + self.db.query(UserAction).delete() + self.db.query(ProjectLog).delete() self.db.query(AuditTrail).delete() + self.db.query(SystemLog).delete() + self.db.query(ProjectHistory).delete() self.db.commit() \ No newline at end of file diff --git a/ai_software_factory/agents/gitea.py b/ai_software_factory/agents/gitea.py index f218bae..9b4b76b 100644 --- a/ai_software_factory/agents/gitea.py +++ b/ai_software_factory/agents/gitea.py @@ -1,12 +1,11 @@ -"""Gitea API integration for commits and PRs.""" +"""Gitea API integration for repository and pull request operations.""" import os -from typing import Optional class GiteaAPI: """Gitea API client for repository operations.""" - + 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("/") @@ -14,69 +13,100 @@ class GiteaAPI: self.repo = repo self.headers = { "Authorization": f"token {token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } - + 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 + "supports_project_repos": not bool(repo), } - + def get_auth_headers(self) -> dict: """Get authentication headers.""" return { "Authorization": f"token {self.token}", - "Content-Type": "application/json" + "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} - + + def _api_url(self, path: str) -> str: + """Build a Gitea API URL from a relative path.""" + return f"{self.base_url}/api/v1/{path.lstrip('/')}" + + async def _request(self, method: str, path: str, payload: dict | None = None) -> dict: + """Perform a Gitea API request and normalize the response.""" try: import aiohttp + async with aiohttp.ClientSession() as session: - async with session.post(url, headers=self.get_auth_headers(), json=payload) as resp: - if resp.status == 201: + async with session.request( + method, + self._api_url(path), + headers=self.get_auth_headers(), + json=payload, + ) as resp: + if resp.status in (200, 201): return await resp.json() - else: - return {"error": await resp.text()} + return {"error": await resp.text(), "status_code": resp.status} except Exception as e: return {"error": str(e)} - + + def build_project_repo_name(self, project_id: str, project_name: str | None = None) -> str: + """Build a repository name for a generated project.""" + preferred = (project_name or project_id or "project").strip().lower().replace(" ", "-") + sanitized = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in preferred) + while "--" in sanitized: + sanitized = sanitized.replace("--", "-") + return sanitized.strip("-") or project_id + + async def create_repo( + self, + repo_name: str, + owner: str | None = None, + description: str | None = None, + private: bool = False, + auto_init: bool = True, + ) -> dict: + """Create a repository inside the configured organization.""" + _owner = owner or self.owner + if not _owner: + return {"error": "Owner or organization is required"} + + payload = { + "name": repo_name, + "description": description or f"AI-generated project repository for {repo_name}", + "private": private, + "auto_init": auto_init, + "default_branch": "main", + } + result = await self._request("POST", f"orgs/{_owner}/repos", payload) + if result.get("status_code") == 409: + existing = await self.get_repo_info(owner=_owner, repo=repo_name) + if not existing.get("error"): + existing["status"] = "exists" + return existing + if not result.get("error"): + result.setdefault("status", "created") + return result + + async def create_branch(self, branch: str, base: str = "main", owner: str | None = None, repo: str | None = None): + """Create a new branch.""" + _owner = owner or self.owner + _repo = repo or self.repo + return await self._request( + "POST", + f"repos/{_owner}/{_repo}/branches", + {"new_branch_name": branch, "old_ref_name": base}, + ) + async def create_pull_request( self, title: str, @@ -84,107 +114,50 @@ class GiteaAPI: owner: str, repo: str, base: str = "main", - head: str | None = None + head: str | None = None, ) -> dict: - """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 - """ + """Create a pull request.""" _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"{_owner}-{_repo}-ai-gen-{hash(title) % 10000}" + "base": base, + "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.get_auth_headers(), json=payload) as resp: - if resp.status == 201: - return await resp.json() - else: - return {"error": await resp.text()} - except Exception as e: - return {"error": str(e)} - + return await self._request("POST", f"repos/{_owner}/{_repo}/pulls", payload) + async def push_commit( self, branch: str, files: list[dict], message: str, owner: str | None = None, - repo: str | None = None + repo: str | None = None, ) -> dict: - """ - Push files to a branch. - + """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 + For now, this remains simulated. """ - # 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, "owner": _owner, - "repo": _repo + "repo": _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 + """Get repository information.""" _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.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)} \ No newline at end of file + + return await self._request("GET", f"repos/{_owner}/{_repo}") \ No newline at end of file diff --git a/ai_software_factory/agents/orchestrator.py b/ai_software_factory/agents/orchestrator.py index 339c25e..586f1c3 100644 --- a/ai_software_factory/agents/orchestrator.py +++ b/ai_software_factory/agents/orchestrator.py @@ -3,7 +3,6 @@ from __future__ import annotations import py_compile -from pathlib import Path from typing import Optional from datetime import datetime @@ -51,18 +50,21 @@ class AgentOrchestrator: self.prompt_text = prompt_text self.prompt_actor = prompt_actor self.changed_files: list[str] = [] - self.project_root = settings.projects_root / project_id - self.prompt_audit = None - - # Initialize agents - self.git_manager = GitManager(project_id) - self.ui_manager = UIManager(project_id) self.gitea_api = GiteaAPI( token=settings.GITEA_TOKEN, base_url=settings.GITEA_URL, owner=settings.GITEA_OWNER, repo=settings.GITEA_REPO or "" ) + self.project_root = settings.projects_root / project_id + self.prompt_audit = None + self.repo_name = settings.gitea_repo or self.gitea_api.build_project_repo_name(project_id, project_name) + self.repo_owner = settings.gitea_owner + self.repo_url = self._build_repo_url(self.repo_owner, self.repo_name) + + # Initialize agents + self.git_manager = GitManager(project_id) + self.ui_manager = UIManager(project_id) # Initialize database manager if db session provided self.db_manager = None @@ -90,6 +92,53 @@ class AgentOrchestrator: self.ui_manager.ui_data["project_root"] = str(self.project_root) self.ui_manager.ui_data["features"] = list(self.features) self.ui_manager.ui_data["tech_stack"] = list(self.tech_stack) + self.ui_manager.ui_data["repository"] = { + "owner": self.repo_owner, + "name": self.repo_name, + "url": self.repo_url, + "mode": "project" if settings.use_project_repositories else "shared", + } + + def _build_repo_url(self, owner: str | None, repo: str | None) -> str | None: + if not owner or not repo or not settings.gitea_url: + return None + return f"{settings.gitea_url.rstrip('/')}/{owner}/{repo}" + + async def _ensure_remote_repository(self) -> None: + if not settings.use_project_repositories: + return + if not self.repo_owner or not settings.gitea_token or not settings.gitea_url: + return + + repo_name = self.repo_name + result = await self.gitea_api.create_repo( + repo_name=repo_name, + owner=self.repo_owner, + description=f"AI-generated project for {self.project_name}", + ) + if result.get("status") == "exists" and repo_name == self.gitea_api.build_project_repo_name(self.project_id, self.project_name): + repo_name = f"{repo_name}-{self.project_id.split('-')[-1]}" + result = await self.gitea_api.create_repo( + repo_name=repo_name, + owner=self.repo_owner, + description=f"AI-generated project for {self.project_name}", + ) + self.repo_name = repo_name + self.ui_manager.ui_data["repository"]["name"] = repo_name + if self.db_manager: + self.db_manager.log_system_event( + component="gitea", + level="ERROR" if result.get("error") else "INFO", + message=( + f"Repository setup failed for {self.repo_owner}/{self.repo_name}: {result.get('error')}" + if result.get("error") + else f"Prepared repository {self.repo_owner}/{self.repo_name}" + ), + ) + self.ui_manager.ui_data["repository"]["status"] = result.get("status", "error" if result.get("error") else "ready") + if result.get("html_url"): + self.repo_url = result["html_url"] + self.ui_manager.ui_data["repository"]["url"] = self.repo_url def _append_log(self, message: str) -> None: timestamped = f"[{datetime.utcnow().isoformat()}] {message}" @@ -165,6 +214,8 @@ class AgentOrchestrator: self._update_progress(5, "initializing", "Setting up project structure...") self._append_log("Initializing project.") + await self._ensure_remote_repository() + # Step 2: Create project structure (skip git operations) self._update_progress(20, "project-structure", "Creating project files...") await self._create_project_structure() @@ -201,6 +252,7 @@ class AgentOrchestrator: "history_id": self.history.id if self.history else None, "project_root": str(self.project_root), "changed_files": list(dict.fromkeys(self.changed_files)), + "repository": self.ui_manager.ui_data.get("repository"), } except Exception as e: @@ -226,6 +278,7 @@ class AgentOrchestrator: "history_id": self.history.id if self.history else None, "project_root": str(self.project_root), "changed_files": list(dict.fromkeys(self.changed_files)), + "repository": self.ui_manager.ui_data.get("repository"), } async def _create_project_structure(self) -> None: diff --git a/ai_software_factory/alembic.ini b/ai_software_factory/alembic.ini index 938ee36..3d6dbee 100644 --- a/ai_software_factory/alembic.ini +++ b/ai_software_factory/alembic.ini @@ -1,6 +1,7 @@ [alembic] script_location = alembic prepend_sys_path = . +path_separator = os sqlalchemy.url = sqlite:////tmp/ai_software_factory_test.db [loggers] diff --git a/ai_software_factory/config.py b/ai_software_factory/config.py index a155380..1b8edb9 100644 --- a/ai_software_factory/config.py +++ b/ai_software_factory/config.py @@ -4,12 +4,18 @@ import os from typing import Optional from pathlib import Path from pydantic import Field -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Application settings loaded from environment variables.""" + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + # Server settings HOST: str = "0.0.0.0" PORT: int = 8000 @@ -23,7 +29,7 @@ class Settings(BaseSettings): GITEA_URL: str = "https://gitea.yourserver.com" GITEA_TOKEN: str = "" GITEA_OWNER: str = "ai-software-factory" - GITEA_REPO: str = "ai-software-factory" + GITEA_REPO: str = "" # n8n settings N8N_WEBHOOK_URL: str = "" @@ -105,6 +111,21 @@ class Settings(BaseSettings): """Get Gitea token with trimmed whitespace.""" return self.GITEA_TOKEN.strip() + @property + def gitea_owner(self) -> str: + """Get Gitea owner/organization with trimmed whitespace.""" + return self.GITEA_OWNER.strip() + + @property + def gitea_repo(self) -> str: + """Get the optional fixed Gitea repository name with trimmed whitespace.""" + return self.GITEA_REPO.strip() + + @property + def use_project_repositories(self) -> bool: + """Whether the service should create one repository per generated project.""" + return not bool(self.gitea_repo) + @property def n8n_webhook_url(self) -> str: """Get n8n webhook URL with trimmed whitespace.""" @@ -177,11 +198,5 @@ class Settings(BaseSettings): """Get test PostgreSQL database name.""" return self.POSTGRES_TEST_DB.strip() - class Config: - env_file = ".env" - env_file_encoding = "utf-8" - extra = "ignore" - - # Create instance for module-level access settings = Settings() \ No newline at end of file diff --git a/ai_software_factory/dashboard_ui.py b/ai_software_factory/dashboard_ui.py index 529fb17..72b0210 100644 --- a/ai_software_factory/dashboard_ui.py +++ b/ai_software_factory/dashboard_ui.py @@ -178,7 +178,7 @@ def create_dashboard(): prompt = prompts[0] ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}") ui.markdown(f"**Tech stack:** {', '.join(prompt['tech_stack']) or 'None'}") - ui.element('div').classes('factory-code').set_text(prompt['prompt_text']) + ui.label(prompt['prompt_text']).classes('factory-code') else: ui.label('No prompt recorded.').classes('factory-muted') @@ -221,7 +221,7 @@ def create_dashboard(): for correlation in correlations: with ui.card().classes('q-pa-md q-mt-md'): ui.label(correlation['project_id']).style('font-size: 1rem; font-weight: 700; color: #2f241d;') - ui.element('div').classes('factory-code q-mt-sm').set_text(correlation['prompt_text']) + ui.label(correlation['prompt_text']).classes('factory-code q-mt-sm') if correlation['changes']: for change in correlation['changes']: ui.markdown( diff --git a/ai_software_factory/frontend.py b/ai_software_factory/frontend.py index 0a9abda..388d911 100644 --- a/ai_software_factory/frontend.py +++ b/ai_software_factory/frontend.py @@ -5,6 +5,7 @@ The dashboard shown is from dashboard_ui.py with real-time database data. """ from fastapi import FastAPI +from fastapi.responses import RedirectResponse from nicegui import app, ui @@ -22,13 +23,24 @@ def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None: storage_secret: Optional secret for persistent user storage. """ - @ui.page('/show') - def show(): + def render_dashboard_page() -> None: create_dashboard() - + # NOTE dark mode will be persistent for each user across tabs and server restarts ui.dark_mode().bind_value(app.storage.user, 'dark_mode') ui.checkbox('dark mode').bind_value(app.storage.user, 'dark_mode') + + @ui.page('/') + def home() -> None: + render_dashboard_page() + + @ui.page('/show') + def show() -> None: + render_dashboard_page() + + @fastapi_app.get('/dashboard', include_in_schema=False) + def dashboard_redirect() -> RedirectResponse: + return RedirectResponse(url='/', status_code=307) ui.run_with( fastapi_app, diff --git a/ai_software_factory/main.py b/ai_software_factory/main.py index a9c8ed0..0ad3463 100644 --- a/ai_software_factory/main.py +++ b/ai_software_factory/main.py @@ -6,7 +6,7 @@ This application uses FastAPI to: 2. Host NiceGUI frontend via ui.run_with() The NiceGUI frontend provides: -1. Interactive dashboard at /show +1. Interactive dashboard at / 2. Real-time data visualization 3. Audit trail display """ @@ -149,14 +149,15 @@ def _resolve_n8n_api_url(explicit_url: str | None = None) -> str: return "" -@app.get('/') -def read_root(): - """Root endpoint that returns service metadata.""" +@app.get('/api') +def read_api_info(): + """Return service metadata for API clients.""" return { 'service': 'AI Software Factory', 'version': __version__, 'endpoints': [ '/', + '/api', '/health', '/generate', '/projects', @@ -217,6 +218,7 @@ async def generate_software(request: SoftwareRequest, db: DbSession): response_data['tech_stack'] = request.tech_stack response_data['project_root'] = result.get('project_root', str(_project_root(project_id))) response_data['changed_files'] = result.get('changed_files', []) + response_data['repository'] = result.get('repository') return {'status': result['status'], 'data': response_data}