diff --git a/docker-compose.yml b/docker-compose.yml index 96943540..ca5b44b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -163,6 +163,7 @@ services: ports: - "${AGENT_WORK_ORDERS_PORT:-8053}:${AGENT_WORK_ORDERS_PORT:-8053}" environment: + - ENABLE_AGENT_WORK_ORDERS=true - SERVICE_DISCOVERY_MODE=docker_compose - ARCHON_SERVER_URL=http://archon-server:${ARCHON_SERVER_PORT:-8181} - ARCHON_MCP_URL=http://archon-mcp:${ARCHON_MCP_PORT:-8051} diff --git a/migration/agent_work_orders_repositories.sql b/migration/agent_work_orders_repositories.sql new file mode 100644 index 00000000..b5079554 --- /dev/null +++ b/migration/agent_work_orders_repositories.sql @@ -0,0 +1,233 @@ +-- ===================================================== +-- Agent Work Orders - Repository Configuration +-- ===================================================== +-- This migration creates the archon_configured_repositories table +-- for storing configured GitHub repositories with metadata and preferences +-- +-- Features: +-- - Repository URL validation and uniqueness +-- - GitHub metadata storage (display_name, owner, default_branch) +-- - Verification status tracking +-- - Per-repository preferences (sandbox type, workflow commands) +-- - Automatic timestamp management +-- - Row Level Security policies +-- +-- Run this in your Supabase SQL Editor +-- ===================================================== + +-- ===================================================== +-- SECTION 1: CREATE TABLE +-- ===================================================== + +-- Create archon_configured_repositories table +CREATE TABLE IF NOT EXISTS archon_configured_repositories ( + -- Primary identification + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Repository identification + repository_url TEXT NOT NULL UNIQUE, + display_name TEXT, -- Extracted from GitHub (e.g., "owner/repo") + owner TEXT, -- Extracted from GitHub + default_branch TEXT, -- Extracted from GitHub (e.g., "main") + + -- Verification status + is_verified BOOLEAN DEFAULT false, + last_verified_at TIMESTAMP WITH TIME ZONE, + + -- Per-repository preferences + -- Note: default_sandbox_type is intentionally restricted to production-ready types only. + -- Experimental types (git_branch, e2b, dagger) are blocked for safety and stability. + default_sandbox_type TEXT DEFAULT 'git_worktree' + CHECK (default_sandbox_type IN ('git_worktree', 'full_clone', 'tmp_dir')), + default_commands JSONB DEFAULT '["create-branch", "planning", "execute", "commit", "create-pr"]'::jsonb, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- URL validation constraint + CONSTRAINT valid_repository_url CHECK ( + repository_url ~ '^https://github\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+/?$' + ) +); + +-- ===================================================== +-- SECTION 2: CREATE INDEXES +-- ===================================================== + +-- Unique index on repository_url (enforces constraint) +CREATE UNIQUE INDEX IF NOT EXISTS idx_configured_repositories_url + ON archon_configured_repositories(repository_url); + +-- Index on is_verified for filtering verified repositories +CREATE INDEX IF NOT EXISTS idx_configured_repositories_verified + ON archon_configured_repositories(is_verified); + +-- Index on created_at for ordering by most recent +CREATE INDEX IF NOT EXISTS idx_configured_repositories_created_at + ON archon_configured_repositories(created_at DESC); + +-- GIN index on default_commands JSONB for querying by commands +CREATE INDEX IF NOT EXISTS idx_configured_repositories_commands + ON archon_configured_repositories USING GIN(default_commands); + +-- ===================================================== +-- SECTION 3: CREATE TRIGGER +-- ===================================================== + +-- Apply auto-update trigger for updated_at timestamp +-- Reuses existing update_updated_at_column() function from complete_setup.sql +CREATE TRIGGER update_configured_repositories_updated_at + BEFORE UPDATE ON archon_configured_repositories + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ===================================================== +-- SECTION 4: ROW LEVEL SECURITY +-- ===================================================== + +-- Enable Row Level Security on the table +ALTER TABLE archon_configured_repositories ENABLE ROW LEVEL SECURITY; + +-- Policy 1: Service role has full access (for API operations) +CREATE POLICY "Allow service role full access to archon_configured_repositories" + ON archon_configured_repositories + FOR ALL + USING (auth.role() = 'service_role'); + +-- Policy 2: Authenticated users can read and update (for frontend operations) +CREATE POLICY "Allow authenticated users to read and update archon_configured_repositories" + ON archon_configured_repositories + FOR ALL + TO authenticated + USING (true); + +-- ===================================================== +-- SECTION 5: TABLE COMMENTS +-- ===================================================== + +-- Add comments to document table structure +COMMENT ON TABLE archon_configured_repositories IS + 'Stores configured GitHub repositories for Agent Work Orders with metadata, verification status, and per-repository preferences'; + +COMMENT ON COLUMN archon_configured_repositories.id IS + 'Unique UUID identifier for the configured repository'; + +COMMENT ON COLUMN archon_configured_repositories.repository_url IS + 'GitHub repository URL (must be https://github.com/owner/repo format)'; + +COMMENT ON COLUMN archon_configured_repositories.display_name IS + 'Human-readable repository name extracted from GitHub API (e.g., "owner/repo-name")'; + +COMMENT ON COLUMN archon_configured_repositories.owner IS + 'Repository owner/organization name extracted from GitHub API'; + +COMMENT ON COLUMN archon_configured_repositories.default_branch IS + 'Default branch name extracted from GitHub API (typically "main" or "master")'; + +COMMENT ON COLUMN archon_configured_repositories.is_verified IS + 'Boolean flag indicating if repository access has been verified via GitHub API'; + +COMMENT ON COLUMN archon_configured_repositories.last_verified_at IS + 'Timestamp of last successful repository verification'; + +COMMENT ON COLUMN archon_configured_repositories.default_sandbox_type IS + 'Default sandbox type for work orders: git_worktree (default), full_clone, or tmp_dir. + IMPORTANT: Intentionally restricted to production-ready types only. + Experimental types (git_branch, e2b, dagger) are blocked by CHECK constraint for safety and stability.'; + +COMMENT ON COLUMN archon_configured_repositories.default_commands IS + 'JSONB array of default workflow commands for work orders (e.g., ["create-branch", "planning", "execute", "commit", "create-pr"])'; + +COMMENT ON COLUMN archon_configured_repositories.created_at IS + 'Timestamp when repository configuration was created'; + +COMMENT ON COLUMN archon_configured_repositories.updated_at IS + 'Timestamp when repository configuration was last updated (auto-managed by trigger)'; + +-- ===================================================== +-- SECTION 6: VERIFICATION +-- ===================================================== + +-- Verify table creation +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'archon_configured_repositories' + ) THEN + RAISE NOTICE '✓ Table archon_configured_repositories created successfully'; + ELSE + RAISE EXCEPTION '✗ Table archon_configured_repositories was not created'; + END IF; +END $$; + +-- Verify indexes +DO $$ +BEGIN + IF ( + SELECT COUNT(*) FROM pg_indexes + WHERE tablename = 'archon_configured_repositories' + ) >= 4 THEN + RAISE NOTICE '✓ Indexes created successfully'; + ELSE + RAISE WARNING '⚠ Expected at least 4 indexes, found fewer'; + END IF; +END $$; + +-- Verify trigger +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgrelid = 'archon_configured_repositories'::regclass + AND tgname = 'update_configured_repositories_updated_at' + ) THEN + RAISE NOTICE '✓ Trigger update_configured_repositories_updated_at created successfully'; + ELSE + RAISE EXCEPTION '✗ Trigger update_configured_repositories_updated_at was not created'; + END IF; +END $$; + +-- Verify RLS policies +DO $$ +BEGIN + IF ( + SELECT COUNT(*) FROM pg_policies + WHERE tablename = 'archon_configured_repositories' + ) >= 2 THEN + RAISE NOTICE '✓ RLS policies created successfully'; + ELSE + RAISE WARNING '⚠ Expected at least 2 RLS policies, found fewer'; + END IF; +END $$; + +-- ===================================================== +-- SECTION 7: ROLLBACK INSTRUCTIONS +-- ===================================================== + +/* +To rollback this migration, run the following commands: + +-- Drop the table (CASCADE will also drop indexes, triggers, and policies) +DROP TABLE IF EXISTS archon_configured_repositories CASCADE; + +-- Verify table is dropped +SELECT table_name FROM information_schema.tables +WHERE table_schema = 'public' +AND table_name = 'archon_configured_repositories'; +-- Should return 0 rows + +-- Note: The update_updated_at_column() function is shared and should NOT be dropped +*/ + +-- ===================================================== +-- MIGRATION COMPLETE +-- ===================================================== +-- The archon_configured_repositories table is now ready for use +-- Next steps: +-- 1. Restart Agent Work Orders service to detect the new table +-- 2. Test repository configuration via API endpoints +-- 3. Verify health endpoint shows table_exists=true +-- ===================================================== diff --git a/python/pyproject.toml b/python/pyproject.toml index af2570ea..59f3e083 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -105,6 +105,7 @@ agent-work-orders = [ "python-dotenv>=1.1.1", "structlog>=25.4.0", "sse-starlette>=2.3.3", + "supabase==2.15.1", ] # All dependencies for running unit tests locally diff --git a/python/src/agent_work_orders/api/routes.py b/python/src/agent_work_orders/api/routes.py index 73e5a258..fcf09700 100644 --- a/python/src/agent_work_orders/api/routes.py +++ b/python/src/agent_work_orders/api/routes.py @@ -5,6 +5,7 @@ FastAPI routes for agent work orders. import asyncio from datetime import datetime +from typing import Any from fastapi import APIRouter, HTTPException, Query from sse_starlette.sse import EventSourceResponse @@ -19,13 +20,17 @@ from ..models import ( AgentWorkOrderResponse, AgentWorkOrderState, AgentWorkOrderStatus, + ConfiguredRepository, CreateAgentWorkOrderRequest, + CreateRepositoryRequest, GitHubRepositoryVerificationRequest, GitHubRepositoryVerificationResponse, GitProgressSnapshot, StepHistory, + UpdateRepositoryRequest, ) from ..sandbox_manager.sandbox_factory import SandboxFactory +from ..state_manager.repository_config_repository import RepositoryConfigRepository from ..state_manager.repository_factory import create_repository from ..utils.id_generator import generate_work_order_id from ..utils.log_buffer import WorkOrderLogBuffer @@ -38,6 +43,7 @@ router = APIRouter() # Initialize dependencies (singletons for MVP) state_repository = create_repository() +repository_config_repo = RepositoryConfigRepository() agent_executor = AgentCLIExecutor() sandbox_factory = SandboxFactory() github_client = GitHubClient() @@ -125,6 +131,319 @@ async def create_agent_work_order( raise HTTPException(status_code=500, detail=f"Failed to create work order: {e}") from e +# ===================================================== +# Repository Configuration Endpoints +# NOTE: These MUST come before the catch-all /{agent_work_order_id} route +# ===================================================== + + +@router.get("/repositories") +async def list_configured_repositories() -> list[ConfiguredRepository]: + """List all configured repositories + + Returns list of all configured repositories ordered by created_at DESC. + Each repository includes metadata, verification status, and preferences. + """ + logger.info("repository_list_started") + + try: + repositories = await repository_config_repo.list_repositories() + + logger.info( + "repository_list_completed", + count=len(repositories) + ) + + return repositories + + except Exception as e: + logger.exception( + "repository_list_failed", + error=str(e) + ) + raise HTTPException(status_code=500, detail=f"Failed to list repositories: {e}") from e + + +@router.post("/repositories", status_code=201) +async def create_configured_repository( + request: CreateRepositoryRequest, +) -> ConfiguredRepository: + """Create a new configured repository + + If verify=True (default), validates repository access via GitHub API + and extracts metadata (display_name, owner, default_branch). + """ + logger.info( + "repository_creation_started", + repository_url=request.repository_url, + verify=request.verify + ) + + try: + # Initialize metadata variables + display_name: str | None = None + owner: str | None = None + default_branch: str | None = None + is_verified = False + + # Verify repository and extract metadata if requested + if request.verify: + try: + is_accessible = await github_client.verify_repository_access(request.repository_url) + + if is_accessible: + repo_info = await github_client.get_repository_info(request.repository_url) + display_name = repo_info.name + owner = repo_info.owner + default_branch = repo_info.default_branch + is_verified = True + logger.info( + "repository_verified", + repository_url=request.repository_url, + display_name=display_name + ) + else: + logger.warning( + "repository_verification_failed", + repository_url=request.repository_url + ) + raise HTTPException( + status_code=400, + detail="Repository not accessible or not found" + ) + except HTTPException: + raise + except Exception as github_error: + logger.error( + "github_api_error_during_verification", + repository_url=request.repository_url, + error=str(github_error), + exc_info=True + ) + raise HTTPException( + status_code=502, + detail=f"GitHub API error during repository verification: {str(github_error)}" + ) from github_error + + # Create repository in database + repository = await repository_config_repo.create_repository( + repository_url=request.repository_url, + display_name=display_name, + owner=owner, + default_branch=default_branch, + is_verified=is_verified, + ) + + logger.info( + "repository_created", + repository_id=repository.id, + repository_url=request.repository_url + ) + + return repository + + except HTTPException: + raise + except ValueError as e: + # Validation errors (e.g., invalid enum values from database) + logger.error( + "repository_validation_error", + repository_url=request.repository_url, + error=str(e), + exc_info=True + ) + raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}") from e + except Exception as e: + # Check for unique constraint violation (duplicate repository_url) + error_message = str(e).lower() + if "unique" in error_message or "duplicate" in error_message: + logger.error( + "repository_url_already_exists", + repository_url=request.repository_url, + error=str(e) + ) + raise HTTPException( + status_code=409, + detail=f"Repository URL already configured: {request.repository_url}" + ) from e + + # All other database/unexpected errors + logger.exception( + "repository_creation_unexpected_error", + repository_url=request.repository_url, + error=str(e) + ) + # For beta: expose detailed error for debugging (as per CLAUDE.md principles) + raise HTTPException( + status_code=500, + detail=f"Failed to create repository: {str(e)}" + ) from e + + +@router.patch("/repositories/{repository_id}") +async def update_configured_repository( + repository_id: str, + request: UpdateRepositoryRequest, +) -> ConfiguredRepository: + """Update an existing configured repository + + Supports partial updates - only provided fields will be updated. + Returns 404 if repository not found. + """ + logger.info( + "repository_update_started", + repository_id=repository_id + ) + + try: + # Build updates dict from non-None fields + updates: dict[str, Any] = {} + if request.default_sandbox_type is not None: + updates["default_sandbox_type"] = request.default_sandbox_type + if request.default_commands is not None: + updates["default_commands"] = request.default_commands + + # Update repository + repository = await repository_config_repo.update_repository(repository_id, **updates) + + if repository is None: + logger.warning( + "repository_not_found_for_update", + repository_id=repository_id + ) + raise HTTPException(status_code=404, detail="Repository not found") + + logger.info( + "repository_updated", + repository_id=repository_id, + updated_fields=list(updates.keys()) + ) + + return repository + + except HTTPException: + raise + except Exception as e: + logger.exception( + "repository_update_failed", + repository_id=repository_id, + error=str(e) + ) + raise HTTPException(status_code=500, detail=f"Failed to update repository: {e}") from e + + +@router.delete("/repositories/{repository_id}", status_code=204) +async def delete_configured_repository(repository_id: str) -> None: + """Delete a configured repository + + Returns 204 No Content on success, 404 if repository not found. + """ + logger.info( + "repository_deletion_started", + repository_id=repository_id + ) + + try: + deleted = await repository_config_repo.delete_repository(repository_id) + + if not deleted: + logger.warning( + "repository_not_found_for_delete", + repository_id=repository_id + ) + raise HTTPException(status_code=404, detail="Repository not found") + + logger.info( + "repository_deleted", + repository_id=repository_id + ) + + except HTTPException: + raise + except Exception as e: + logger.exception( + "repository_deletion_failed", + repository_id=repository_id, + error=str(e) + ) + raise HTTPException(status_code=500, detail=f"Failed to delete repository: {e}") from e + + +@router.post("/repositories/{repository_id}/verify") +async def verify_repository_access(repository_id: str) -> dict[str, bool | str]: + """Re-verify repository access and update metadata + + Calls GitHub API to verify current access and updates repository + metadata if accessible (display_name, owner, default_branch, is_verified, last_verified_at). + Returns verification result with is_accessible boolean. + """ + logger.info( + "repository_verification_started", + repository_id=repository_id + ) + + try: + # Fetch repository from database + repository = await repository_config_repo.get_repository(repository_id) + + if repository is None: + logger.warning( + "repository_not_found_for_verification", + repository_id=repository_id + ) + raise HTTPException(status_code=404, detail="Repository not found") + + # Verify repository access + is_accessible = await github_client.verify_repository_access(repository.repository_url) + + if is_accessible: + # Fetch updated metadata + repo_info = await github_client.get_repository_info(repository.repository_url) + + # Update repository with new metadata + await repository_config_repo.update_repository( + repository_id, + display_name=repo_info.name, + owner=repo_info.owner, + default_branch=repo_info.default_branch, + is_verified=True, + last_verified_at=datetime.now(), + ) + + logger.info( + "repository_verification_success", + repository_id=repository_id, + repository_url=repository.repository_url + ) + else: + # Update verification status to false + await repository_config_repo.update_repository( + repository_id, + is_verified=False, + ) + + logger.warning( + "repository_verification_not_accessible", + repository_id=repository_id, + repository_url=repository.repository_url + ) + + return { + "is_accessible": is_accessible, + "repository_id": repository_id, + } + + except HTTPException: + raise + except Exception as e: + logger.exception( + "repository_verification_failed", + repository_id=repository_id, + error=str(e) + ) + raise HTTPException(status_code=500, detail=f"Failed to verify repository: {e}") from e + + @router.get("/{agent_work_order_id}") async def get_agent_work_order(agent_work_order_id: str) -> AgentWorkOrder: """Get agent work order by ID""" @@ -491,3 +810,5 @@ async def verify_github_repository( default_branch=None, error_message=str(e), ) + + diff --git a/python/src/agent_work_orders/models.py b/python/src/agent_work_orders/models.py index 6c071638..d25be580 100644 --- a/python/src/agent_work_orders/models.py +++ b/python/src/agent_work_orders/models.py @@ -175,6 +175,71 @@ class GitHubRepository(BaseModel): url: str +class ConfiguredRepository(BaseModel): + """Configured repository with metadata and preferences + + Stores GitHub repository configuration for Agent Work Orders, including + verification status, metadata extracted from GitHub API, and per-repository + preferences for sandbox type and workflow commands. + """ + + id: str = Field(..., description="Unique UUID identifier for the configured repository") + repository_url: str = Field(..., description="GitHub repository URL (https://github.com/owner/repo format)") + display_name: str | None = Field(None, description="Human-readable repository name (e.g., 'owner/repo-name')") + owner: str | None = Field(None, description="Repository owner/organization name") + default_branch: str | None = Field(None, description="Default branch name (e.g., 'main' or 'master')") + is_verified: bool = Field(default=False, description="Boolean flag indicating if repository access has been verified") + last_verified_at: datetime | None = Field(None, description="Timestamp of last successful repository verification") + default_sandbox_type: SandboxType = Field( + default=SandboxType.GIT_WORKTREE, + description="Default sandbox type for work orders: git_worktree (default), full_clone, or tmp_dir" + ) + default_commands: list[WorkflowStep] = Field( + default=[ + WorkflowStep.CREATE_BRANCH, + WorkflowStep.PLANNING, + WorkflowStep.EXECUTE, + WorkflowStep.COMMIT, + WorkflowStep.CREATE_PR, + ], + description="Default workflow commands for work orders" + ) + created_at: datetime = Field(..., description="Timestamp when repository configuration was created") + updated_at: datetime = Field(..., description="Timestamp when repository configuration was last updated") + + +class CreateRepositoryRequest(BaseModel): + """Request to create a new configured repository + + Creates a new repository configuration. If verify=True, the system will + call the GitHub API to validate repository access and extract metadata + (display_name, owner, default_branch) before storing. + """ + + repository_url: str = Field(..., description="GitHub repository URL to configure") + verify: bool = Field( + default=True, + description="Whether to verify repository access via GitHub API and extract metadata" + ) + + +class UpdateRepositoryRequest(BaseModel): + """Request to update an existing configured repository + + All fields are optional for partial updates. Only provided fields will be + updated in the database. + """ + + default_sandbox_type: SandboxType | None = Field( + None, + description="Update the default sandbox type for this repository" + ) + default_commands: list[WorkflowStep] | None = Field( + None, + description="Update the default workflow commands for this repository" + ) + + class GitHubPullRequest(BaseModel): """GitHub pull request information""" diff --git a/python/src/agent_work_orders/server.py b/python/src/agent_work_orders/server.py index 78dc7a4a..fba5be2c 100644 --- a/python/src/agent_work_orders/server.py +++ b/python/src/agent_work_orders/server.py @@ -213,6 +213,27 @@ async def health_check() -> dict[str, Any]: "error": str(e), } + # Check Supabase database connectivity (if configured) + supabase_url = os.getenv("SUPABASE_URL") + if supabase_url: + try: + from .state_manager.repository_config_repository import get_supabase_client + + client = get_supabase_client() + # Check if archon_configured_repositories table exists + response = client.table("archon_configured_repositories").select("id").limit(1).execute() + health_status["dependencies"]["supabase"] = { + "available": True, + "table_exists": True, + "url": supabase_url.split("@")[-1] if "@" in supabase_url else supabase_url.split("//")[-1], + } + except Exception as e: + health_status["dependencies"]["supabase"] = { + "available": False, + "table_exists": False, + "error": str(e), + } + # Determine overall status critical_deps_ok = ( health_status["dependencies"].get("claude_cli", {}).get("available", False) diff --git a/python/src/agent_work_orders/state_manager/repository_config_repository.py b/python/src/agent_work_orders/state_manager/repository_config_repository.py new file mode 100644 index 00000000..108842e5 --- /dev/null +++ b/python/src/agent_work_orders/state_manager/repository_config_repository.py @@ -0,0 +1,351 @@ +"""Repository Configuration Repository + +Provides database operations for managing configured GitHub repositories. +Stores repository metadata, verification status, and per-repository preferences. +""" + +import os +from datetime import datetime +from typing import Any + +from supabase import Client, create_client + +from ..models import ConfiguredRepository, SandboxType, WorkflowStep +from ..utils.structured_logger import get_logger + +logger = get_logger(__name__) + + +def get_supabase_client() -> Client: + """Get a Supabase client instance for agent work orders. + + Returns: + Supabase client instance + + Raises: + ValueError: If environment variables are not set + """ + url = os.getenv("SUPABASE_URL") + key = os.getenv("SUPABASE_SERVICE_KEY") + + if not url or not key: + raise ValueError( + "SUPABASE_URL and SUPABASE_SERVICE_KEY must be set in environment variables" + ) + + return create_client(url, key) + + +class RepositoryConfigRepository: + """Repository for managing configured repositories in Supabase + + Provides CRUD operations for the archon_configured_repositories table. + Uses the same Supabase client as the main Archon server for consistency. + + Architecture Note - async/await Pattern: + All repository methods are declared as `async def` for interface consistency + with other repository implementations (FileStateRepository, WorkOrderRepository), + even though the Supabase Python client's operations are synchronous. + + This design choice maintains a consistent async API contract across all + repository implementations, allowing them to be used interchangeably without + caller code changes. The async signature enables future migration to truly + async database clients (e.g., asyncpg) without breaking the interface. + + Current behavior: Methods don't await Supabase operations (which are sync), + but callers should still await repository method calls for forward compatibility. + """ + + def __init__(self) -> None: + """Initialize repository with Supabase client""" + self.client: Client = get_supabase_client() + self.table_name: str = "archon_configured_repositories" + self._logger = logger.bind(table=self.table_name) + self._logger.info("repository_config_repository_initialized") + + def _row_to_model(self, row: dict[str, Any]) -> ConfiguredRepository: + """Convert database row to ConfiguredRepository model + + Args: + row: Database row dictionary + + Returns: + ConfiguredRepository model instance + + Raises: + ValueError: If row contains invalid enum values that cannot be converted + """ + repository_id = row.get("id", "unknown") + + # Convert default_commands from list of strings to list of WorkflowStep enums + default_commands_raw = row.get("default_commands", []) + try: + default_commands = [WorkflowStep(cmd) for cmd in default_commands_raw] + except ValueError as e: + self._logger.error( + "invalid_workflow_step_in_database", + repository_id=repository_id, + invalid_commands=default_commands_raw, + error=str(e), + exc_info=True + ) + raise ValueError( + f"Database contains invalid workflow steps for repository {repository_id}: {default_commands_raw}" + ) from e + + # Convert default_sandbox_type from string to SandboxType enum + sandbox_type_raw = row.get("default_sandbox_type", "git_worktree") + try: + sandbox_type = SandboxType(sandbox_type_raw) + except ValueError as e: + self._logger.error( + "invalid_sandbox_type_in_database", + repository_id=repository_id, + invalid_type=sandbox_type_raw, + error=str(e), + exc_info=True + ) + raise ValueError( + f"Database contains invalid sandbox type for repository {repository_id}: {sandbox_type_raw}" + ) from e + + return ConfiguredRepository( + id=row["id"], + repository_url=row["repository_url"], + display_name=row.get("display_name"), + owner=row.get("owner"), + default_branch=row.get("default_branch"), + is_verified=row.get("is_verified", False), + last_verified_at=row.get("last_verified_at"), + default_sandbox_type=sandbox_type, + default_commands=default_commands, + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + async def list_repositories(self) -> list[ConfiguredRepository]: + """List all configured repositories + + Returns: + List of ConfiguredRepository models ordered by created_at DESC + + Raises: + Exception: If database query fails + """ + try: + response = self.client.table(self.table_name).select("*").order("created_at", desc=True).execute() + + repositories = [self._row_to_model(row) for row in response.data] + + self._logger.info( + "repositories_listed", + count=len(repositories) + ) + + return repositories + + except Exception as e: + self._logger.exception( + "list_repositories_failed", + error=str(e) + ) + raise + + async def get_repository(self, repository_id: str) -> ConfiguredRepository | None: + """Get a single repository by ID + + Args: + repository_id: UUID of the repository + + Returns: + ConfiguredRepository model or None if not found + + Raises: + Exception: If database query fails + """ + try: + response = self.client.table(self.table_name).select("*").eq("id", repository_id).execute() + + if not response.data: + self._logger.info( + "repository_not_found", + repository_id=repository_id + ) + return None + + repository = self._row_to_model(response.data[0]) + + self._logger.info( + "repository_retrieved", + repository_id=repository_id, + repository_url=repository.repository_url + ) + + return repository + + except Exception as e: + self._logger.exception( + "get_repository_failed", + repository_id=repository_id, + error=str(e) + ) + raise + + async def create_repository( + self, + repository_url: str, + display_name: str | None = None, + owner: str | None = None, + default_branch: str | None = None, + is_verified: bool = False, + ) -> ConfiguredRepository: + """Create a new configured repository + + Args: + repository_url: GitHub repository URL + display_name: Human-readable repository name (e.g., "owner/repo") + owner: Repository owner/organization + default_branch: Default branch name (e.g., "main") + is_verified: Whether repository access has been verified + + Returns: + Created ConfiguredRepository model + + Raises: + Exception: If database insert fails (e.g., unique constraint violation) + """ + try: + # Prepare data for insertion + data: dict[str, Any] = { + "repository_url": repository_url, + "display_name": display_name, + "owner": owner, + "default_branch": default_branch, + "is_verified": is_verified, + } + + # Set last_verified_at if verified + if is_verified: + data["last_verified_at"] = datetime.now().isoformat() + + response = self.client.table(self.table_name).insert(data).execute() + + repository = self._row_to_model(response.data[0]) + + self._logger.info( + "repository_created", + repository_id=repository.id, + repository_url=repository_url, + is_verified=is_verified + ) + + return repository + + except Exception as e: + self._logger.exception( + "create_repository_failed", + repository_url=repository_url, + error=str(e) + ) + raise + + async def update_repository( + self, + repository_id: str, + **updates: Any + ) -> ConfiguredRepository | None: + """Update an existing repository + + Args: + repository_id: UUID of the repository + **updates: Fields to update (any valid column name) + + Returns: + Updated ConfiguredRepository model or None if not found + + Raises: + Exception: If database update fails + """ + try: + # Convert enum values to strings for database storage + prepared_updates: dict[str, Any] = {} + for key, value in updates.items(): + if isinstance(value, SandboxType): + prepared_updates[key] = value.value + elif isinstance(value, list) and value and isinstance(value[0], WorkflowStep): + prepared_updates[key] = [step.value for step in value] + else: + prepared_updates[key] = value + + # Always update updated_at timestamp + prepared_updates["updated_at"] = datetime.now().isoformat() + + response = ( + self.client.table(self.table_name) + .update(prepared_updates) + .eq("id", repository_id) + .execute() + ) + + if not response.data: + self._logger.info( + "repository_not_found_for_update", + repository_id=repository_id + ) + return None + + repository = self._row_to_model(response.data[0]) + + self._logger.info( + "repository_updated", + repository_id=repository_id, + updated_fields=list(updates.keys()) + ) + + return repository + + except Exception as e: + self._logger.exception( + "update_repository_failed", + repository_id=repository_id, + error=str(e) + ) + raise + + async def delete_repository(self, repository_id: str) -> bool: + """Delete a repository by ID + + Args: + repository_id: UUID of the repository + + Returns: + True if deleted, False if not found + + Raises: + Exception: If database delete fails + """ + try: + response = self.client.table(self.table_name).delete().eq("id", repository_id).execute() + + deleted = len(response.data) > 0 + + if deleted: + self._logger.info( + "repository_deleted", + repository_id=repository_id + ) + else: + self._logger.info( + "repository_not_found_for_delete", + repository_id=repository_id + ) + + return deleted + + except Exception as e: + self._logger.exception( + "delete_repository_failed", + repository_id=repository_id, + error=str(e) + ) + raise diff --git a/python/tests/agent_work_orders/test_repository_config_repository.py b/python/tests/agent_work_orders/test_repository_config_repository.py new file mode 100644 index 00000000..b8c413a4 --- /dev/null +++ b/python/tests/agent_work_orders/test_repository_config_repository.py @@ -0,0 +1,454 @@ +"""Unit Tests for RepositoryConfigRepository + +Tests all CRUD operations for configured repositories. +""" + +import pytest +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +from src.agent_work_orders.models import ConfiguredRepository, SandboxType, WorkflowStep +from src.agent_work_orders.state_manager.repository_config_repository import RepositoryConfigRepository + + +@pytest.fixture +def mock_supabase_client(): + """Mock Supabase client with chainable methods""" + mock = MagicMock() + + # Set up method chaining: table().select().order().execute() + mock.table.return_value = mock + mock.select.return_value = mock + mock.order.return_value = mock + mock.insert.return_value = mock + mock.update.return_value = mock + mock.delete.return_value = mock + mock.eq.return_value = mock + + # Execute returns response with data attribute + mock.execute.return_value = MagicMock(data=[]) + + return mock + + +@pytest.fixture +def repository_instance(mock_supabase_client): + """Create RepositoryConfigRepository instance with mocked client""" + with patch('src.agent_work_orders.state_manager.repository_config_repository.get_supabase_client', return_value=mock_supabase_client): + return RepositoryConfigRepository() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_list_repositories_returns_all_repositories(repository_instance, mock_supabase_client): + """Test listing all repositories""" + # Mock response data + mock_data = [ + { + "id": "repo-1", + "repository_url": "https://github.com/test/repo1", + "display_name": "test/repo1", + "owner": "test", + "default_branch": "main", + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "git_worktree", + "default_commands": ["create-branch", "planning", "execute"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + } + ] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + # Call method + repositories = await repository_instance.list_repositories() + + # Assertions + assert len(repositories) == 1 + assert isinstance(repositories[0], ConfiguredRepository) + assert repositories[0].id == "repo-1" + assert repositories[0].repository_url == "https://github.com/test/repo1" + + # Verify Supabase client methods called correctly + mock_supabase_client.table.assert_called_once_with("archon_configured_repositories") + mock_supabase_client.select.assert_called_once() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_list_repositories_with_empty_result(repository_instance, mock_supabase_client): + """Test listing repositories when database is empty""" + mock_supabase_client.execute.return_value = MagicMock(data=[]) + + repositories = await repository_instance.list_repositories() + + assert repositories == [] + assert isinstance(repositories, list) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_get_repository_success(repository_instance, mock_supabase_client): + """Test getting a single repository by ID""" + mock_data = [{ + "id": "repo-1", + "repository_url": "https://github.com/test/repo1", + "display_name": "test/repo1", + "owner": "test", + "default_branch": "main", + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "git_worktree", + "default_commands": ["create-branch", "planning"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + }] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + repository = await repository_instance.get_repository("repo-1") + + assert repository is not None + assert isinstance(repository, ConfiguredRepository) + assert repository.id == "repo-1" + mock_supabase_client.eq.assert_called_with("id", "repo-1") + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_get_repository_not_found(repository_instance, mock_supabase_client): + """Test getting a repository that doesn't exist""" + mock_supabase_client.execute.return_value = MagicMock(data=[]) + + repository = await repository_instance.get_repository("nonexistent-id") + + assert repository is None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_create_repository_success(repository_instance, mock_supabase_client): + """Test creating a new repository""" + mock_data = [{ + "id": "new-repo-id", + "repository_url": "https://github.com/test/newrepo", + "display_name": "test/newrepo", + "owner": "test", + "default_branch": "main", + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "git_worktree", + "default_commands": ["create-branch", "planning", "execute", "commit", "create-pr"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + }] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + repository = await repository_instance.create_repository( + repository_url="https://github.com/test/newrepo", + display_name="test/newrepo", + owner="test", + default_branch="main", + is_verified=True, + ) + + assert repository is not None + assert repository.id == "new-repo-id" + assert repository.repository_url == "https://github.com/test/newrepo" + assert repository.is_verified is True + mock_supabase_client.insert.assert_called_once() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_create_repository_with_verification(repository_instance, mock_supabase_client): + """Test creating a repository with is_verified=True sets last_verified_at""" + mock_data = [{ + "id": "verified-repo", + "repository_url": "https://github.com/test/verified", + "display_name": None, + "owner": None, + "default_branch": None, + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "git_worktree", + "default_commands": ["create-branch", "planning", "execute", "commit", "create-pr"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + }] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + repository = await repository_instance.create_repository( + repository_url="https://github.com/test/verified", + is_verified=True, + ) + + assert repository.is_verified is True + assert repository.last_verified_at is not None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_update_repository_success(repository_instance, mock_supabase_client): + """Test updating a repository""" + mock_data = [{ + "id": "repo-1", + "repository_url": "https://github.com/test/repo1", + "display_name": "test/repo1", + "owner": "test", + "default_branch": "main", + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "git_branch", # Updated value (valid enum) + "default_commands": ["create-branch", "execute"], # Updated value + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + }] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + repository = await repository_instance.update_repository( + "repo-1", + default_sandbox_type=SandboxType.GIT_BRANCH, + default_commands=[WorkflowStep.CREATE_BRANCH, WorkflowStep.EXECUTE], + ) + + assert repository is not None + assert repository.id == "repo-1" + mock_supabase_client.update.assert_called_once() + mock_supabase_client.eq.assert_called_with("id", "repo-1") + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_update_repository_not_found(repository_instance, mock_supabase_client): + """Test updating a repository that doesn't exist""" + mock_supabase_client.execute.return_value = MagicMock(data=[]) + + repository = await repository_instance.update_repository( + "nonexistent-id", + default_sandbox_type=SandboxType.GIT_WORKTREE, + ) + + assert repository is None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_delete_repository_success(repository_instance, mock_supabase_client): + """Test deleting a repository""" + mock_data = [{"id": "repo-1"}] # Supabase returns deleted row + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + deleted = await repository_instance.delete_repository("repo-1") + + assert deleted is True + mock_supabase_client.delete.assert_called_once() + mock_supabase_client.eq.assert_called_with("id", "repo-1") + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_delete_repository_not_found(repository_instance, mock_supabase_client): + """Test deleting a repository that doesn't exist""" + mock_supabase_client.execute.return_value = MagicMock(data=[]) + + deleted = await repository_instance.delete_repository("nonexistent-id") + + assert deleted is False + + +# ===================================================== +# Additional Error Handling Tests +# ===================================================== + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_row_to_model_with_invalid_workflow_step(repository_instance): + """Test _row_to_model raises ValueError for invalid workflow step""" + invalid_row = { + "id": "test-id", + "repository_url": "https://github.com/test/repo", + "display_name": "test/repo", + "owner": "test", + "default_branch": "main", + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "git_worktree", + "default_commands": ["invalid-command", "planning"], # Invalid command + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + } + + with pytest.raises(ValueError) as exc_info: + repository_instance._row_to_model(invalid_row) + + assert "invalid workflow steps" in str(exc_info.value).lower() + assert "test-id" in str(exc_info.value) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_row_to_model_with_invalid_sandbox_type(repository_instance): + """Test _row_to_model raises ValueError for invalid sandbox type""" + invalid_row = { + "id": "test-id", + "repository_url": "https://github.com/test/repo", + "display_name": "test/repo", + "owner": "test", + "default_branch": "main", + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "invalid_type", # Invalid type + "default_commands": ["create-branch", "planning"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + } + + with pytest.raises(ValueError) as exc_info: + repository_instance._row_to_model(invalid_row) + + assert "invalid sandbox type" in str(exc_info.value).lower() + assert "test-id" in str(exc_info.value) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_create_repository_with_all_fields(repository_instance, mock_supabase_client): + """Test creating a repository with all optional fields populated""" + mock_data = [{ + "id": "full-repo-id", + "repository_url": "https://github.com/test/fullrepo", + "display_name": "test/fullrepo", + "owner": "test", + "default_branch": "develop", + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "git_worktree", + "default_commands": ["create-branch", "planning", "execute", "commit", "create-pr"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + }] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + repository = await repository_instance.create_repository( + repository_url="https://github.com/test/fullrepo", + display_name="test/fullrepo", + owner="test", + default_branch="develop", + is_verified=True, + ) + + assert repository.id == "full-repo-id" + assert repository.display_name == "test/fullrepo" + assert repository.owner == "test" + assert repository.default_branch == "develop" + assert repository.is_verified is True + assert repository.last_verified_at is not None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_update_repository_with_multiple_fields(repository_instance, mock_supabase_client): + """Test updating repository with multiple fields at once""" + mock_data = [{ + "id": "repo-1", + "repository_url": "https://github.com/test/repo1", + "display_name": "updated-name", + "owner": "updated-owner", + "default_branch": "updated-branch", + "is_verified": True, + "last_verified_at": datetime.now().isoformat(), + "default_sandbox_type": "git_worktree", + "default_commands": ["create-branch"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + }] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + repository = await repository_instance.update_repository( + "repo-1", + display_name="updated-name", + owner="updated-owner", + default_branch="updated-branch", + is_verified=True, + ) + + assert repository is not None + assert repository.display_name == "updated-name" + assert repository.owner == "updated-owner" + assert repository.default_branch == "updated-branch" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_list_repositories_with_multiple_items(repository_instance, mock_supabase_client): + """Test listing multiple repositories""" + mock_data = [ + { + "id": f"repo-{i}", + "repository_url": f"https://github.com/test/repo{i}", + "display_name": f"test/repo{i}", + "owner": "test", + "default_branch": "main", + "is_verified": i % 2 == 0, # Alternate verified status + "last_verified_at": datetime.now().isoformat() if i % 2 == 0 else None, + "default_sandbox_type": "git_worktree", + "default_commands": ["create-branch", "planning", "execute"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + } + for i in range(5) + ] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + repositories = await repository_instance.list_repositories() + + assert len(repositories) == 5 + assert all(isinstance(repo, ConfiguredRepository) for repo in repositories) + # Check verification status alternates + assert repositories[0].is_verified is True + assert repositories[1].is_verified is False + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_create_repository_database_error(repository_instance, mock_supabase_client): + """Test create_repository handles database errors properly""" + mock_supabase_client.execute.side_effect = Exception("Database connection failed") + + with pytest.raises(Exception) as exc_info: + await repository_instance.create_repository( + repository_url="https://github.com/test/repo", + is_verified=False, + ) + + assert "Database connection failed" in str(exc_info.value) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_get_repository_with_minimal_data(repository_instance, mock_supabase_client): + """Test getting repository with minimal fields (all optionals null)""" + mock_data = [{ + "id": "minimal-repo", + "repository_url": "https://github.com/test/minimal", + "display_name": None, + "owner": None, + "default_branch": None, + "is_verified": False, + "last_verified_at": None, + "default_sandbox_type": "git_worktree", + "default_commands": ["create-branch"], + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + }] + mock_supabase_client.execute.return_value = MagicMock(data=mock_data) + + repository = await repository_instance.get_repository("minimal-repo") + + assert repository is not None + assert repository.display_name is None + assert repository.owner is None + assert repository.default_branch is None + assert repository.is_verified is False + assert repository.last_verified_at is None diff --git a/python/uv.lock b/python/uv.lock index 9b65a102..693c40cc 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -172,6 +172,7 @@ agent-work-orders = [ { name = "python-dotenv" }, { name = "sse-starlette" }, { name = "structlog" }, + { name = "supabase" }, { name = "uvicorn" }, ] agents = [ @@ -277,6 +278,7 @@ agent-work-orders = [ { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "sse-starlette", specifier = ">=2.3.3" }, { name = "structlog", specifier = ">=25.4.0" }, + { name = "supabase", specifier = "==2.15.1" }, { name = "uvicorn", specifier = ">=0.38.0" }, ] agents = [