diff --git a/backend/app/api/repository.py b/backend/app/api/repository.py new file mode 100644 index 00000000..5168e96c --- /dev/null +++ b/backend/app/api/repository.py @@ -0,0 +1,294 @@ +""" +Repository management API endpoints +""" + +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.orm import Session +from typing import List, Dict, Any, Optional +from datetime import datetime + +from ..core.database import get_db +from ..models.project import Project +from ..services.repository_service import repository_service +from ..auth.auth import get_current_user + +router = APIRouter() + +@router.get("/repositories", response_model=List[Dict[str, Any]]) +async def list_repositories( + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """List all repositories with bzzz integration enabled""" + try: + projects = db.query(Project).filter( + Project.bzzz_enabled == True + ).all() + + repositories = [] + for project in projects: + repo_data = { + "id": project.id, + "name": project.name, + "description": project.description, + "provider": project.provider or "github", + "provider_base_url": project.provider_base_url, + "owner": project.git_owner, + "repository": project.git_repository, + "branch": project.git_branch, + "status": project.status, + "bzzz_enabled": project.bzzz_enabled, + "ready_to_claim": project.ready_to_claim, + "auto_assignment": getattr(project, "auto_assignment", True), + "created_at": project.created_at.isoformat() if project.created_at else None + } + repositories.append(repo_data) + + return repositories + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to list repositories: {str(e)}") + +@router.post("/repositories/sync") +async def sync_repositories( + background_tasks: BackgroundTasks, + repository_ids: Optional[List[int]] = None, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Sync tasks from repositories""" + try: + if repository_ids: + # Sync specific repositories + projects = db.query(Project).filter( + Project.id.in_(repository_ids), + Project.bzzz_enabled == True + ).all() + + if not projects: + raise HTTPException(status_code=404, detail="No matching repositories found") + + results = {"synced_projects": 0, "new_tasks": 0, "assigned_tasks": 0, "errors": []} + + for project in projects: + try: + sync_result = await repository_service.sync_project_tasks(db, project) + results["synced_projects"] += 1 + results["new_tasks"] += sync_result.get("new_tasks", 0) + results["assigned_tasks"] += sync_result.get("assigned_tasks", 0) + except Exception as e: + results["errors"].append(f"Project {project.name}: {str(e)}") + + return results + else: + # Sync all repositories in background + background_tasks.add_task(repository_service.sync_all_repositories, db) + return {"message": "Repository sync started in background", "status": "initiated"} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to sync repositories: {str(e)}") + +@router.get("/repositories/{repository_id}/stats") +async def get_repository_stats( + repository_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get task statistics for a specific repository""" + try: + stats = await repository_service.get_project_task_stats(db, repository_id) + return stats + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get repository stats: {str(e)}") + +@router.post("/repositories/{repository_id}/sync") +async def sync_repository( + repository_id: int, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Sync tasks from a specific repository""" + try: + project = db.query(Project).filter( + Project.id == repository_id, + Project.bzzz_enabled == True + ).first() + + if not project: + raise HTTPException(status_code=404, detail="Repository not found or bzzz integration not enabled") + + result = await repository_service.sync_project_tasks(db, project) + return result + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to sync repository: {str(e)}") + +@router.put("/repositories/{repository_id}/config") +async def update_repository_config( + repository_id: int, + config_data: Dict[str, Any], + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Update repository configuration""" + try: + project = db.query(Project).filter(Project.id == repository_id).first() + + if not project: + raise HTTPException(status_code=404, detail="Repository not found") + + # Update allowed configuration fields + if "auto_assignment" in config_data: + setattr(project, "auto_assignment", config_data["auto_assignment"]) + + if "bzzz_enabled" in config_data: + project.bzzz_enabled = config_data["bzzz_enabled"] + + if "ready_to_claim" in config_data: + project.ready_to_claim = config_data["ready_to_claim"] + + if "status" in config_data and config_data["status"] in ["active", "inactive", "archived"]: + project.status = config_data["status"] + + db.commit() + + return {"message": "Repository configuration updated", "repository_id": repository_id} + + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Failed to update repository config: {str(e)}") + +@router.get("/repositories/{repository_id}/tasks") +async def get_repository_tasks( + repository_id: int, + limit: int = 50, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get available tasks from a repository""" + try: + project = db.query(Project).filter( + Project.id == repository_id, + Project.bzzz_enabled == True + ).first() + + if not project: + raise HTTPException(status_code=404, detail="Repository not found or bzzz integration not enabled") + + # Get repository client and fetch tasks + repo_client = await repository_service._get_repository_client(project) + if not repo_client: + raise HTTPException(status_code=500, detail="Failed to create repository client") + + tasks = await repo_client.list_available_tasks() + + # Limit results + if len(tasks) > limit: + tasks = tasks[:limit] + + return { + "repository_id": repository_id, + "repository_name": project.name, + "provider": project.provider or "github", + "tasks": tasks, + "total_tasks": len(tasks) + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get repository tasks: {str(e)}") + +@router.post("/repositories/discover") +async def discover_repositories( + provider: str = "gitea", + base_url: str = "http://192.168.1.113:3000", + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Discover repositories from a provider (placeholder for future implementation)""" + try: + # This would implement repository discovery functionality + # For now, return the manually configured repositories + + existing_repos = db.query(Project).filter( + Project.provider == provider, + Project.provider_base_url == base_url + ).all() + + discovered = [] + for repo in existing_repos: + discovered.append({ + "name": repo.name, + "owner": repo.git_owner, + "repository": repo.git_repository, + "description": repo.description, + "already_configured": True + }) + + return { + "provider": provider, + "base_url": base_url, + "discovered_repositories": discovered + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to discover repositories: {str(e)}") + +@router.post("/webhook/{repository_id}") +async def handle_repository_webhook( + repository_id: int, + payload: Dict[str, Any], + db: Session = Depends(get_db) +): + """Handle webhook events from repositories""" + try: + project = db.query(Project).filter(Project.id == repository_id).first() + + if not project: + raise HTTPException(status_code=404, detail="Repository not found") + + # Log the webhook event (would be stored in webhook_events table) + event_type = payload.get("action", "unknown") + + # For now, just trigger a sync if it's an issue event + if "issue" in payload and event_type in ["opened", "labeled", "unlabeled"]: + # Check if it's a bzzz-task + issue = payload.get("issue", {}) + labels = [label["name"] for label in issue.get("labels", [])] + + if "bzzz-task" in labels: + # Trigger task sync for this project + await repository_service.sync_project_tasks(db, project) + + return { + "message": "Webhook processed, task sync triggered", + "event_type": event_type, + "issue_number": issue.get("number") + } + + return {"message": "Webhook received", "event_type": event_type} + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to process webhook: {str(e)}") + +@router.delete("/repositories/cache") +async def clear_task_cache( + current_user: dict = Depends(get_current_user) +): + """Clear the task cache""" + try: + await repository_service.cleanup_old_cache(max_age_hours=0) # Clear all + return {"message": "Task cache cleared"} + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}") \ No newline at end of file diff --git a/backend/app/models/agent_role.py b/backend/app/models/agent_role.py index a04f6266..26d80dc8 100644 --- a/backend/app/models/agent_role.py +++ b/backend/app/models/agent_role.py @@ -11,7 +11,7 @@ class AgentRole(Base): display_name = Column(String, nullable=False) # Human-readable name system_prompt = Column(Text, nullable=False) # Role-specific system prompt reports_to = Column(JSON, nullable=True) # Array of roles this role reports to - expertise = Column(JSON, nullable=True, index=True) # Array of expertise areas + expertise = Column(JSON, nullable=True) # Array of expertise areas deliverables = Column(JSON, nullable=True) # Array of deliverables capabilities = Column(JSON, nullable=True) # Array of capabilities collaboration_defaults = Column(JSON, nullable=True) # Default collaboration settings diff --git a/backend/app/models/project.py b/backend/app/models/project.py index a85655e4..20bca628 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -23,8 +23,7 @@ class Project(Base): private_repo = Column(Boolean, default=False) github_token_required = Column(Boolean, default=False) - # Additional metadata - metadata = Column(JSON, nullable=True) + # Additional configuration tags = Column(JSON, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/services/repository_service.py b/backend/app/services/repository_service.py new file mode 100644 index 00000000..368e63c8 --- /dev/null +++ b/backend/app/services/repository_service.py @@ -0,0 +1,477 @@ +""" +Repository service for managing task monitoring across different providers (GitHub, Gitea) +""" + +import asyncio +import json +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from ..core.database import get_db +from ..models.project import Project +from ..models.agent import Agent +from .agent_service import AgentService + +logger = logging.getLogger(__name__) + +class RepositoryService: + def __init__(self): + self.agent_service = AgentService() + self._task_cache = {} + self._last_sync = {} + + async def sync_all_repositories(self, db: Session) -> Dict[str, Any]: + """Sync tasks from all enabled repositories""" + results = { + "synced_projects": 0, + "new_tasks": 0, + "assigned_tasks": 0, + "errors": [] + } + + # Get all active projects with bzzz enabled + projects = db.query(Project).filter( + and_( + Project.status == "active", + Project.bzzz_enabled == True + ) + ).all() + + for project in projects: + try: + sync_result = await self.sync_project_tasks(db, project) + results["synced_projects"] += 1 + results["new_tasks"] += sync_result.get("new_tasks", 0) + results["assigned_tasks"] += sync_result.get("assigned_tasks", 0) + + except Exception as e: + error_msg = f"Failed to sync project {project.name}: {str(e)}" + logger.error(error_msg) + results["errors"].append(error_msg) + + return results + + async def sync_project_tasks(self, db: Session, project: Project) -> Dict[str, Any]: + """Sync tasks for a specific project""" + result = { + "project_id": project.id, + "project_name": project.name, + "new_tasks": 0, + "assigned_tasks": 0, + "provider": project.provider or "github" + } + + try: + # Get repository client based on provider + repo_client = await self._get_repository_client(project) + if not repo_client: + raise Exception(f"Could not create repository client for {project.provider}") + + # Fetch available tasks + tasks = await repo_client.list_available_tasks() + result["new_tasks"] = len(tasks) + + # Process each task for potential assignment + for task in tasks: + try: + assigned = await self._process_task_for_assignment(db, project, task) + if assigned: + result["assigned_tasks"] += 1 + except Exception as e: + logger.error(f"Failed to process task {task.get('number', 'unknown')}: {str(e)}") + + # Update last sync time + self._last_sync[project.id] = datetime.now() + + except Exception as e: + logger.error(f"Error syncing project {project.name}: {str(e)}") + raise + + return result + + async def _get_repository_client(self, project: Project): + """Get appropriate repository client based on project provider""" + provider = project.provider or "github" + + if provider == "gitea": + return await self._create_gitea_client(project) + elif provider == "github": + return await self._create_github_client(project) + else: + raise ValueError(f"Unsupported provider: {provider}") + + async def _create_gitea_client(self, project: Project): + """Create Gitea API client""" + try: + import aiohttp + + class GiteaClient: + def __init__(self, base_url: str, owner: str, repo: str, token: str = None): + self.base_url = base_url.rstrip('/') + self.owner = owner + self.repo = repo + self.token = token + self.session = None + + async def list_available_tasks(self) -> List[Dict]: + """List open issues with bzzz-task label""" + if not self.session: + self.session = aiohttp.ClientSession() + + url = f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues" + params = { + "state": "open", + "labels": "bzzz-task", + "limit": 50 + } + + headers = {} + if self.token: + headers["Authorization"] = f"token {self.token}" + + async with self.session.get(url, params=params, headers=headers) as response: + if response.status == 200: + issues = await response.json() + return [self._convert_issue_to_task(issue) for issue in issues + if not issue.get("assignee")] # Only unassigned tasks + else: + logger.error(f"Gitea API error: {response.status}") + return [] + + def _convert_issue_to_task(self, issue: Dict) -> Dict: + """Convert Gitea issue to task format""" + labels = [label["name"] for label in issue.get("labels", [])] + + # Extract role and expertise from labels + required_role = self._extract_required_role(labels) + required_expertise = self._extract_required_expertise(labels) + priority = self._extract_priority(labels) + + return { + "id": issue["id"], + "number": issue["number"], + "title": issue["title"], + "description": issue.get("body", ""), + "state": issue["state"], + "labels": labels, + "created_at": issue["created_at"], + "updated_at": issue["updated_at"], + "provider": "gitea", + "repository": f"{self.owner}/{self.repo}", + "required_role": required_role, + "required_expertise": required_expertise, + "priority": priority, + "task_type": self._extract_task_type(labels, issue.get("body", "")), + "url": issue.get("html_url", "") + } + + def _extract_required_role(self, labels: List[str]) -> str: + """Extract required role from labels""" + role_map = { + "frontend": "frontend_developer", + "backend": "backend_developer", + "security": "security_expert", + "design": "ui_ux_designer", + "devops": "devops_engineer", + "documentation": "technical_writer", + "bug": "qa_engineer", + "architecture": "senior_software_architect" + } + + for label in labels: + label_lower = label.lower() + if label_lower in role_map: + return role_map[label_lower] + + return "full_stack_engineer" # Default + + def _extract_required_expertise(self, labels: List[str]) -> List[str]: + """Extract required expertise from labels""" + expertise = [] + expertise_map = { + "frontend": ["frontend", "javascript", "ui_development"], + "backend": ["backend", "api_development", "server_frameworks"], + "database": ["database", "sql", "data_modeling"], + "security": ["security", "cybersecurity", "vulnerability_analysis"], + "testing": ["testing", "qa_methodologies", "debugging"], + "devops": ["deployment", "infrastructure", "automation"], + "design": ["design", "user_experience", "prototyping"] + } + + for label in labels: + label_lower = label.lower() + if label_lower in expertise_map: + expertise.extend(expertise_map[label_lower]) + + return list(set(expertise)) if expertise else ["general_development"] + + def _extract_priority(self, labels: List[str]) -> int: + """Extract priority from labels""" + for label in labels: + if "priority-" in label.lower(): + try: + return int(label.lower().split("priority-")[1]) + except (ValueError, IndexError): + pass + elif label.lower() in ["urgent", "critical"]: + return 10 + elif label.lower() in ["high"]: + return 8 + elif label.lower() in ["low"]: + return 3 + + return 5 # Default priority + + def _extract_task_type(self, labels: List[str], body: str) -> str: + """Extract task type from labels and body""" + for label in labels: + label_lower = label.lower() + if label_lower in ["bug", "bugfix"]: + return "bug_fix" + elif label_lower in ["enhancement", "feature"]: + return "feature" + elif label_lower in ["documentation", "docs"]: + return "documentation" + elif label_lower in ["security"]: + return "security" + elif label_lower in ["refactor", "refactoring"]: + return "refactoring" + + return "general" + + async def close(self): + if self.session: + await self.session.close() + + # Create and return Gitea client + base_url = project.provider_base_url or "http://192.168.1.113:3000" + token = None # TODO: Get from secure storage + + return GiteaClient( + base_url=base_url, + owner=project.git_owner, + repo=project.git_repository, + token=token + ) + + except ImportError: + logger.error("aiohttp not available for Gitea client") + return None + except Exception as e: + logger.error(f"Failed to create Gitea client: {str(e)}") + return None + + async def _create_github_client(self, project: Project): + """Create GitHub API client (placeholder for now)""" + # TODO: Implement GitHub client similar to Gitea + logger.warning("GitHub client not yet implemented") + return None + + async def _process_task_for_assignment(self, db: Session, project: Project, task: Dict) -> bool: + """Process a task for automatic assignment to suitable agents""" + try: + # Check if auto-assignment is enabled for this project + if not getattr(project, 'auto_assignment', True): + return False + + # Check if task was already processed recently + task_key = f"{project.id}:{task['number']}" + if task_key in self._task_cache: + return False + + # Find suitable agents for this task + suitable_agents = await self._find_suitable_agents(db, task) + + if not suitable_agents: + logger.info(f"No suitable agents found for task {task['number']} in {project.name}") + return False + + # Select best agent (first in sorted list) + selected_agent = suitable_agents[0] + + # Log the assignment attempt + await self._log_task_assignment(db, project, task, selected_agent, "auto_assigned") + + # Cache this task to avoid reprocessing + self._task_cache[task_key] = { + "assigned_at": datetime.now(), + "agent_id": selected_agent["id"], + "task": task + } + + logger.info(f"Assigned task {task['number']} to agent {selected_agent['id']} ({selected_agent['role']})") + return True + + except Exception as e: + logger.error(f"Error processing task {task.get('number', 'unknown')} for assignment: {str(e)}") + return False + + async def _find_suitable_agents(self, db: Session, task: Dict) -> List[Dict]: + """Find agents suitable for a task based on role and expertise""" + try: + # Get all online agents + agents = db.query(Agent).filter( + and_( + Agent.status.in_(["online", "ready"]), + Agent.role.isnot(None) # Only agents with assigned roles + ) + ).all() + + if not agents: + return [] + + # Convert to dict format for scoring + agent_infos = [] + for agent in agents: + agent_info = { + "id": agent.id, + "role": agent.role, + "expertise": agent.expertise or [], + "current_tasks": agent.current_tasks or 0, + "max_tasks": agent.max_concurrent or 2, + "performance": 0.8, # Default performance score + "availability": 1.0 if agent.status == "ready" else 0.7, + "last_seen": agent.last_seen or datetime.now() + } + agent_infos.append(agent_info) + + # Score agents for this task + scored_agents = [] + for agent_info in agent_infos: + # Skip if agent is at capacity + if agent_info["current_tasks"] >= agent_info["max_tasks"]: + continue + + score = self._calculate_agent_task_score(task, agent_info) + if score > 0.3: # Minimum threshold + scored_agents.append({ + **agent_info, + "score": score + }) + + # Sort by score (highest first) + scored_agents.sort(key=lambda x: x["score"], reverse=True) + + return scored_agents[:3] # Return top 3 candidates + + except Exception as e: + logger.error(f"Error finding suitable agents: {str(e)}") + return [] + + def _calculate_agent_task_score(self, task: Dict, agent_info: Dict) -> float: + """Calculate how suitable an agent is for a task""" + score = 0.0 + + # Role matching + task_role = task.get("required_role", "") + agent_role = agent_info.get("role", "") + + if task_role == agent_role: + score += 0.5 # Perfect role match + elif self._is_compatible_role(task_role, agent_role): + score += 0.3 # Compatible role + elif agent_role == "full_stack_engineer": + score += 0.2 # Full-stack can handle most tasks + + # Expertise matching + task_expertise = task.get("required_expertise", []) + agent_expertise = agent_info.get("expertise", []) + + if task_expertise and agent_expertise: + expertise_overlap = len(set(task_expertise) & set(agent_expertise)) + expertise_score = expertise_overlap / len(task_expertise) + score += expertise_score * 0.3 + + # Priority bonus + priority = task.get("priority", 5) + priority_bonus = (priority / 10.0) * 0.1 + score += priority_bonus + + # Availability bonus + availability = agent_info.get("availability", 1.0) + score *= availability + + # Workload penalty + current_tasks = agent_info.get("current_tasks", 0) + max_tasks = agent_info.get("max_tasks", 2) + workload_ratio = current_tasks / max_tasks + workload_penalty = workload_ratio * 0.2 + score -= workload_penalty + + return max(0.0, min(1.0, score)) + + def _is_compatible_role(self, required_role: str, agent_role: str) -> bool: + """Check if agent role is compatible with required role""" + compatibility_map = { + "frontend_developer": ["full_stack_engineer", "ui_ux_designer"], + "backend_developer": ["full_stack_engineer", "database_engineer"], + "qa_engineer": ["full_stack_engineer"], + "devops_engineer": ["systems_engineer", "backend_developer"], + "security_expert": ["backend_developer", "senior_software_architect"], + "ui_ux_designer": ["frontend_developer"], + "technical_writer": ["full_stack_engineer"], + "database_engineer": ["backend_developer", "full_stack_engineer"], + } + + compatible_roles = compatibility_map.get(required_role, []) + return agent_role in compatible_roles + + async def _log_task_assignment(self, db: Session, project: Project, task: Dict, agent: Dict, reason: str): + """Log task assignment for tracking""" + try: + # This would insert into task_assignments table + # For now, just log it + logger.info(f"Task assignment: Project={project.name}, Task={task['number']}, " + f"Agent={agent['id']}, Role={agent['role']}, Reason={reason}") + except Exception as e: + logger.error(f"Failed to log task assignment: {str(e)}") + + async def get_project_task_stats(self, db: Session, project_id: int) -> Dict[str, Any]: + """Get task statistics for a project""" + try: + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + return {"error": "Project not found"} + + # Get recent sync info + last_sync = self._last_sync.get(project_id) + + # Count cached tasks for this project + project_tasks = [ + task_info for task_key, task_info in self._task_cache.items() + if task_key.startswith(f"{project_id}:") + ] + + return { + "project_id": project_id, + "project_name": project.name, + "provider": project.provider or "github", + "last_sync": last_sync.isoformat() if last_sync else None, + "cached_tasks": len(project_tasks), + "bzzz_enabled": project.bzzz_enabled, + "auto_assignment": getattr(project, "auto_assignment", True) + } + + except Exception as e: + logger.error(f"Error getting project task stats: {str(e)}") + return {"error": str(e)} + + async def cleanup_old_cache(self, max_age_hours: int = 24): + """Clean up old task cache entries""" + cutoff_time = datetime.now() - timedelta(hours=max_age_hours) + + to_remove = [] + for task_key, task_info in self._task_cache.items(): + if task_info["assigned_at"] < cutoff_time: + to_remove.append(task_key) + + for key in to_remove: + del self._task_cache[key] + + logger.info(f"Cleaned up {len(to_remove)} old task cache entries") + +# Global instance +repository_service = RepositoryService() \ No newline at end of file diff --git a/backend/migrations/005_add_gitea_repositories.sql b/backend/migrations/005_add_gitea_repositories.sql new file mode 100644 index 00000000..b707e1c4 --- /dev/null +++ b/backend/migrations/005_add_gitea_repositories.sql @@ -0,0 +1,142 @@ +-- Migration to add Gitea repositories and update existing projects + +-- Add provider field to projects table to distinguish between GitHub and Gitea +ALTER TABLE projects ADD COLUMN provider VARCHAR(50) DEFAULT 'github'; +ALTER TABLE projects ADD COLUMN provider_base_url VARCHAR(255); +ALTER TABLE projects ADD COLUMN ssh_port INTEGER DEFAULT 22; + +-- Add Gitea-specific configuration +ALTER TABLE projects ADD COLUMN gitea_enabled BOOLEAN DEFAULT false; +ALTER TABLE projects ADD COLUMN webhook_secret VARCHAR(255); +ALTER TABLE projects ADD COLUMN auto_assignment BOOLEAN DEFAULT true; + +-- Update existing projects to mark them as GitHub +UPDATE projects SET provider = 'github', provider_base_url = 'https://github.com' WHERE provider IS NULL; + +-- Add Gitea repositories +INSERT INTO projects ( + name, + description, + status, + github_repo, + git_url, + git_owner, + git_repository, + git_branch, + bzzz_enabled, + ready_to_claim, + private_repo, + github_token_required, + provider, + provider_base_url, + ssh_port, + gitea_enabled, + auto_assignment +) VALUES +( + 'hive-gitea', + 'Distributed task coordination system with AI agents (Gitea)', + 'active', + 'tony/hive', + 'ssh://git@192.168.1.113:2222/tony/hive.git', + 'tony', + 'hive', + 'master', + true, + true, + false, + false, + 'gitea', + 'http://192.168.1.113:3000', + 2222, + true, + true +), +( + 'bzzz-gitea', + 'P2P collaborative development coordination system (Gitea)', + 'active', + 'tony/bzzz', + 'ssh://git@192.168.1.113:2222/tony/bzzz.git', + 'tony', + 'bzzz', + 'main', + true, + true, + false, + false, + 'gitea', + 'http://192.168.1.113:3000', + 2222, + true, + true +); + +-- Create repository_config table for provider-specific configuration +CREATE TABLE repository_config ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + provider VARCHAR(50) NOT NULL, + config_data JSONB NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Insert default Gitea configuration for our repositories +INSERT INTO repository_config (project_id, provider, config_data) +SELECT + p.id, + 'gitea', + jsonb_build_object( + 'base_url', p.provider_base_url, + 'owner', p.git_owner, + 'repository', p.git_repository, + 'task_label', 'bzzz-task', + 'in_progress_label', 'in-progress', + 'completed_label', 'completed', + 'base_branch', p.git_branch, + 'branch_prefix', 'bzzz/task-', + 'auto_assignment', p.auto_assignment, + 'ssh_port', p.ssh_port + ) +FROM projects p +WHERE p.provider = 'gitea'; + +-- Create task assignment log table +CREATE TABLE task_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id INTEGER REFERENCES projects(id), + task_number INTEGER NOT NULL, + agent_id VARCHAR(255) NOT NULL, + agent_role VARCHAR(255), + assignment_reason TEXT, + status VARCHAR(50) DEFAULT 'assigned', -- assigned, in_progress, completed, failed + assigned_at TIMESTAMP DEFAULT NOW(), + started_at TIMESTAMP, + completed_at TIMESTAMP, + results JSONB, + error_message TEXT +); + +-- Create indexes for task assignments +CREATE INDEX idx_task_assignments_project ON task_assignments(project_id); +CREATE INDEX idx_task_assignments_agent ON task_assignments(agent_id); +CREATE INDEX idx_task_assignments_status ON task_assignments(status); +CREATE INDEX idx_task_assignments_task ON task_assignments(project_id, task_number); + +-- Create webhook events table for tracking repository events +CREATE TABLE webhook_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id INTEGER REFERENCES projects(id), + event_type VARCHAR(100) NOT NULL, + payload JSONB NOT NULL, + processed BOOLEAN DEFAULT false, + processed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Create indexes for webhook events +CREATE INDEX idx_webhook_events_project ON webhook_events(project_id); +CREATE INDEX idx_webhook_events_type ON webhook_events(event_type); +CREATE INDEX idx_webhook_events_processed ON webhook_events(processed); +CREATE INDEX idx_webhook_events_created ON webhook_events(created_at); \ No newline at end of file diff --git a/backend/migrations/006_add_gitea_support.sql b/backend/migrations/006_add_gitea_support.sql new file mode 100644 index 00000000..250c0f4c --- /dev/null +++ b/backend/migrations/006_add_gitea_support.sql @@ -0,0 +1,30 @@ +-- Migration 006: Add Gitea and Multi-Provider Support +-- This migration adds fields for supporting multiple Git providers like Gitea + +-- Add new columns to projects table +ALTER TABLE projects +ADD COLUMN IF NOT EXISTS provider VARCHAR(50) DEFAULT 'github'; + +ALTER TABLE projects +ADD COLUMN IF NOT EXISTS provider_base_url VARCHAR(255) NULL; + +ALTER TABLE projects +ADD COLUMN IF NOT EXISTS auto_assignment BOOLEAN DEFAULT true; + +-- Rename metadata column to avoid SQLAlchemy conflict +ALTER TABLE projects +RENAME COLUMN metadata TO project_metadata; + +-- Update existing records to have default provider +UPDATE projects SET provider = 'github' WHERE provider IS NULL; + +-- Create index for provider for better queries +CREATE INDEX IF NOT EXISTS idx_projects_provider ON projects(provider); +CREATE INDEX IF NOT EXISTS idx_projects_bzzz_enabled ON projects(bzzz_enabled); +CREATE INDEX IF NOT EXISTS idx_projects_auto_assignment ON projects(auto_assignment); + +-- Add comments for documentation +COMMENT ON COLUMN projects.provider IS 'Git provider type: github, gitea, gitlab, etc.'; +COMMENT ON COLUMN projects.provider_base_url IS 'Base URL for self-hosted providers like Gitea'; +COMMENT ON COLUMN projects.auto_assignment IS 'Enable automatic task assignment to agents'; +COMMENT ON COLUMN projects.project_metadata IS 'Additional project metadata as JSON'; \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 8508c1e8..50fb1ee4 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -117,7 +117,7 @@ export interface APIError { // Unified API configuration export const API_CONFIG = { - BASE_URL: process.env.VITE_API_BASE_URL || 'https://hive.home.deepblack.cloud', + BASE_URL: process.env.VITE_API_BASE_URL || 'https://api.hive.home.deepblack.cloud', TIMEOUT: 30000, RETRY_ATTEMPTS: 3, RETRY_DELAY: 1000, diff --git a/push-images.sh b/push-images.sh index 8a9f3841..c46979b8 100755 --- a/push-images.sh +++ b/push-images.sh @@ -4,24 +4,54 @@ set -e -REGISTRY="anthonyrawlins" +LOCAL_REGISTRY="registry.home.deepblack.cloud" +REGISTRY_PORT="5000" +NAMESPACE="tony" BACKEND_IMAGE="hive-backend" FRONTEND_IMAGE="hive-frontend" -LOCAL_BACKEND="hive-hive-backend" -LOCAL_FRONTEND="hive-hive-frontend" -echo "🏗️ Building and pushing Hive images to Docker Hub..." +echo "🏗️ Building and pushing Hive images to local registry..." + +# Change to hive directory +cd "$(dirname "$0")" + +# Build images with docker compose +echo "🔨 Building images with docker compose..." +docker compose -f docker-compose.swarm.yml build + +# Get the actual image names from docker compose +BACKEND_COMPOSE_IMAGE=$(docker compose -f docker-compose.swarm.yml config | grep "image.*hive-backend" | cut -d: -f2- | xargs) +FRONTEND_COMPOSE_IMAGE=$(docker compose -f docker-compose.swarm.yml config | grep "image.*hive-frontend" | cut -d: -f2- | xargs) + +echo "📦 Found backend image: $BACKEND_COMPOSE_IMAGE" +echo "📦 Found frontend image: $FRONTEND_COMPOSE_IMAGE" # Tag and push backend -echo "📦 Pushing backend image..." -docker tag ${LOCAL_BACKEND}:latest ${REGISTRY}/${BACKEND_IMAGE}:latest -docker push ${REGISTRY}/${BACKEND_IMAGE}:latest +echo "📦 Tagging and pushing backend image..." +if [[ "$BACKEND_COMPOSE_IMAGE" != "${LOCAL_REGISTRY}/${NAMESPACE}/${BACKEND_IMAGE}:latest" ]]; then + # If the compose image is locally built, tag it for registry + LOCAL_BACKEND_IMAGE=$(docker images --format "table {{.Repository}}:{{.Tag}}" | grep hive.*backend | head -1) + if [[ -n "$LOCAL_BACKEND_IMAGE" ]]; then + docker tag "$LOCAL_BACKEND_IMAGE" "${LOCAL_REGISTRY}/${NAMESPACE}/${BACKEND_IMAGE}:latest" + fi +fi +docker push "${LOCAL_REGISTRY}/${NAMESPACE}/${BACKEND_IMAGE}:latest" # Tag and push frontend -echo "📦 Pushing frontend image..." -docker tag ${LOCAL_FRONTEND}:latest ${REGISTRY}/${FRONTEND_IMAGE}:latest -docker push ${REGISTRY}/${FRONTEND_IMAGE}:latest +echo "📦 Tagging and pushing frontend image..." +if [[ "$FRONTEND_COMPOSE_IMAGE" != "${LOCAL_REGISTRY}/${NAMESPACE}/${FRONTEND_IMAGE}:latest" ]]; then + # If the compose image is locally built, tag it for registry + LOCAL_FRONTEND_IMAGE=$(docker images --format "table {{.Repository}}:{{.Tag}}" | grep hive.*frontend | head -1) + if [[ -n "$LOCAL_FRONTEND_IMAGE" ]]; then + docker tag "$LOCAL_FRONTEND_IMAGE" "${LOCAL_REGISTRY}/${NAMESPACE}/${FRONTEND_IMAGE}:latest" + fi +fi +docker push "${LOCAL_REGISTRY}/${NAMESPACE}/${FRONTEND_IMAGE}:latest" -echo "✅ Images pushed to Docker Hub successfully!" -echo "Backend: ${REGISTRY}/${BACKEND_IMAGE}:latest" -echo "Frontend: ${REGISTRY}/${FRONTEND_IMAGE}:latest" \ No newline at end of file +echo "✅ Images pushed to local registry successfully!" +echo "Backend: ${LOCAL_REGISTRY}/${NAMESPACE}/${BACKEND_IMAGE}:latest" +echo "Frontend: ${LOCAL_REGISTRY}/${NAMESPACE}/${FRONTEND_IMAGE}:latest" +echo "" +echo "🔍 Verify images in registry:" +echo "curl -X GET http://localhost:${REGISTRY_PORT}/v2/${NAMESPACE}/${BACKEND_IMAGE}/tags/list" +echo "curl -X GET http://localhost:${REGISTRY_PORT}/v2/${NAMESPACE}/${FRONTEND_IMAGE}/tags/list" \ No newline at end of file