feat: implement tddai Python library for TDD workspace management
- Create comprehensive tddai package with workspace, issue fetcher, and test generator modules - Add Python CLI interface (tddai_cli.py) to replace complex Makefile shell logic - Update Makefile targets to use Python CLI for better maintainability - Implement proper behavior-based tests instead of file existence checks - Add workspace lifecycle management (create, active, finish, cleanup) - Add issue fetching from Gitea API with error handling - Add comprehensive test coverage with 19 passing tests - Support environment variable configuration for different deployments This addresses issue #11: Setup TDD workspace infrastructure All tests pass and the system achieves green state before commit. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
219
tddai/workspace.py
Normal file
219
tddai/workspace.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Workspace management for tddai.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .config import get_config
|
||||
from .exceptions import WorkspaceError
|
||||
|
||||
|
||||
class WorkspaceStatus(Enum):
|
||||
"""Status of workspace."""
|
||||
CLEAN = "clean"
|
||||
ACTIVE = "active"
|
||||
DIRTY = "dirty"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Workspace:
|
||||
"""Represents a TDD workspace for an issue."""
|
||||
|
||||
issue_number: int
|
||||
issue_title: str
|
||||
issue_body: str
|
||||
issue_state: str
|
||||
created_at: datetime
|
||||
workspace_dir: Path
|
||||
|
||||
@property
|
||||
def issue_dir(self) -> Path:
|
||||
"""Get the issue-specific directory."""
|
||||
return self.workspace_dir / f"issue_{self.issue_number}"
|
||||
|
||||
@property
|
||||
def tests_dir(self) -> Path:
|
||||
"""Get the tests directory for this issue."""
|
||||
return self.issue_dir / "tests"
|
||||
|
||||
@property
|
||||
def requirements_file(self) -> Path:
|
||||
"""Get the requirements file path."""
|
||||
return self.issue_dir / "requirements.md"
|
||||
|
||||
@property
|
||||
def test_plan_file(self) -> Path:
|
||||
"""Get the test plan file path."""
|
||||
return self.issue_dir / "test_plan.md"
|
||||
|
||||
|
||||
class WorkspaceManager:
|
||||
"""Manages TDD workspaces for issues."""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config or get_config()
|
||||
|
||||
def get_status(self) -> WorkspaceStatus:
|
||||
"""Get current workspace status."""
|
||||
if not self.config.workspace_dir.exists():
|
||||
return WorkspaceStatus.CLEAN
|
||||
|
||||
if not self.config.current_issue_path.exists():
|
||||
return WorkspaceStatus.DIRTY
|
||||
|
||||
return WorkspaceStatus.ACTIVE
|
||||
|
||||
def get_current_workspace(self) -> Optional[Workspace]:
|
||||
"""Get the currently active workspace."""
|
||||
if not self.config.current_issue_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(self.config.current_issue_path, 'r') as f:
|
||||
issue_data = json.load(f)
|
||||
|
||||
return Workspace(
|
||||
issue_number=issue_data['number'],
|
||||
issue_title=issue_data['title'],
|
||||
issue_body=issue_data['body'],
|
||||
issue_state=issue_data['state'],
|
||||
created_at=datetime.fromisoformat(issue_data['created_at']),
|
||||
workspace_dir=self.config.workspace_dir
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
raise WorkspaceError(f"Failed to load current workspace: {e}")
|
||||
|
||||
def create_workspace(self, issue_data: Dict[str, Any]) -> Workspace:
|
||||
"""Create a new workspace for an issue."""
|
||||
status = self.get_status()
|
||||
if status == WorkspaceStatus.ACTIVE:
|
||||
current = self.get_current_workspace()
|
||||
raise WorkspaceError(
|
||||
f"Workspace already active for issue #{current.issue_number}. "
|
||||
"Finish current workspace before starting a new one."
|
||||
)
|
||||
|
||||
# Clean up any dirty workspace
|
||||
if status == WorkspaceStatus.DIRTY:
|
||||
self.cleanup_workspace()
|
||||
|
||||
# Create workspace structure
|
||||
workspace = Workspace(
|
||||
issue_number=issue_data['number'],
|
||||
issue_title=issue_data['title'],
|
||||
issue_body=issue_data['body'],
|
||||
issue_state=issue_data['state'],
|
||||
created_at=datetime.now(),
|
||||
workspace_dir=self.config.workspace_dir
|
||||
)
|
||||
|
||||
# Create directories
|
||||
workspace.workspace_dir.mkdir(exist_ok=True)
|
||||
workspace.issue_dir.mkdir(exist_ok=True)
|
||||
workspace.tests_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create metadata files
|
||||
self._create_requirements_file(workspace, issue_data)
|
||||
self._create_test_plan_file(workspace, issue_data)
|
||||
self._save_current_issue(workspace, issue_data)
|
||||
|
||||
return workspace
|
||||
|
||||
def cleanup_workspace(self) -> None:
|
||||
"""Clean up the current workspace."""
|
||||
if self.config.workspace_dir.exists():
|
||||
shutil.rmtree(self.config.workspace_dir)
|
||||
|
||||
def finish_workspace(self) -> Optional[Workspace]:
|
||||
"""Finish the current workspace and integrate tests."""
|
||||
workspace = self.get_current_workspace()
|
||||
if not workspace:
|
||||
return None
|
||||
|
||||
# Move tests to main tests directory
|
||||
main_tests_dir = self.config.tests_dir
|
||||
main_tests_dir.mkdir(exist_ok=True)
|
||||
|
||||
if workspace.tests_dir.exists():
|
||||
for test_file in workspace.tests_dir.glob("*.py"):
|
||||
dest_file = main_tests_dir / test_file.name
|
||||
shutil.copy2(test_file, dest_file)
|
||||
|
||||
# Clean up workspace
|
||||
self.cleanup_workspace()
|
||||
|
||||
return workspace
|
||||
|
||||
def _create_requirements_file(self, workspace: Workspace, issue_data: Dict[str, Any]) -> None:
|
||||
"""Create requirements.md file for the issue."""
|
||||
content = f"""# Requirements for Issue #{workspace.issue_number}
|
||||
|
||||
## Title
|
||||
{workspace.issue_title}
|
||||
|
||||
## Description
|
||||
{workspace.issue_body}
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Implementation meets the requirements described above
|
||||
- [ ] All tests pass
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] Documentation is updated if needed
|
||||
|
||||
## Notes
|
||||
Created: {workspace.created_at.strftime('%Y-%m-%d %H:%M:%S')}
|
||||
"""
|
||||
workspace.requirements_file.write_text(content)
|
||||
|
||||
def _create_test_plan_file(self, workspace: Workspace, issue_data: Dict[str, Any]) -> None:
|
||||
"""Create test_plan.md file for the issue."""
|
||||
content = f"""# Test Plan for Issue #{workspace.issue_number}
|
||||
|
||||
## Overview
|
||||
This test plan outlines the testing strategy for implementing: {workspace.issue_title}
|
||||
|
||||
## Test Categories
|
||||
|
||||
### Unit Tests
|
||||
- [ ] Core functionality tests
|
||||
- [ ] Edge case handling
|
||||
- [ ] Error condition tests
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Component integration
|
||||
- [ ] API integration
|
||||
- [ ] End-to-end scenarios
|
||||
|
||||
### Generated Tests
|
||||
Tests generated for this workspace will be listed here as they are created.
|
||||
|
||||
## Test Execution
|
||||
Run tests with: `pytest tests/test_issue_{workspace.issue_number}_*.py`
|
||||
|
||||
## Notes
|
||||
- Follow TDD red-green-refactor cycle
|
||||
- Each test should be focused and specific
|
||||
- Tests should be readable and maintainable
|
||||
"""
|
||||
workspace.test_plan_file.write_text(content)
|
||||
|
||||
def _save_current_issue(self, workspace: Workspace, issue_data: Dict[str, Any]) -> None:
|
||||
"""Save current issue metadata."""
|
||||
current_issue_data = {
|
||||
'number': workspace.issue_number,
|
||||
'title': workspace.issue_title,
|
||||
'body': workspace.issue_body,
|
||||
'state': workspace.issue_state,
|
||||
'created_at': workspace.created_at.isoformat(),
|
||||
'url': issue_data.get('html_url', ''),
|
||||
'assignee': issue_data.get('assignee', {}).get('login', '') if issue_data.get('assignee') else ''
|
||||
}
|
||||
|
||||
with open(self.config.current_issue_path, 'w') as f:
|
||||
json.dump(current_issue_data, f, indent=2)
|
||||
Reference in New Issue
Block a user