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:
183
Makefile
183
Makefile
@@ -230,22 +230,7 @@ CURRENT_ISSUE_FILE := $(WORKSPACE_DIR)/current_issue.json
|
||||
|
||||
# List all gitea issues
|
||||
list-issues:
|
||||
@echo "📋 MarkiTect Issues from Gitea Repository"
|
||||
@echo "========================================"
|
||||
@echo ""
|
||||
@if ! command -v curl >/dev/null 2>&1; then \
|
||||
echo "❌ curl not found - required for API access"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! command -v jq >/dev/null 2>&1; then \
|
||||
echo "⚠️ jq not found - using basic formatting"; \
|
||||
echo " Install jq for better formatting: sudo apt install jq"; \
|
||||
curl -s "$(ISSUES_API)" | head -20; \
|
||||
else \
|
||||
curl -s "$(ISSUES_API)" | jq -r '.[] | "[\(.state | ascii_upcase)] #\(.number): \(.title)\n Created: \(.created_at[:10]) | Updated: \(.updated_at[:10])\n \(.body[:80])...\n"' | head -40; \
|
||||
fi
|
||||
@echo ""
|
||||
@echo "💡 Tip: Use 'make show-issue NUM=X' to see full details"
|
||||
@python3 tddai_cli.py list-issues
|
||||
|
||||
# Show detailed view of a specific issue
|
||||
show-issue:
|
||||
@@ -253,39 +238,11 @@ show-issue:
|
||||
echo "❌ Please specify issue number: make show-issue NUM=5"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! command -v curl >/dev/null 2>&1; then \
|
||||
echo "❌ curl not found - required for API access"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "🔍 Issue #$(NUM) Details"
|
||||
@echo "======================="
|
||||
@echo ""
|
||||
@if ! command -v jq >/dev/null 2>&1; then \
|
||||
echo "⚠️ jq not found - using basic formatting"; \
|
||||
curl -s "$(ISSUES_API)/$(NUM)"; \
|
||||
else \
|
||||
curl -s "$(ISSUES_API)/$(NUM)" | jq -r 'if . == null or .message then "❌ Issue #$(NUM) not found or API error" else "**Title:** " + .title + "\n**Status:** " + (.state | ascii_upcase) + "\n**Number:** #" + (.number | tostring) + "\n**Created:** " + (.created_at[:10]) + " by " + (.user.full_name // .user.login) + "\n**Updated:** " + (.updated_at[:10]) + "\n**URL:** " + .html_url + "\n\n**Description:**\n" + .body end' 2>/dev/null || echo "❌ Issue #$(NUM) not found or API error"; \
|
||||
fi
|
||||
@echo ""
|
||||
@echo "💡 Tip: Use 'make list-issues' to see all issues"
|
||||
@python3 tddai_cli.py show-issue $(NUM)
|
||||
|
||||
# List only open issues (active backlog)
|
||||
list-open-issues:
|
||||
@echo "📋 Open MarkiTect Issues (Active Backlog)"
|
||||
@echo "========================================"
|
||||
@echo ""
|
||||
@if ! command -v curl >/dev/null 2>&1; then \
|
||||
echo "❌ curl not found - required for API access"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! command -v jq >/dev/null 2>&1; then \
|
||||
echo "⚠️ jq not found - using basic formatting"; \
|
||||
curl -s "$(ISSUES_API)?state=open" | head -20; \
|
||||
else \
|
||||
curl -s "$(ISSUES_API)?state=open" | jq -r '.[] | "[OPEN] #\(.number): \(.title)\n Created: \(.created_at[:10]) | Updated: \(.updated_at[:10])\n \(.body[:80])...\n"' | head -40; \
|
||||
fi
|
||||
@echo ""
|
||||
@echo "💡 Tip: Use 'make show-issue NUM=X' for full details or 'make list-issues' for all issues"
|
||||
@python3 tddai_cli.py list-open-issues
|
||||
|
||||
# Generate test skeleton from gitea issue (requires Claude Code)
|
||||
test-from-issue:
|
||||
@@ -316,142 +273,16 @@ start-issue:
|
||||
echo "❌ Please specify issue number: make start-issue NUM=1"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "🔍 Starting work on issue #$(NUM)..."
|
||||
@if [ -f "$(CURRENT_ISSUE_FILE)" ]; then \
|
||||
CURRENT=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.number // "unknown"'); \
|
||||
echo "⚠️ Already working on issue #$$CURRENT"; \
|
||||
echo " Run 'make finish-issue' first or 'make workspace-status' to see details"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then \
|
||||
echo "❌ curl and jq required for workspace management"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "📋 Fetching issue #$(NUM) details..."
|
||||
@ISSUE_DATA=$$(curl -s "$(ISSUES_API)/$(NUM)" 2>/dev/null); \
|
||||
if echo "$$ISSUE_DATA" | jq -e '.title' >/dev/null 2>&1; then \
|
||||
mkdir -p "$(WORKSPACE_DIR)/issue_$(NUM)/tests"; \
|
||||
echo "$$ISSUE_DATA" | jq '{number: .number, title: .title, body: .body, state: .state, created_at: .created_at, html_url: .html_url}' > "$(CURRENT_ISSUE_FILE)"; \
|
||||
echo "$$ISSUE_DATA" | jq -r '"# Issue #" + (.number | tostring) + ": " + .title + "\n\n## Description\n" + .body + "\n\n## Requirements Breakdown\n\n- [ ] TODO: Break down requirements into testable scenarios\n- [ ] TODO: Identify edge cases\n- [ ] TODO: Define acceptance criteria\n\n## Test Plan\n\n- [ ] TODO: List specific test scenarios to implement\n"' > "$(WORKSPACE_DIR)/issue_$(NUM)/requirements.md"; \
|
||||
echo "# Test Plan for Issue #$(NUM)" > "$(WORKSPACE_DIR)/issue_$(NUM)/test_plan.md"; \
|
||||
echo "" >> "$(WORKSPACE_DIR)/issue_$(NUM)/test_plan.md"; \
|
||||
echo "## Test Scenarios" >> "$(WORKSPACE_DIR)/issue_$(NUM)/test_plan.md"; \
|
||||
echo "" >> "$(WORKSPACE_DIR)/issue_$(NUM)/test_plan.md"; \
|
||||
echo "- [ ] TODO: Add specific test scenarios" >> "$(WORKSPACE_DIR)/issue_$(NUM)/test_plan.md"; \
|
||||
echo "✅ Workspace created for issue #$(NUM)"; \
|
||||
echo "📁 Workspace: $(WORKSPACE_DIR)/issue_$(NUM)/"; \
|
||||
echo "📋 Requirements: $(WORKSPACE_DIR)/issue_$(NUM)/requirements.md"; \
|
||||
echo "🧪 Test plan: $(WORKSPACE_DIR)/issue_$(NUM)/test_plan.md"; \
|
||||
echo ""; \
|
||||
echo "💡 Next steps:"; \
|
||||
echo " 1. Review requirements.md and break down the issue"; \
|
||||
echo " 2. Plan test scenarios in test_plan.md"; \
|
||||
echo " 3. Use 'make add-test' to generate tests"; \
|
||||
echo " 4. Use 'make finish-issue' when complete"; \
|
||||
else \
|
||||
echo "❌ Issue #$(NUM) not found or API error"; \
|
||||
echo " Use 'make list-open-issues' to see available issues"; \
|
||||
fi
|
||||
@python3 tddai_cli.py start-issue $(NUM)
|
||||
|
||||
# Add test to current issue workspace
|
||||
add-test:
|
||||
@if [ ! -f "$(CURRENT_ISSUE_FILE)" ]; then \
|
||||
echo "❌ No active issue workspace"; \
|
||||
echo " Run 'make start-issue NUM=X' first"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! command -v claude >/dev/null 2>&1; then \
|
||||
echo "❌ Claude Code not found - required for test generation"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@CURRENT_ISSUE=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.number'); \
|
||||
ISSUE_TITLE=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.title'); \
|
||||
ISSUE_BODY=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.body'); \
|
||||
echo "🧪 Adding test to issue #$$CURRENT_ISSUE workspace"; \
|
||||
echo ""; \
|
||||
echo "📋 Issue: $$ISSUE_TITLE"; \
|
||||
echo "📁 Workspace: $(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/"; \
|
||||
echo ""; \
|
||||
echo "🤖 Please ask Claude Code to generate a test:"; \
|
||||
echo ""; \
|
||||
echo " Command: 'Generate a test for the current workspace issue'"; \
|
||||
echo ""; \
|
||||
echo "📝 Test Requirements:"; \
|
||||
echo " - Save test in: $(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/tests/"; \
|
||||
echo " - Name format: test_issue_$$CURRENT_ISSUE_<scenario>.py"; \
|
||||
echo " - Include docstring referencing issue #$$CURRENT_ISSUE"; \
|
||||
echo " - Follow TDD principles (test should fail initially)"; \
|
||||
echo " - Review requirements.md and test_plan.md for context"; \
|
||||
echo ""; \
|
||||
echo "📋 Issue Details:"; \
|
||||
echo " Title: $$ISSUE_TITLE"; \
|
||||
echo " Description: $$ISSUE_BODY"; \
|
||||
echo ""; \
|
||||
echo "💡 After generation: Use 'make workspace-status' to see all tests"
|
||||
@python3 tddai_cli.py add-test
|
||||
|
||||
# Show current workspace status
|
||||
workspace-status:
|
||||
@if [ ! -f "$(CURRENT_ISSUE_FILE)" ]; then \
|
||||
echo "📋 No active issue workspace"; \
|
||||
echo " Use 'make start-issue NUM=X' to begin working on an issue"; \
|
||||
exit 0; \
|
||||
fi
|
||||
@CURRENT_ISSUE=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.number'); \
|
||||
ISSUE_TITLE=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.title'); \
|
||||
ISSUE_STATE=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.state'); \
|
||||
echo "📋 Active Issue Workspace"; \
|
||||
echo "========================"; \
|
||||
echo ""; \
|
||||
echo "🎯 Issue #$$CURRENT_ISSUE: $$ISSUE_TITLE"; \
|
||||
echo "📊 Status: $$ISSUE_STATE"; \
|
||||
echo "📁 Workspace: $(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/"; \
|
||||
echo ""; \
|
||||
if [ -d "$(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/tests" ]; then \
|
||||
TEST_COUNT=$$(find "$(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/tests" -name "*.py" | wc -l); \
|
||||
echo "🧪 Generated Tests ($$TEST_COUNT):"; \
|
||||
if [ $$TEST_COUNT -gt 0 ]; then \
|
||||
find "$(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/tests" -name "*.py" -exec basename {} \; | sed 's/^/ - /'; \
|
||||
else \
|
||||
echo " - No tests generated yet"; \
|
||||
fi; \
|
||||
echo ""; \
|
||||
fi; \
|
||||
echo "📋 Workspace Files:"; \
|
||||
echo " - requirements.md (review and break down issue)"; \
|
||||
echo " - test_plan.md (plan test scenarios)"; \
|
||||
echo " - tests/ (generated test files)"; \
|
||||
echo ""; \
|
||||
echo "💡 Commands:"; \
|
||||
echo " - make add-test (generate another test)"; \
|
||||
echo " - make finish-issue (complete and move tests to main)"
|
||||
@python3 tddai_cli.py workspace-status
|
||||
|
||||
# Complete issue work (move tests to main and cleanup)
|
||||
finish-issue:
|
||||
@if [ ! -f "$(CURRENT_ISSUE_FILE)" ]; then \
|
||||
echo "❌ No active issue workspace"; \
|
||||
echo " Nothing to finish"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@CURRENT_ISSUE=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.number'); \
|
||||
ISSUE_TITLE=$$(cat "$(CURRENT_ISSUE_FILE)" | jq -r '.title'); \
|
||||
echo "🏁 Finishing work on issue #$$CURRENT_ISSUE"; \
|
||||
echo ""; \
|
||||
if [ -d "$(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/tests" ]; then \
|
||||
TEST_COUNT=$$(find "$(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/tests" -name "*.py" | wc -l); \
|
||||
if [ $$TEST_COUNT -gt 0 ]; then \
|
||||
echo "📦 Moving $$TEST_COUNT test(s) to tests/ directory..."; \
|
||||
cp $(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE/tests/*.py tests/ 2>/dev/null || echo " No .py files to move"; \
|
||||
echo "✅ Tests moved to main tests/ directory"; \
|
||||
else \
|
||||
echo "⚠️ No tests found in workspace"; \
|
||||
fi; \
|
||||
fi; \
|
||||
echo "🧹 Cleaning up workspace..."; \
|
||||
rm -rf "$(WORKSPACE_DIR)/issue_$$CURRENT_ISSUE"; \
|
||||
rm -f "$(CURRENT_ISSUE_FILE)"; \
|
||||
echo "✅ Issue #$$CURRENT_ISSUE workspace cleaned up"; \
|
||||
echo ""; \
|
||||
echo "💡 Next steps:"; \
|
||||
echo " - Run 'make test' to verify tests fail (red state)"; \
|
||||
echo " - Implement code to make tests pass (green state)"; \
|
||||
echo " - Start next issue with 'make start-issue NUM=X'"
|
||||
@python3 tddai_cli.py finish-issue
|
||||
|
||||
26
tddai/__init__.py
Normal file
26
tddai/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
tddai - Test-Driven Development with AI Support
|
||||
|
||||
A Python library for managing issue-driven TDD workflows with AI assistance.
|
||||
Provides workspace management, test generation, and issue integration.
|
||||
"""
|
||||
|
||||
from .workspace import WorkspaceManager, Workspace, WorkspaceStatus
|
||||
from .issue_fetcher import IssueFetcher, Issue
|
||||
from .test_generator import TestGenerator
|
||||
from .exceptions import TddaiError, WorkspaceError, IssueError, ConfigurationError, TestGenerationError
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = [
|
||||
"WorkspaceManager",
|
||||
"Workspace",
|
||||
"WorkspaceStatus",
|
||||
"IssueFetcher",
|
||||
"Issue",
|
||||
"TestGenerator",
|
||||
"TddaiError",
|
||||
"WorkspaceError",
|
||||
"IssueError",
|
||||
"ConfigurationError",
|
||||
"TestGenerationError",
|
||||
]
|
||||
92
tddai/config.py
Normal file
92
tddai/config.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Configuration management for tddai.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .exceptions import ConfigurationError
|
||||
|
||||
|
||||
@dataclass
|
||||
class TddaiConfig:
|
||||
"""Configuration settings for tddai."""
|
||||
|
||||
# Workspace settings
|
||||
workspace_dir: Path = Path(".markitect_workspace")
|
||||
current_issue_file: str = "current_issue.json"
|
||||
|
||||
# Git repository settings
|
||||
gitea_url: str = "http://92.205.130.254:32166"
|
||||
repo_owner: str = "coulomb"
|
||||
repo_name: str = "markitect_project"
|
||||
|
||||
# Test settings
|
||||
tests_dir: Path = Path("tests")
|
||||
test_file_pattern: str = "test_issue_{issue_num}_{scenario}.py"
|
||||
|
||||
# AI settings
|
||||
claude_code_command: str = "claude"
|
||||
|
||||
@property
|
||||
def issues_api_url(self) -> str:
|
||||
"""Get the full issues API URL."""
|
||||
return f"{self.gitea_url}/api/v1/repos/{self.repo_owner}/{self.repo_name}/issues"
|
||||
|
||||
@property
|
||||
def current_issue_path(self) -> Path:
|
||||
"""Get the path to current issue file."""
|
||||
return self.workspace_dir / self.current_issue_file
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "TddaiConfig":
|
||||
"""Create config from environment variables."""
|
||||
config = cls()
|
||||
|
||||
# Override with environment variables if present
|
||||
if gitea_url := os.getenv("TDDAI_GITEA_URL"):
|
||||
config.gitea_url = gitea_url
|
||||
|
||||
if repo_owner := os.getenv("TDDAI_REPO_OWNER"):
|
||||
config.repo_owner = repo_owner
|
||||
|
||||
if repo_name := os.getenv("TDDAI_REPO_NAME"):
|
||||
config.repo_name = repo_name
|
||||
|
||||
if workspace_dir := os.getenv("TDDAI_WORKSPACE_DIR"):
|
||||
config.workspace_dir = Path(workspace_dir)
|
||||
|
||||
return config
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration settings."""
|
||||
if not self.gitea_url:
|
||||
raise ConfigurationError("gitea_url cannot be empty")
|
||||
|
||||
if not self.repo_owner:
|
||||
raise ConfigurationError("repo_owner cannot be empty")
|
||||
|
||||
if not self.repo_name:
|
||||
raise ConfigurationError("repo_name cannot be empty")
|
||||
|
||||
|
||||
# Global config instance
|
||||
_config: Optional[TddaiConfig] = None
|
||||
|
||||
|
||||
def get_config() -> TddaiConfig:
|
||||
"""Get the global configuration instance."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = TddaiConfig.from_environment()
|
||||
_config.validate()
|
||||
return _config
|
||||
|
||||
|
||||
def set_config(config: TddaiConfig) -> None:
|
||||
"""Set the global configuration instance."""
|
||||
global _config
|
||||
config.validate()
|
||||
_config = config
|
||||
28
tddai/exceptions.py
Normal file
28
tddai/exceptions.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Custom exceptions for tddai library.
|
||||
"""
|
||||
|
||||
|
||||
class TddaiError(Exception):
|
||||
"""Base exception for all tddai errors."""
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceError(TddaiError):
|
||||
"""Raised when workspace operations fail."""
|
||||
pass
|
||||
|
||||
|
||||
class IssueError(TddaiError):
|
||||
"""Raised when issue operations fail."""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(TddaiError):
|
||||
"""Raised when configuration is invalid."""
|
||||
pass
|
||||
|
||||
|
||||
class TestGenerationError(TddaiError):
|
||||
"""Raised when test generation fails."""
|
||||
pass
|
||||
136
tddai/issue_fetcher.py
Normal file
136
tddai/issue_fetcher.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Issue fetching from Gitea API.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from .config import get_config
|
||||
from .exceptions import IssueError
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
"""Represents a Gitea issue."""
|
||||
|
||||
number: int
|
||||
title: str
|
||||
body: str
|
||||
state: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
html_url: str
|
||||
assignee: Optional[str] = None
|
||||
labels: List[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.labels is None:
|
||||
self.labels = []
|
||||
|
||||
|
||||
class IssueFetcher:
|
||||
"""Fetches issues from Gitea API."""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config or get_config()
|
||||
|
||||
def fetch_issue(self, issue_number: int) -> Issue:
|
||||
"""Fetch a specific issue by number."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['curl', '-s', f"{self.config.issues_api_url}/{issue_number}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise IssueError(f"Failed to fetch issue #{issue_number}: {result.stderr}")
|
||||
|
||||
issue_data = json.loads(result.stdout)
|
||||
|
||||
if 'message' in issue_data:
|
||||
raise IssueError(f"Issue #{issue_number} not found: {issue_data['message']}")
|
||||
|
||||
return self._parse_issue(issue_data)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise IssueError(f"Failed to fetch issue #{issue_number}: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise IssueError(f"Failed to parse issue data: {e}")
|
||||
|
||||
def fetch_issues(self, state: str = "all") -> List[Issue]:
|
||||
"""Fetch all issues with optional state filter."""
|
||||
try:
|
||||
url = self.config.issues_api_url
|
||||
if state != "all":
|
||||
url += f"?state={state}"
|
||||
|
||||
result = subprocess.run(
|
||||
['curl', '-s', url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise IssueError(f"Failed to fetch issues: {result.stderr}")
|
||||
|
||||
issues_data = json.loads(result.stdout)
|
||||
|
||||
if isinstance(issues_data, dict) and 'message' in issues_data:
|
||||
raise IssueError(f"Failed to fetch issues: {issues_data['message']}")
|
||||
|
||||
if not isinstance(issues_data, list):
|
||||
raise IssueError("Invalid response format: expected list of issues")
|
||||
|
||||
return [self._parse_issue(issue_data) for issue_data in issues_data]
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise IssueError(f"Failed to fetch issues: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise IssueError(f"Failed to parse issues data: {e}")
|
||||
|
||||
def fetch_open_issues(self) -> List[Issue]:
|
||||
"""Fetch only open issues."""
|
||||
return self.fetch_issues(state="open")
|
||||
|
||||
def _parse_issue(self, issue_data: Dict[str, Any]) -> Issue:
|
||||
"""Parse issue data from API response."""
|
||||
try:
|
||||
labels = [label['name'] for label in issue_data.get('labels', [])]
|
||||
assignee = None
|
||||
if issue_data.get('assignee'):
|
||||
assignee = issue_data['assignee'].get('login')
|
||||
|
||||
return Issue(
|
||||
number=issue_data['number'],
|
||||
title=issue_data['title'],
|
||||
body=issue_data.get('body', ''),
|
||||
state=issue_data['state'],
|
||||
created_at=datetime.fromisoformat(issue_data['created_at'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(issue_data['updated_at'].replace('Z', '+00:00')),
|
||||
html_url=issue_data['html_url'],
|
||||
assignee=assignee,
|
||||
labels=labels
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
raise IssueError(f"Failed to parse issue data: {e}")
|
||||
|
||||
def get_issue_data_dict(self, issue_number: int) -> Dict[str, Any]:
|
||||
"""Get issue data as dictionary for workspace creation."""
|
||||
issue = self.fetch_issue(issue_number)
|
||||
return {
|
||||
'number': issue.number,
|
||||
'title': issue.title,
|
||||
'body': issue.body,
|
||||
'state': issue.state,
|
||||
'created_at': issue.created_at.isoformat(),
|
||||
'updated_at': issue.updated_at.isoformat(),
|
||||
'html_url': issue.html_url,
|
||||
'assignee': {'login': issue.assignee} if issue.assignee else None,
|
||||
'labels': [{'name': label} for label in issue.labels]
|
||||
}
|
||||
163
tddai/test_generator.py
Normal file
163
tddai/test_generator.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Test generation with AI assistance.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .config import get_config
|
||||
from .workspace import WorkspaceManager
|
||||
from .exceptions import TestGenerationError
|
||||
|
||||
|
||||
class TestGenerator:
|
||||
"""Generates tests using AI assistance."""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config or get_config()
|
||||
self.workspace_manager = WorkspaceManager(config)
|
||||
|
||||
def generate_test(self, scenario_name: str, test_description: str) -> Path:
|
||||
"""Generate a test file for the current workspace issue."""
|
||||
workspace = self.workspace_manager.get_current_workspace()
|
||||
if not workspace:
|
||||
raise TestGenerationError("No active workspace found")
|
||||
|
||||
# Create test file name
|
||||
test_filename = self.config.test_file_pattern.format(
|
||||
issue_num=workspace.issue_number,
|
||||
scenario=scenario_name.lower().replace(' ', '_').replace('-', '_')
|
||||
)
|
||||
test_file_path = workspace.tests_dir / test_filename
|
||||
|
||||
# Generate test prompt
|
||||
prompt = self._create_test_prompt(workspace, scenario_name, test_description)
|
||||
|
||||
# Use Claude Code to generate the test
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
|
||||
f.write(prompt)
|
||||
prompt_file = Path(f.name)
|
||||
|
||||
result = subprocess.run(
|
||||
[self.config.claude_code_command, '--file', str(prompt_file)],
|
||||
cwd=workspace.workspace_dir,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
prompt_file.unlink() # Clean up temp file
|
||||
|
||||
if result.returncode != 0:
|
||||
raise TestGenerationError(f"Claude Code failed: {result.stderr}")
|
||||
|
||||
# Extract Python code from Claude's response
|
||||
test_content = self._extract_test_code(result.stdout)
|
||||
|
||||
# Write test file
|
||||
test_file_path.write_text(test_content)
|
||||
|
||||
# Update test plan
|
||||
self._update_test_plan(workspace, scenario_name, test_filename)
|
||||
|
||||
return test_file_path
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise TestGenerationError(f"Failed to generate test: {e}")
|
||||
except Exception as e:
|
||||
raise TestGenerationError(f"Test generation error: {e}")
|
||||
|
||||
def _create_test_prompt(self, workspace, scenario_name: str, test_description: str) -> str:
|
||||
"""Create prompt for Claude Code to generate test."""
|
||||
return f"""# Test Generation Request
|
||||
|
||||
## Context
|
||||
- Issue #{workspace.issue_number}: {workspace.issue_title}
|
||||
- Scenario: {scenario_name}
|
||||
|
||||
## Issue Description
|
||||
{workspace.issue_body}
|
||||
|
||||
## Test Requirements
|
||||
{test_description}
|
||||
|
||||
## Instructions
|
||||
Please generate a comprehensive Python test file that:
|
||||
|
||||
1. Tests the behavior described in the scenario
|
||||
2. Follows pytest conventions
|
||||
3. Includes proper docstrings and comments
|
||||
4. Tests both positive and negative cases
|
||||
5. Uses meaningful test method names
|
||||
6. Includes appropriate assertions
|
||||
|
||||
The test should focus on behavior verification rather than implementation details.
|
||||
|
||||
## Expected Output
|
||||
Please provide only the Python test code without any additional explanation.
|
||||
The code should be ready to save as `{self.config.test_file_pattern.format(issue_num=workspace.issue_number, scenario=scenario_name.lower().replace(' ', '_'))}`
|
||||
"""
|
||||
|
||||
def _extract_test_code(self, claude_response: str) -> str:
|
||||
"""Extract Python test code from Claude's response."""
|
||||
lines = claude_response.split('\n')
|
||||
code_lines = []
|
||||
in_code_block = False
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith('```python'):
|
||||
in_code_block = True
|
||||
continue
|
||||
elif line.strip() == '```' and in_code_block:
|
||||
break
|
||||
elif in_code_block:
|
||||
code_lines.append(line)
|
||||
|
||||
if not code_lines:
|
||||
# If no code block found, assume entire response is code
|
||||
return claude_response.strip()
|
||||
|
||||
return '\n'.join(code_lines)
|
||||
|
||||
def _update_test_plan(self, workspace, scenario_name: str, test_filename: str) -> None:
|
||||
"""Update the test plan with the new test."""
|
||||
test_plan_content = workspace.test_plan_file.read_text()
|
||||
|
||||
# Add test to the generated tests section
|
||||
new_entry = f"- [x] {scenario_name} (`{test_filename}`)"
|
||||
|
||||
if "### Generated Tests" in test_plan_content:
|
||||
# Add to existing generated tests section
|
||||
lines = test_plan_content.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "Tests generated for this workspace will be listed here as they are created.":
|
||||
lines[i] = new_entry
|
||||
break
|
||||
elif line.startswith("- [") and "Generated Tests" in lines[max(0, i-5):i]:
|
||||
lines.insert(i, new_entry)
|
||||
break
|
||||
else:
|
||||
# Add at the end of generated tests section
|
||||
for i, line in enumerate(lines):
|
||||
if "### Generated Tests" in line:
|
||||
# Find next section or end
|
||||
j = i + 1
|
||||
while j < len(lines) and not lines[j].startswith('##'):
|
||||
j += 1
|
||||
lines.insert(j, new_entry)
|
||||
break
|
||||
|
||||
workspace.test_plan_file.write_text('\n'.join(lines))
|
||||
|
||||
def list_generated_tests(self) -> list:
|
||||
"""List all generated tests for the current workspace."""
|
||||
workspace = self.workspace_manager.get_current_workspace()
|
||||
if not workspace:
|
||||
return []
|
||||
|
||||
if not workspace.tests_dir.exists():
|
||||
return []
|
||||
|
||||
return list(workspace.tests_dir.glob("*.py"))
|
||||
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)
|
||||
331
tddai_cli.py
Normal file
331
tddai_cli.py
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI interface for tddai library.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Add current directory to path so we can import tddai
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from tddai import (
|
||||
WorkspaceManager, IssueFetcher, TestGenerator,
|
||||
WorkspaceStatus, TddaiError
|
||||
)
|
||||
|
||||
|
||||
def workspace_status():
|
||||
"""Show current workspace status."""
|
||||
try:
|
||||
manager = WorkspaceManager()
|
||||
status = manager.get_status()
|
||||
|
||||
if status == WorkspaceStatus.CLEAN:
|
||||
print("📋 No active issue workspace")
|
||||
print(" Use 'make start-issue NUM=X' to begin working on an issue")
|
||||
return
|
||||
|
||||
if status == WorkspaceStatus.DIRTY:
|
||||
print("⚠️ Workspace directory exists but no current issue file")
|
||||
print(" Run 'make finish-issue' to clean up or 'make start-issue' to create new workspace")
|
||||
return
|
||||
|
||||
workspace = manager.get_current_workspace()
|
||||
if not workspace:
|
||||
print("❌ Failed to load workspace")
|
||||
return
|
||||
|
||||
print("📋 Active Issue Workspace")
|
||||
print("========================")
|
||||
print()
|
||||
print(f"🎯 Issue #{workspace.issue_number}: {workspace.issue_title}")
|
||||
print(f"📊 Status: {workspace.issue_state}")
|
||||
print(f"📁 Workspace: {workspace.workspace_dir}/issue_{workspace.issue_number}/")
|
||||
print()
|
||||
|
||||
if workspace.tests_dir.exists():
|
||||
test_files = list(workspace.tests_dir.glob("*.py"))
|
||||
print(f"🧪 Generated Tests ({len(test_files)}):")
|
||||
if test_files:
|
||||
for test_file in test_files:
|
||||
print(f" - {test_file.name}")
|
||||
else:
|
||||
print(" - No tests generated yet")
|
||||
print()
|
||||
|
||||
print("📋 Workspace Files:")
|
||||
print(" - requirements.md (review and break down issue)")
|
||||
print(" - test_plan.md (plan test scenarios)")
|
||||
print(" - tests/ (generated test files)")
|
||||
print()
|
||||
print("💡 Commands:")
|
||||
print(" - make add-test (generate another test)")
|
||||
print(" - make finish-issue (complete and move tests to main)")
|
||||
|
||||
except TddaiError as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def start_issue(issue_number: int):
|
||||
"""Start working on an issue."""
|
||||
try:
|
||||
manager = WorkspaceManager()
|
||||
fetcher = IssueFetcher()
|
||||
|
||||
# Check if workspace already active
|
||||
status = manager.get_status()
|
||||
if status == WorkspaceStatus.ACTIVE:
|
||||
current = manager.get_current_workspace()
|
||||
print(f"⚠️ Already working on issue #{current.issue_number}")
|
||||
print(" Run 'make finish-issue' first or 'make workspace-status' to see details")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"🔍 Starting work on issue #{issue_number}...")
|
||||
print(f"📋 Fetching issue #{issue_number} details...")
|
||||
|
||||
# Fetch issue data
|
||||
issue_data = fetcher.get_issue_data_dict(issue_number)
|
||||
|
||||
# Create workspace
|
||||
workspace = manager.create_workspace(issue_data)
|
||||
|
||||
print(f"✅ Workspace created for issue #{issue_number}")
|
||||
print(f"📁 Workspace: {workspace.workspace_dir}/issue_{issue_number}/")
|
||||
print(f"📋 Requirements: {workspace.requirements_file}")
|
||||
print(f"🧪 Test plan: {workspace.test_plan_file}")
|
||||
print()
|
||||
print("💡 Next steps:")
|
||||
print(" 1. Review requirements.md and break down the issue")
|
||||
print(" 2. Plan test scenarios in test_plan.md")
|
||||
print(" 3. Use 'make add-test' to generate tests")
|
||||
print(" 4. Use 'make finish-issue' when complete")
|
||||
|
||||
except TddaiError as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def finish_issue():
|
||||
"""Finish current issue workspace."""
|
||||
try:
|
||||
manager = WorkspaceManager()
|
||||
|
||||
workspace = manager.get_current_workspace()
|
||||
if not workspace:
|
||||
print("❌ No active issue workspace")
|
||||
print(" Nothing to finish")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"🏁 Finishing work on issue #{workspace.issue_number}")
|
||||
print()
|
||||
|
||||
# Check for tests
|
||||
if workspace.tests_dir.exists():
|
||||
test_files = list(workspace.tests_dir.glob("*.py"))
|
||||
if test_files:
|
||||
print(f"📦 Moving {len(test_files)} test(s) to tests/ directory...")
|
||||
print("✅ Tests moved to main tests/ directory")
|
||||
else:
|
||||
print("⚠️ No tests found in workspace")
|
||||
|
||||
# Finish workspace (moves tests and cleans up)
|
||||
manager.finish_workspace()
|
||||
|
||||
print("🧹 Cleaning up workspace...")
|
||||
print(f"✅ Issue #{workspace.issue_number} workspace cleaned up")
|
||||
print()
|
||||
print("💡 Next steps:")
|
||||
print(" - Run 'make test' to verify tests fail (red state)")
|
||||
print(" - Implement code to make tests pass (green state)")
|
||||
print(" - Start next issue with 'make start-issue NUM=X'")
|
||||
|
||||
except TddaiError as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def add_test_guidance():
|
||||
"""Show guidance for adding tests."""
|
||||
try:
|
||||
manager = WorkspaceManager()
|
||||
|
||||
workspace = manager.get_current_workspace()
|
||||
if not workspace:
|
||||
print("❌ No active issue workspace")
|
||||
print(" Run 'make start-issue NUM=X' first")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"🧪 Adding test to issue #{workspace.issue_number} workspace")
|
||||
print()
|
||||
print(f"📋 Issue: {workspace.issue_title}")
|
||||
print(f"📁 Workspace: {workspace.workspace_dir}/issue_{workspace.issue_number}/")
|
||||
print()
|
||||
print("🤖 Please ask Claude Code to generate a test:")
|
||||
print()
|
||||
print(" Command: 'Generate a test for the current workspace issue'")
|
||||
print()
|
||||
print("📝 Test Requirements:")
|
||||
print(f" - Save test in: {workspace.tests_dir}/")
|
||||
print(f" - Name format: test_issue_{workspace.issue_number}_<scenario>.py")
|
||||
print(f" - Include docstring referencing issue #{workspace.issue_number}")
|
||||
print(" - Follow TDD principles (test should fail initially)")
|
||||
print(" - Review requirements.md and test_plan.md for context")
|
||||
print()
|
||||
print("📋 Issue Details:")
|
||||
print(f" Title: {workspace.issue_title}")
|
||||
print(f" Description: {workspace.issue_body}")
|
||||
print()
|
||||
print("💡 After generation: Use 'make workspace-status' to see all tests")
|
||||
|
||||
except TddaiError as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def list_issues():
|
||||
"""List all issues."""
|
||||
try:
|
||||
fetcher = IssueFetcher()
|
||||
print("📋 MarkiTect Issues")
|
||||
print("==================")
|
||||
print()
|
||||
|
||||
issues = fetcher.fetch_issues()
|
||||
if not issues:
|
||||
print("No issues found")
|
||||
return
|
||||
|
||||
for issue in issues:
|
||||
status_icon = "🟢" if issue.state == "open" else "🔴"
|
||||
print(f"{status_icon} #{issue.number}: {issue.title}")
|
||||
print(f" Status: {issue.state.upper()} | Created: {issue.created_at.strftime('%Y-%m-%d')}")
|
||||
|
||||
# Truncate body for list view
|
||||
body_preview = issue.body[:80] + "..." if len(issue.body) > 80 else issue.body
|
||||
if body_preview:
|
||||
print(f" {body_preview}")
|
||||
print()
|
||||
|
||||
print("💡 Tip: Use 'make show-issue NUM=X' for full details")
|
||||
|
||||
except TddaiError as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def list_open_issues():
|
||||
"""List only open issues."""
|
||||
try:
|
||||
fetcher = IssueFetcher()
|
||||
print("📋 Open MarkiTect Issues (Active Backlog)")
|
||||
print("========================================")
|
||||
print()
|
||||
|
||||
issues = fetcher.fetch_open_issues()
|
||||
if not issues:
|
||||
print("No open issues found")
|
||||
return
|
||||
|
||||
for issue in issues:
|
||||
print(f"[OPEN] #{issue.number}: {issue.title}")
|
||||
print(f" Created: {issue.created_at.strftime('%Y-%m-%d')} | Updated: {issue.updated_at.strftime('%Y-%m-%d')}")
|
||||
|
||||
# Truncate body for list view
|
||||
body_preview = issue.body[:80] + "..." if len(issue.body) > 80 else issue.body
|
||||
if body_preview:
|
||||
print(f" {body_preview}")
|
||||
print()
|
||||
|
||||
print("💡 Tip: Use 'make show-issue NUM=X' for full details or 'make list-issues' for all issues")
|
||||
|
||||
except TddaiError as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def show_issue(issue_number: int):
|
||||
"""Show detailed issue information."""
|
||||
try:
|
||||
fetcher = IssueFetcher()
|
||||
print(f"🔍 Issue #{issue_number} Details")
|
||||
print("=======================")
|
||||
print()
|
||||
|
||||
issue = fetcher.fetch_issue(issue_number)
|
||||
|
||||
print(f"**Title:** {issue.title}")
|
||||
print(f"**Status:** {issue.state.upper()}")
|
||||
print(f"**Number:** #{issue.number}")
|
||||
print(f"**Created:** {issue.created_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
print(f"**Updated:** {issue.updated_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
print(f"**URL:** {issue.html_url}")
|
||||
|
||||
if issue.assignee:
|
||||
print(f"**Assignee:** {issue.assignee}")
|
||||
|
||||
if issue.labels:
|
||||
print(f"**Labels:** {', '.join(issue.labels)}")
|
||||
|
||||
print()
|
||||
print("**Description:**")
|
||||
print(issue.body)
|
||||
print()
|
||||
print("💡 Tip: Use 'make list-issues' to see all issues")
|
||||
|
||||
except TddaiError as e:
|
||||
print(f"❌ Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(description="tddai CLI tool")
|
||||
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||
|
||||
# Workspace commands
|
||||
subparsers.add_parser('workspace-status', help='Show workspace status')
|
||||
|
||||
start_parser = subparsers.add_parser('start-issue', help='Start working on issue')
|
||||
start_parser.add_argument('issue_number', type=int, help='Issue number')
|
||||
|
||||
subparsers.add_parser('finish-issue', help='Finish current issue')
|
||||
subparsers.add_parser('add-test', help='Show guidance for adding tests')
|
||||
|
||||
# Issue commands
|
||||
subparsers.add_parser('list-issues', help='List all issues')
|
||||
subparsers.add_parser('list-open-issues', help='List open issues')
|
||||
|
||||
show_parser = subparsers.add_parser('show-issue', help='Show issue details')
|
||||
show_parser.add_argument('issue_number', type=int, help='Issue number')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
try:
|
||||
if args.command == 'workspace-status':
|
||||
workspace_status()
|
||||
elif args.command == 'start-issue':
|
||||
start_issue(args.issue_number)
|
||||
elif args.command == 'finish-issue':
|
||||
finish_issue()
|
||||
elif args.command == 'add-test':
|
||||
add_test_guidance()
|
||||
elif args.command == 'list-issues':
|
||||
list_issues()
|
||||
elif args.command == 'list-open-issues':
|
||||
list_open_issues()
|
||||
elif args.command == 'show-issue':
|
||||
show_issue(args.issue_number)
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ Operation cancelled")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1
tests/test_example.py
Normal file
1
tests/test_example.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test content
|
||||
1
tests/test_issue_11_complete.py
Normal file
1
tests/test_issue_11_complete.py
Normal file
@@ -0,0 +1 @@
|
||||
# Complete test content
|
||||
6
tests/test_issue_11_feature.py
Normal file
6
tests/test_issue_11_feature.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Test for issue #11."""
|
||||
import pytest
|
||||
|
||||
def test_feature():
|
||||
"""Test the feature implementation."""
|
||||
assert True # Replace with actual test
|
||||
194
tests/test_issue_11_workflow_integration.py
Normal file
194
tests/test_issue_11_workflow_integration.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Test TDD workflow integration for workspace infrastructure.
|
||||
|
||||
This test validates issue #11: Setup TDD workspace infrastructure
|
||||
- Tests complete workflow from start to finish
|
||||
- Validates integration between workspace and main codebase
|
||||
- Tests cleanup and finalization processes
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tddai import WorkspaceManager, IssueFetcher
|
||||
from tddai.config import TddaiConfig
|
||||
|
||||
|
||||
class TestWorkflowIntegration:
|
||||
"""Test suite for complete TDD workflow integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace(self):
|
||||
"""Create a temporary workspace for testing."""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
config = TddaiConfig(workspace_dir=temp_dir / ".markitect_workspace")
|
||||
yield config
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
def test_make_workspace_status_command(self):
|
||||
"""Test that make workspace-status command works correctly."""
|
||||
result = subprocess.run(['make', 'workspace-status'],
|
||||
capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, "workspace-status command should succeed"
|
||||
# Should show clean workspace when no active workspace
|
||||
assert ("No active issue workspace" in result.stdout or
|
||||
"Workspace directory exists but no current issue file" in result.stdout)
|
||||
|
||||
def test_make_add_test_command_without_workspace(self):
|
||||
"""Test that make add-test provides proper error when no workspace."""
|
||||
result = subprocess.run(['make', 'add-test'],
|
||||
capture_output=True, text=True)
|
||||
|
||||
assert result.returncode != 0, "add-test command should fail when no workspace"
|
||||
assert "No active issue workspace" in result.stdout
|
||||
|
||||
def test_cli_integration_basic(self):
|
||||
"""Test that CLI script can be imported and basic functions exist."""
|
||||
import tddai_cli
|
||||
|
||||
# Test that main functions exist
|
||||
assert hasattr(tddai_cli, 'workspace_status')
|
||||
assert hasattr(tddai_cli, 'start_issue')
|
||||
assert hasattr(tddai_cli, 'finish_issue')
|
||||
assert hasattr(tddai_cli, 'main')
|
||||
|
||||
def test_workspace_to_main_integration(self, temp_workspace):
|
||||
"""Test moving tests from workspace to main tests directory."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
|
||||
mock_issue_data = {
|
||||
'number': 11,
|
||||
'title': 'Test Issue',
|
||||
'body': 'Test Description',
|
||||
'state': 'open',
|
||||
'created_at': '2025-01-01T00:00:00Z',
|
||||
'html_url': 'http://example.com/issues/11',
|
||||
'assignee': None,
|
||||
'labels': []
|
||||
}
|
||||
|
||||
# Create workspace
|
||||
workspace = manager.create_workspace(mock_issue_data)
|
||||
|
||||
# Add a test file to workspace
|
||||
test_file = workspace.tests_dir / "test_issue_11_feature.py"
|
||||
test_content = '''"""Test for issue #11."""
|
||||
import pytest
|
||||
|
||||
def test_feature():
|
||||
"""Test the feature implementation."""
|
||||
assert True # Replace with actual test
|
||||
'''
|
||||
test_file.write_text(test_content)
|
||||
|
||||
# Finish workspace (should move tests)
|
||||
finished_workspace = manager.finish_workspace()
|
||||
|
||||
# Verify test was moved to main tests directory
|
||||
main_test_file = temp_workspace.tests_dir / "test_issue_11_feature.py"
|
||||
assert main_test_file.exists()
|
||||
assert main_test_file.read_text() == test_content
|
||||
|
||||
# Verify workspace is cleaned up
|
||||
assert not temp_workspace.workspace_dir.exists()
|
||||
|
||||
def test_workspace_cleanup_process(self, temp_workspace):
|
||||
"""Test that workspace cleanup removes temporary files."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
|
||||
mock_issue_data = {
|
||||
'number': 11,
|
||||
'title': 'Test Issue',
|
||||
'body': 'Test Description',
|
||||
'state': 'open'
|
||||
}
|
||||
|
||||
# Create workspace
|
||||
workspace = manager.create_workspace(mock_issue_data)
|
||||
|
||||
# Verify workspace exists
|
||||
assert workspace.workspace_dir.exists()
|
||||
assert workspace.issue_dir.exists()
|
||||
|
||||
# Clean up
|
||||
manager.cleanup_workspace()
|
||||
|
||||
# Verify cleanup
|
||||
assert not workspace.workspace_dir.exists()
|
||||
|
||||
def test_gitignore_excludes_workspace(self):
|
||||
"""Test that workspace files are properly excluded from git."""
|
||||
gitignore_path = Path(".gitignore")
|
||||
assert gitignore_path.exists(), "Gitignore file should exist"
|
||||
|
||||
with open(gitignore_path, 'r') as f:
|
||||
gitignore_content = f.read()
|
||||
|
||||
assert ".markitect_workspace/" in gitignore_content, \
|
||||
"Workspace should be excluded from git"
|
||||
|
||||
@patch('tddai.issue_fetcher.subprocess.run')
|
||||
def test_issue_fetcher_integration(self, mock_run, temp_workspace):
|
||||
"""Test that IssueFetcher properly integrates with API."""
|
||||
# Mock successful curl response
|
||||
mock_run.return_value.returncode = 0
|
||||
mock_run.return_value.stdout = """{
|
||||
"number": 11,
|
||||
"title": "Setup TDD workspace infrastructure",
|
||||
"body": "Create workspace management system",
|
||||
"state": "open",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
"html_url": "http://example.com/issues/11",
|
||||
"assignee": null,
|
||||
"labels": []
|
||||
}"""
|
||||
|
||||
fetcher = IssueFetcher(temp_workspace)
|
||||
issue = fetcher.fetch_issue(11)
|
||||
|
||||
assert issue.number == 11
|
||||
assert issue.title == "Setup TDD workspace infrastructure"
|
||||
assert issue.state == "open"
|
||||
|
||||
def test_complete_workflow_cycle(self, temp_workspace):
|
||||
"""Test complete workflow from start to finish."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
|
||||
mock_issue_data = {
|
||||
'number': 11,
|
||||
'title': 'Setup TDD workspace infrastructure',
|
||||
'body': 'Create workspace management system for TDD workflow',
|
||||
'state': 'open',
|
||||
'created_at': '2025-01-01T00:00:00Z',
|
||||
'html_url': 'http://example.com/issues/11',
|
||||
'assignee': None,
|
||||
'labels': []
|
||||
}
|
||||
|
||||
# 1. Start with clean workspace
|
||||
assert manager.get_status().name == "CLEAN"
|
||||
|
||||
# 2. Create workspace
|
||||
workspace = manager.create_workspace(mock_issue_data)
|
||||
assert manager.get_status().name == "ACTIVE"
|
||||
assert workspace.issue_number == 11
|
||||
|
||||
# 3. Add test files
|
||||
test_file = workspace.tests_dir / "test_issue_11_complete.py"
|
||||
test_file.write_text("# Complete test content")
|
||||
|
||||
# 4. Finish workspace
|
||||
finished = manager.finish_workspace()
|
||||
assert finished.issue_number == 11
|
||||
assert manager.get_status().name == "CLEAN"
|
||||
|
||||
# 5. Verify test moved to main
|
||||
main_test = temp_workspace.tests_dir / "test_issue_11_complete.py"
|
||||
assert main_test.exists()
|
||||
assert main_test.read_text() == "# Complete test content"
|
||||
156
tests/test_issue_11_workspace_creation.py
Normal file
156
tests/test_issue_11_workspace_creation.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Test workspace creation functionality for TDD infrastructure.
|
||||
|
||||
This test validates issue #11: Setup TDD workspace infrastructure
|
||||
- Tests workspace creation from issue numbers
|
||||
- Validates workspace structure and files
|
||||
- Ensures proper error handling
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from tddai import WorkspaceManager, IssueFetcher, WorkspaceStatus, WorkspaceError, IssueError
|
||||
from tddai.config import TddaiConfig
|
||||
|
||||
|
||||
class TestWorkspaceCreation:
|
||||
"""Test suite for workspace creation functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_workspace(self):
|
||||
"""Create a temporary workspace for testing."""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
config = TddaiConfig(workspace_dir=temp_dir / ".markitect_workspace")
|
||||
yield config
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_issue_data(self):
|
||||
"""Mock issue data for testing."""
|
||||
return {
|
||||
'number': 11,
|
||||
'title': 'Setup TDD workspace infrastructure',
|
||||
'body': 'Create workspace management system for TDD workflow',
|
||||
'state': 'open',
|
||||
'created_at': '2025-01-01T00:00:00Z',
|
||||
'html_url': 'http://example.com/issues/11',
|
||||
'assignee': None,
|
||||
'labels': []
|
||||
}
|
||||
|
||||
def test_workspace_manager_initialization(self, temp_workspace):
|
||||
"""Test that WorkspaceManager can be initialized."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
assert manager.config == temp_workspace
|
||||
|
||||
def test_workspace_status_clean_initially(self, temp_workspace):
|
||||
"""Test that workspace status is clean when no workspace exists."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
status = manager.get_status()
|
||||
assert status == WorkspaceStatus.CLEAN
|
||||
|
||||
def test_workspace_creation_from_issue_data(self, temp_workspace, mock_issue_data):
|
||||
"""Test that workspace can be created from issue data."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
|
||||
workspace = manager.create_workspace(mock_issue_data)
|
||||
|
||||
assert workspace.issue_number == 11
|
||||
assert workspace.issue_title == 'Setup TDD workspace infrastructure'
|
||||
assert workspace.workspace_dir == temp_workspace.workspace_dir
|
||||
|
||||
# Verify workspace status changes to active
|
||||
status = manager.get_status()
|
||||
assert status == WorkspaceStatus.ACTIVE
|
||||
|
||||
def test_workspace_directory_structure_created(self, temp_workspace, mock_issue_data):
|
||||
"""Test that workspace creates proper directory structure."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
workspace = manager.create_workspace(mock_issue_data)
|
||||
|
||||
assert workspace.workspace_dir.exists()
|
||||
assert workspace.issue_dir.exists()
|
||||
assert workspace.tests_dir.exists()
|
||||
|
||||
def test_workspace_metadata_files_created(self, temp_workspace, mock_issue_data):
|
||||
"""Test that workspace creates required metadata files."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
workspace = manager.create_workspace(mock_issue_data)
|
||||
|
||||
assert workspace.requirements_file.exists()
|
||||
assert workspace.test_plan_file.exists()
|
||||
assert temp_workspace.current_issue_path.exists()
|
||||
|
||||
def test_current_issue_metadata_content(self, temp_workspace, mock_issue_data):
|
||||
"""Test that current issue metadata is properly stored."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
manager.create_workspace(mock_issue_data)
|
||||
|
||||
current_workspace = manager.get_current_workspace()
|
||||
assert current_workspace.issue_number == 11
|
||||
assert current_workspace.issue_title == 'Setup TDD workspace infrastructure'
|
||||
assert current_workspace.issue_state == 'open'
|
||||
|
||||
def test_workspace_prevents_multiple_active_issues(self, temp_workspace, mock_issue_data):
|
||||
"""Test that only one workspace can be active at a time."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
manager.create_workspace(mock_issue_data)
|
||||
|
||||
# Try to create another workspace
|
||||
second_issue_data = mock_issue_data.copy()
|
||||
second_issue_data['number'] = 12
|
||||
second_issue_data['title'] = 'Different issue'
|
||||
|
||||
with pytest.raises(WorkspaceError, match="Workspace already active"):
|
||||
manager.create_workspace(second_issue_data)
|
||||
|
||||
@patch('tddai.issue_fetcher.subprocess.run')
|
||||
def test_issue_fetcher_handles_invalid_issue(self, mock_run, temp_workspace):
|
||||
"""Test error handling for invalid issue numbers."""
|
||||
# Mock curl response for non-existent issue
|
||||
mock_run.return_value.returncode = 0
|
||||
mock_run.return_value.stdout = '{"message": "404 Not Found"}'
|
||||
|
||||
fetcher = IssueFetcher(temp_workspace)
|
||||
|
||||
with pytest.raises(IssueError, match="not found"):
|
||||
fetcher.fetch_issue(999)
|
||||
|
||||
def test_workspace_cleanup(self, temp_workspace, mock_issue_data):
|
||||
"""Test that workspace can be cleaned up properly."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
manager.create_workspace(mock_issue_data)
|
||||
|
||||
# Verify workspace exists
|
||||
assert manager.get_status() == WorkspaceStatus.ACTIVE
|
||||
|
||||
# Clean up
|
||||
manager.cleanup_workspace()
|
||||
|
||||
# Verify workspace is clean
|
||||
assert manager.get_status() == WorkspaceStatus.CLEAN
|
||||
assert not temp_workspace.workspace_dir.exists()
|
||||
|
||||
def test_workspace_finish_moves_tests(self, temp_workspace, mock_issue_data):
|
||||
"""Test that finishing workspace moves tests to main directory."""
|
||||
manager = WorkspaceManager(temp_workspace)
|
||||
workspace = manager.create_workspace(mock_issue_data)
|
||||
|
||||
# Create a test file in workspace
|
||||
test_file = workspace.tests_dir / "test_example.py"
|
||||
test_file.write_text("# Test content")
|
||||
|
||||
# Finish workspace
|
||||
finished_workspace = manager.finish_workspace()
|
||||
|
||||
assert finished_workspace.issue_number == 11
|
||||
assert manager.get_status() == WorkspaceStatus.CLEAN
|
||||
|
||||
# Verify test was moved
|
||||
main_test_file = temp_workspace.tests_dir / "test_example.py"
|
||||
assert main_test_file.exists()
|
||||
assert main_test_file.read_text() == "# Test content"
|
||||
Reference in New Issue
Block a user