refactor: remove obsolete issue management system in favor of issue-facade
Some checks failed
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled

Complete cleanup of the legacy TDD AI and issue management system, establishing clear separation of concerns as requested. All issue handling is now provided by the standalone issue-facade system.

Removed components:
- TDD AI framework (tddai/ directory and tddai_cli.py)
- Legacy issue management CLI commands and services
- Issue-related Makefile targets and helper commands
- Obsolete tests and infrastructure dependencies
- Finance modules that depended on the old issue system

Updated:
- Makefile: Removed issue-*, tdd-*, and test-from-issue commands
- CLI framework: Simplified to core functionality only
- Documentation: Added deprecation notice for old config system

The issue-facade now serves as the universal CLI for issue tracking,
providing backend-agnostic interface to GitHub, GitLab, Gitea, and
local SQLite storage as documented in issue-facade/README.md.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-24 21:25:04 +02:00
parent cb94c92fc0
commit a8e5b4b044
58 changed files with 11 additions and 14628 deletions

View File

@@ -1,5 +1,9 @@
# TDDAi Configuration Management
> **⚠️ DEPRECATED**: The tddai framework has been replaced by the [issue-facade](issue-facade/) system. This documentation is kept for historical reference only.
>
> **For current issue management**: See [issue-facade/README.md](issue-facade/README.md)
The tddai framework uses a flexible, hierarchical configuration system designed for project-agnostic deployment while supporting per-project customization.
## Configuration Hierarchy

260
Makefile
View File

@@ -1,7 +1,7 @@
# MarkiTect - Advanced Markdown Engine
# Makefile for common development tasks
.PHONY: help setup install-dev install-home install-home-venv install-deps install-deps-force install-deps-venv install-system list-deps setup-dev test build clean update status lint format check-deps venv-status update-digest add-diary-entry issue-list issue-show issue-list-open issue-create issue-close issue-close-enhanced issue-close-batch issue-get issue-csv issue-json issue-high test-from-issue tdd-start tdd-add-test tdd-finish tdd-status test-status test-new test-coverage test-arch test-foundation test-infrastructure test-integration test-domain test-service test-application test-presentation test-quick test-layers test-random test-random-seed test-random-repeat test-install-randomly test-clean test-tdd test-changed test-module test-cache-clean test-efficient cli-help release-status release-validate release-prepare release-build release-publish release-dry-run chaos-validate chaos-matrix chaos-inject chaos-report cost-help cost-note-issue
.PHONY: help setup install-dev install-home install-home-venv install-deps install-deps-force install-deps-venv install-system list-deps setup-dev test build clean update status lint format check-deps venv-status update-digest add-diary-entry test-status test-new test-coverage test-arch test-foundation test-infrastructure test-integration test-domain test-service test-application test-presentation test-quick test-layers test-random test-random-seed test-random-repeat test-install-randomly test-clean test-tdd test-changed test-module test-cache-clean test-efficient cli-help release-status release-validate release-prepare release-build release-publish release-dry-run chaos-validate chaos-matrix chaos-inject chaos-report cost-help
# Default target
help:
@@ -28,7 +28,7 @@ help:
@echo " test - Run all tests"
@echo " test-status - Show test status summary without re-running"
@echo " test-new - Create new test file template"
@echo " test-coverage ISSUE=X - Analyze test coverage for issue"
@echo " test-coverage - Analyze test coverage"
@echo " build - Build the package"
@echo " lint - Run code linting"
@echo " format - Format code"
@@ -49,7 +49,6 @@ help:
@echo ""
@echo "Cost Tracking:"
@echo " cost-help - Show cost tracking commands and usage"
@echo " cost-note-issue ISSUE=X INPUT_TOKENS=N OUTPUT_TOKENS=M - Generate cost note for issue"
@echo ""
@echo "Architectural Testing:"
@echo " test-arch - Run all tests in architectural order"
@@ -87,27 +86,6 @@ help:
@echo " update-digest - Update ProjectStatusDigest.md (requires Claude Code)"
@echo " add-diary-entry - Add new entry to ProjectDiary.md (requires Claude Code)"
@echo ""
@echo "Issue Management:"
@echo " issue-list - Show all gitea issues with status and priority"
@echo " issue-list-open - Show only open issues (active backlog)"
@echo " issue-create TITLE='...' BODY='...' - Create a new issue (or BODY_FILE='/path/to/file.md')"
@echo " issue-show ISSUE=X (or NUM=X) - Show detailed view of specific issue"
@echo " issue-close ISSUE=X [COMMENT='reason'] - Close an issue and mark as completed"
@echo " issue-close-enhanced ISSUE=X [WORK='description'] - Close issue with enhanced functionality"
@echo " issue-close-batch NUMS='X Y Z' [COMMENT='reason'] - Close multiple issues at once"
@echo " issue-get - Export compact issue index to ISSUES.index"
@echo " issue-csv - Export issues as CSV for spreadsheet processing"
@echo " issue-json - Export issues as JSON for programmatic processing"
@echo " issue-high - Export only high/critical priority issues"
@echo ""
@echo "Test-Driven Development:"
@echo " test-from-issue ISSUE=X - Generate test skeleton from issue (requires Claude Code)"
@echo ""
@echo "TDD Workspace:"
@echo " tdd-start ISSUE=X - Start working on issue (with requirements validation)"
@echo " tdd-add-test - Add test to current issue workspace"
@echo " tdd-status - Show current workspace state"
@echo " tdd-finish - Complete issue work (moves tests to main)"
@echo ""
@echo "Requirements Engineering:"
@echo " validate-requirements - Analyze foundations before development"
@@ -569,202 +547,7 @@ add-diary-entry:
# Git repository and API configuration
GITEA_URL := http://92.205.130.254:32166
REPO_OWNER := coulomb
REPO_NAME := markitect_project
ISSUES_API := $(GITEA_URL)/api/v1/repos/$(REPO_OWNER)/$(REPO_NAME)/issues
# Issue workspace configuration
WORKSPACE_DIR := .markitect_workspace
CURRENT_ISSUE_FILE := $(WORKSPACE_DIR)/current_issue.json
# List all gitea issues
issue-list: $(VENV)/bin/activate
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py list-issues
# Show detailed view of a specific issue
issue-show: $(VENV)/bin/activate
@ISSUE_NUM=""; \
if [ -n "$(ISSUE)" ]; then \
ISSUE_NUM="$(ISSUE)"; \
elif [ -n "$(NUM)" ]; then \
ISSUE_NUM="$(NUM)"; \
fi; \
if [ -z "$$ISSUE_NUM" ]; then \
echo "❌ Please specify issue number: make issue-show ISSUE=5 (or NUM=5)"; \
exit 1; \
fi; \
PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py show-issue $$ISSUE_NUM
# List only open issues (active backlog)
issue-list-open: $(VENV)/bin/activate
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py list-open-issues
# Create a new issue
issue-create:
@if [ -z "$(TITLE)" ]; then \
echo "❌ Please specify issue title: make issue-create TITLE='Fix bug' BODY='Description'"; \
echo "❌ Or use: make issue-create TITLE='Fix bug' BODY_FILE='/path/to/body.md'"; \
exit 1; \
fi
@if [ -z "$(BODY)" ] && [ -z "$(BODY_FILE)" ]; then \
echo "❌ Please specify either BODY='...' or BODY_FILE='/path/to/file.md'"; \
exit 1; \
fi
@echo "📋 Creating new issue..."
@echo "📋 Title: $(TITLE)"
@if [ -n "$(BODY_FILE)" ]; then \
tea issue create --title "$(TITLE)" --description "$$(cat $(BODY_FILE))"; \
else \
tea issue create --title "$(TITLE)" --description "$(BODY)"; \
fi
# Close an issue and mark as completed
issue-close: $(VENV)/bin/activate
@ISSUE_NUM=""; \
if [ -n "$(ISSUE)" ]; then \
ISSUE_NUM="$(ISSUE)"; \
elif [ -n "$(NUM)" ]; then \
ISSUE_NUM="$(NUM)"; \
fi; \
if [ -z "$$ISSUE_NUM" ]; then \
echo "❌ Please specify issue number: make issue-close ISSUE=5 (or NUM=5)"; \
exit 1; \
fi; \
if [ -n "$(COMMENT)" ]; then \
echo "🔄 Closing issue #$$ISSUE_NUM with comment..."; \
PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py close-issue $$ISSUE_NUM --comment "$(COMMENT)"; \
else \
echo "🔄 Closing issue #$$ISSUE_NUM..."; \
PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py close-issue $$ISSUE_NUM; \
fi; \
echo "✅ Issue #$$ISSUE_NUM closed successfully!"
# Close issue using dedicated issue_closer.py script (enhanced functionality)
issue-close-enhanced: $(VENV)/bin/activate
@ISSUE_NUM=""; \
if [ -n "$(ISSUE)" ]; then \
ISSUE_NUM="$(ISSUE)"; \
elif [ -n "$(NUM)" ]; then \
ISSUE_NUM="$(NUM)"; \
fi; \
if [ -z "$$ISSUE_NUM" ]; then \
echo "❌ Please specify issue number: make issue-close-enhanced ISSUE=5 (or NUM=5)"; \
exit 1; \
fi; \
if [ -n "$(WORK)" ]; then \
echo "🔄 Closing issue #$$ISSUE_NUM with completion message..."; \
PYTHONPATH=. $(VENV_PYTHON) tddai/issue_closer.py $$ISSUE_NUM --work-completed "$(WORK)"; \
elif [ -n "$(COMMENT)" ]; then \
echo "🔄 Closing issue #$$ISSUE_NUM with comment..."; \
PYTHONPATH=. $(VENV_PYTHON) tddai/issue_closer.py $$ISSUE_NUM --comment "$(COMMENT)"; \
else \
echo "🔄 Closing issue #$$ISSUE_NUM..."; \
PYTHONPATH=. $(VENV_PYTHON) tddai/issue_closer.py $$ISSUE_NUM; \
fi
# Close multiple issues at once using issue_closer.py
issue-close-batch: $(VENV)/bin/activate
@if [ -z "$(NUMS)" ]; then \
echo "❌ Please specify issue numbers: make issue-close-batch NUMS='42 43 44'"; \
exit 1; \
fi
@if [ -n "$(COMMENT)" ]; then \
echo "🔄 Closing issues $(NUMS) with comment..."; \
PYTHONPATH=. $(VENV_PYTHON) tddai/issue_closer.py $(NUMS) --comment "$(COMMENT)"; \
else \
echo "🔄 Closing issues $(NUMS)..."; \
PYTHONPATH=. $(VENV_PYTHON) tddai/issue_closer.py $(NUMS); \
fi
# Export compact issue index to ISSUES.index file (TSV format)
issue-get: $(VENV)/bin/activate
@echo "📋 Fetching issue index from gitea..."
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py issue-index --format tsv --sort number > ISSUES.index
@echo "✅ Issue index exported to ISSUES.index (TSV format)"
@echo "📄 File contents:"
@cat ISSUES.index
# Export issues as CSV for spreadsheet processing
issue-csv: $(VENV)/bin/activate
@echo "📊 Exporting issues as CSV..."
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py issue-index --format csv --sort priority --include-state > ISSUES.csv
@echo "✅ Issues exported to ISSUES.csv"
@wc -l ISSUES.csv | awk '{print "📄 Total entries:", $$1-1, "(excluding header)"}'
# Export issues as JSON for programmatic processing
issue-json: $(VENV)/bin/activate
@echo "🔧 Exporting issues as JSON..."
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py issue-index --format json --sort priority > ISSUES.json
@echo "✅ Issues exported to ISSUES.json"
@echo "📄 Sample entry:"
@head -20 ISSUES.json
# Export only high and critical priority issues
issue-high: $(VENV)/bin/activate
@echo "🚨 Exporting high priority issues..."
@echo "High priority issues:" > ISSUES.high.txt
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py issue-index --format tsv --filter-priority high --sort number >> ISSUES.high.txt
@echo "" >> ISSUES.high.txt
@echo "Critical priority issues:" >> ISSUES.high.txt
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py issue-index --format tsv --filter-priority critical --sort number >> ISSUES.high.txt
@echo "✅ High priority issues exported to ISSUES.high.txt"
@cat ISSUES.high.txt
# Generate test skeleton from gitea issue (requires Claude Code)
test-from-issue:
@ISSUE_NUM=""; \
if [ -n "$(ISSUE)" ]; then \
ISSUE_NUM="$(ISSUE)"; \
elif [ -n "$(NUM)" ]; then \
ISSUE_NUM="$(NUM)"; \
fi; \
if [ -z "$$ISSUE_NUM" ]; then \
echo "❌ Please specify issue number: make test-from-issue ISSUE=1 (or NUM=1)"; \
exit 1; \
fi
@echo "🔍 Checking for Claude Code availability..."
@if ! command -v claude >/dev/null 2>&1; then \
echo "❌ Claude Code not found in PATH"; \
echo " This target requires Claude Code CLI to be installed"; \
echo " Visit: https://claude.ai/code for installation instructions"; \
exit 1; \
fi
@echo "✅ Claude Code found"
@echo "🔍 Checking for curl..."
@if ! command -v curl >/dev/null 2>&1; then \
echo "❌ curl not found - required for API access"; \
exit 1; \
fi
@echo "✅ curl found"
@echo "📋 Fetching issue #$$ISSUE_NUM details..."
@curl -s "$(ISSUES_API)/$$ISSUE_NUM" | jq -r 'if .title then "✅ Issue #'"$$ISSUE_NUM"': " + .title + "\n\n🧪 Generating test skeleton...\n Please ask Claude Code to generate a test for this issue:\n\n Command: '"'"'Generate a test skeleton for issue #'"$$ISSUE_NUM"''"'"'\n\n📋 Issue Details:\n Title: " + .title + "\n Description: " + .body + "\n\n📝 Test Requirements:\n - Follow TDD principles (test first, then implementation)\n - Use pytest framework (existing project convention)\n - Place test in tests/ directory\n - Name test file: test_issue_'"$$ISSUE_NUM"'_*.py\n - Include docstring referencing issue #'"$$ISSUE_NUM"'\n - Test should initially fail (red state)\n\n💡 After generation, run '"'"'make test'"'"' to verify test fails initially" else "❌ Issue #'"$$ISSUE_NUM"' not found or API error\n Use '"'"'make list-open-issues'"'"' to see available issues" end' 2>/dev/null || echo "❌ Issue #$$ISSUE_NUM not found or API error"
# Start working on an issue (creates workspace with requirements validation)
tdd-start: validate-requirements $(VENV)/bin/activate
@ISSUE_NUM=""; \
if [ -n "$(ISSUE)" ]; then \
ISSUE_NUM="$(ISSUE)"; \
elif [ -n "$(NUM)" ]; then \
ISSUE_NUM="$(NUM)"; \
fi; \
if [ -z "$$ISSUE_NUM" ]; then \
echo "❌ Please specify issue number: make tdd-start ISSUE=1 (or NUM=1)"; \
exit 1; \
fi; \
echo "🚀 Starting TDD workflow with requirements validation..."; \
PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py start-issue $$ISSUE_NUM
# Add test to current issue workspace
tdd-add-test: $(VENV)/bin/activate
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py add-test
# Show current workspace status
tdd-status: $(VENV)/bin/activate
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py workspace-status
# Complete issue work (move tests to main and cleanup)
tdd-finish: $(VENV)/bin/activate
@PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py finish-issue
# Show test status summary without re-running tests
test-status: $(VENV)/bin/activate
@@ -876,19 +659,11 @@ test-new: $(VENV)/bin/activate
echo " 3. Implement the actual functionality"; \
echo " 4. Run tests again to verify (TDD cycle)"
# Analyze test coverage for a specific issue
# Analyze test coverage
test-coverage: $(VENV)/bin/activate
@ISSUE_NUM=""; \
if [ -n "$(ISSUE)" ]; then \
ISSUE_NUM="$(ISSUE)"; \
elif [ -n "$(NUM)" ]; then \
ISSUE_NUM="$(NUM)"; \
fi; \
if [ -z "$$ISSUE_NUM" ]; then \
echo "❌ Please specify issue number: make test-coverage ISSUE=5 (or NUM=5)"; \
exit 1; \
fi; \
PYTHONPATH=. $(VENV_PYTHON) tddai_cli.py analyze-coverage $$ISSUE_NUM
@echo "📊 Analyzing test coverage..."
@pytest --cov=markitect --cov-report=html --cov-report=term-missing tests/
@echo "✅ Coverage report generated in htmlcov/"
# ============================================================================
# Architectural Testing Targets
@@ -1473,26 +1248,3 @@ cost-help:
@echo "💰 Currency: Costs calculated in USD and EUR"
@echo "🤖 Model: Default claude-sonnet-4 pricing"
# Generate cost note for an issue (requires ISSUE, INPUT_TOKENS, OUTPUT_TOKENS)
cost-note-issue: $(VENV)/bin/activate
@if [ -z "$(ISSUE)" ]; then \
echo "❌ Please specify issue number: make cost-note-issue ISSUE=136 INPUT_TOKENS=45000 OUTPUT_TOKENS=28000"; \
exit 1; \
fi
@if [ -z "$(INPUT_TOKENS)" ]; then \
echo "❌ Please specify input tokens: make cost-note-issue ISSUE=$(ISSUE) INPUT_TOKENS=45000 OUTPUT_TOKENS=28000"; \
exit 1; \
fi
@if [ -z "$(OUTPUT_TOKENS)" ]; then \
echo "❌ Please specify output tokens: make cost-note-issue ISSUE=$(ISSUE) INPUT_TOKENS=$(INPUT_TOKENS) OUTPUT_TOKENS=28000"; \
exit 1; \
fi
@echo "💰 Generating cost note for Issue #$(ISSUE)..."
@$(VENV_PYTHON) -c "import sys; sys.path.append('.'); from tddai.issue_fetcher import IssueFetcher; fetcher = IssueFetcher(); issue = fetcher.fetch_issue($(ISSUE)); print(f'📋 Issue: {issue[\"title\"]}')"
@ISSUE_TITLE=$$($(VENV_PYTHON) -c "import sys; sys.path.append('.'); from tddai.issue_fetcher import IssueFetcher; fetcher = IssueFetcher(); issue = fetcher.fetch_issue($(ISSUE)); print(issue['title'])"); \
markitect cost session track $(ISSUE) "$$ISSUE_TITLE" \
--input-tokens $(INPUT_TOKENS) \
--output-tokens $(OUTPUT_TOKENS) \
--summary "$(if $(SUMMARY),$(SUMMARY),Implementation completed using TDD8 methodology)"
@echo "✅ Cost note generated successfully!"
@echo "📁 Check cost_notes/issue_$(ISSUE)_cost_$$(date +%Y-%m-%d).md"

View File

@@ -5,16 +5,8 @@ Commands handle argument parsing and delegation to services.
They contain no business logic, only CLI-specific concerns.
"""
from .workspace import WorkspaceCommands
from .issues import IssueCommands
from .project import ProjectCommands
from .export import ExportCommands
from .config import ConfigCommands
__all__ = [
'WorkspaceCommands',
'IssueCommands',
'ProjectCommands',
'ExportCommands',
'ConfigCommands'
]

View File

@@ -1,82 +0,0 @@
"""
Export and reporting CLI commands.
"""
import sys
from typing import Optional
from tddai import TddaiError
from services import ExportService
from cli.presenters import OutputFormatter
class ExportCommands:
"""Commands for data export and reporting."""
def __init__(self):
self.service = ExportService()
def issue_index(self, format_type: str = "tsv", sort_by: str = "number",
filter_state: Optional[str] = None, filter_priority: Optional[str] = None,
include_state: bool = False) -> None:
"""Output compact index of all issues for Unix processing.
Args:
format_type: Output format (tsv, csv, json, fields)
sort_by: Sort by field (number, title, priority, state, created, updated)
filter_state: Filter by state (open, closed)
filter_priority: Filter by priority (low, medium, high, critical, none)
include_state: Include state column in output
"""
try:
output = self.service.export_issues(
format_type=format_type,
sort_by=sort_by,
filter_state=filter_state,
filter_priority=filter_priority,
include_state=include_state
)
# Output directly to stdout for piping
print(output)
except TddaiError as e:
# Send error to stderr to avoid corrupting piped output
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
def export_issues_csv(self, output_file: str = None) -> None:
"""Export issues in CSV format."""
try:
output = self.service.export_issues(
format_type="csv",
sort_by="number"
)
if output_file:
with open(output_file, 'w') as f:
f.write(output)
OutputFormatter.success(f"Issues exported to {output_file}")
else:
print(output)
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))
def export_issues_json(self, output_file: str = None) -> None:
"""Export issues in JSON format."""
try:
output = self.service.export_issues(
format_type="json",
sort_by="number"
)
if output_file:
with open(output_file, 'w') as f:
f.write(output)
OutputFormatter.success(f"Issues exported to {output_file}")
else:
print(output)
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))

View File

@@ -1,134 +0,0 @@
"""
Issue CLI commands.
"""
from typing import List, Optional, Any
from tddai import TddaiError
from services import IssueService
from cli.presenters import OutputFormatter, IssueView
class IssueCommands:
"""Commands for issue operations."""
def __init__(self) -> None:
self.service = IssueService()
def list_issues(self) -> None:
"""List all issues."""
try:
issues = self.service.list_issues()
IssueView.show_list(issues)
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))
def list_open_issues(self) -> None:
"""List only open issues."""
try:
issues = self.service.list_open_issues()
IssueView.show_open_issues(issues)
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))
def show_issue(self, issue_number: int) -> None:
"""Show detailed issue information."""
try:
issue_data = self.service.get_issue_details(issue_number)
IssueView.show_issue_details(issue_data)
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))
def create_issue(self, title: str, body: str, issue_type: str = "enhancement") -> None:
"""Create a new issue."""
try:
OutputFormatter.info(f"Creating {issue_type} issue: {title}")
OutputFormatter.empty_line()
result = self.service.create_issue(title, body, labels=[issue_type])
IssueView.show_creation_success(result, issue_type)
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error creating issue: {e}")
def create_enhancement_issue(self, title: str, use_case: str,
technical_requirements: str = "",
acceptance_criteria: Optional[List[str]] = None,
dependencies: Optional[List[str]] = None,
priority: str = "Medium") -> None:
"""Create a structured enhancement issue."""
try:
OutputFormatter.info(f"Creating enhancement issue: {title}")
OutputFormatter.empty_line()
result = self.service.create_enhancement_issue(
title, use_case, technical_requirements,
acceptance_criteria, dependencies, priority
)
OutputFormatter.success("Enhancement issue created successfully!")
OutputFormatter.key_value("Number", f"#{result['number']}")
OutputFormatter.key_value("Title", result['title'])
OutputFormatter.key_value("Priority", priority)
if 'html_url' in result:
OutputFormatter.key_value("URL", result['html_url'])
OutputFormatter.empty_line()
print("💡 Next steps:")
print(f" - Use 'make tdd-start NUM={result['number']}' to begin work")
print(f" - Use 'make show-issue NUM={result['number']}' to view details")
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error creating enhancement issue: {e}")
def create_from_template(self, template_file: str, **kwargs: Any) -> None:
"""Create issue from template file."""
try:
OutputFormatter.info(f"Creating issue from template: {template_file}")
OutputFormatter.empty_line()
result = self.service.create_from_template(template_file, **kwargs)
OutputFormatter.success("Issue created from template successfully!")
OutputFormatter.key_value("Number", f"#{result['number']}")
OutputFormatter.key_value("Title", result['title'])
if 'html_url' in result:
OutputFormatter.key_value("URL", result['html_url'])
OutputFormatter.empty_line()
print("💡 Next steps:")
print(f" - Use 'make tdd-start NUM={result['number']}' to begin work")
print(f" - Use 'make show-issue NUM={result['number']}' to view details")
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error creating issue from template: {e}")
def close_issue(self, issue_number: int, comment: str = "") -> None:
"""Close an issue with optional comment."""
try:
OutputFormatter.info(f"Closing issue #{issue_number}")
if comment:
OutputFormatter.info(f"Comment: {comment}")
OutputFormatter.empty_line()
result = self.service.close_issue(issue_number, comment)
OutputFormatter.success(f"Issue #{issue_number} closed successfully!")
OutputFormatter.key_value("Title", result['title'])
OutputFormatter.key_value("State", result['state'])
if 'html_url' in result:
OutputFormatter.key_value("URL", result['html_url'])
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error closing issue: {e}")
def analyze_coverage(self, issue_number: int) -> None:
"""Analyze test coverage for a specific issue."""
try:
coverage_data = self.service.analyze_coverage(issue_number)
IssueView.show_coverage_analysis(coverage_data)
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))

View File

@@ -1,88 +0,0 @@
"""
Project management CLI commands.
"""
from tddai import TddaiError
from services import ProjectService
from cli.presenters import OutputFormatter, ProjectView
class ProjectCommands:
"""Commands for project management operations."""
def __init__(self):
self.service = ProjectService()
def setup_project_management(self) -> None:
"""Setup project management labels and milestones."""
try:
OutputFormatter.info("Setting up project management system...")
self.service.setup_project_management()
ProjectView.show_setup_success()
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error setting up project management: {e}")
def move_issue_to_state(self, issue_number: int, state: str) -> None:
"""Move issue to a specific project state."""
try:
OutputFormatter.info(f"Moving issue #{issue_number} to {state} state...")
result = self.service.set_issue_state(issue_number, state)
# If moving to done, also close the issue
if state == 'done':
self.service.move_issue_to_done(issue_number)
OutputFormatter.success(f"Issue #{issue_number} moved to {state} and closed")
else:
OutputFormatter.success(f"Issue #{issue_number} moved to {state}")
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error moving issue to {state}: {e}")
def set_issue_priority(self, issue_number: int, priority: str) -> None:
"""Set issue priority."""
try:
OutputFormatter.info(f"Setting issue #{issue_number} priority to {priority}...")
result = self.service.set_issue_priority(issue_number, priority)
OutputFormatter.success(f"Issue #{issue_number} priority set to {priority}")
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error setting issue priority: {e}")
def create_milestone(self, title: str, description: str = "") -> None:
"""Create a new milestone (project)."""
try:
OutputFormatter.info(f"Creating milestone: {title}")
milestone = self.service.create_milestone(title, description)
OutputFormatter.success("Milestone created successfully!")
OutputFormatter.key_value("ID", milestone.id)
OutputFormatter.key_value("Title", milestone.title)
OutputFormatter.key_value("Description", milestone.description)
OutputFormatter.key_value("State", milestone.state)
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error creating milestone: {e}")
def list_milestones(self) -> None:
"""List all milestones."""
try:
milestones = self.service.list_milestones("all")
ProjectView.show_milestone_list(milestones)
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error listing milestones: {e}")
def assign_issue_to_milestone(self, issue_number: int, milestone_id: int) -> None:
"""Assign issue to a milestone."""
try:
OutputFormatter.info(f"Assigning issue #{issue_number} to milestone #{milestone_id}...")
result = self.service.assign_issue_to_milestone(issue_number, milestone_id)
OutputFormatter.success(f"Issue #{issue_number} assigned to milestone #{milestone_id}")
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error assigning issue to milestone: {e}")
def project_overview(self) -> None:
"""Show project management overview."""
try:
overview = self.service.get_project_overview()
ProjectView.show_overview(overview)
except TddaiError as e:
OutputFormatter.exit_with_error(f"Error getting project overview: {e}")

View File

@@ -1,100 +0,0 @@
"""
Workspace CLI commands.
"""
from tddai import TddaiError
from services import WorkspaceService
from cli.presenters import OutputFormatter, WorkspaceView
class WorkspaceCommands:
"""Commands for workspace operations."""
def __init__(self):
self.service = WorkspaceService()
def status(self) -> None:
"""Show current workspace status."""
try:
summary = self.service.get_workspace_summary()
WorkspaceView.show_status(summary)
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))
def start_issue(self, issue_number: int) -> None:
"""Start working on an issue."""
try:
OutputFormatter.info(f"Starting work on issue #{issue_number}...")
OutputFormatter.info(f"Fetching issue #{issue_number} details...")
workspace_info = self.service.start_issue_workspace(issue_number)
summary = self.service.get_workspace_summary()
WorkspaceView.show_start_success(summary)
except TddaiError as e:
if "Already working on" in str(e):
OutputFormatter.warning(str(e))
print(" Run 'make tdd-finish' first or 'make tdd-status' to see details")
OutputFormatter.exit_with_error("Cannot start new workspace", 1)
else:
OutputFormatter.exit_with_error(str(e))
def finish_issue(self) -> None:
"""Finish current issue workspace."""
try:
issue_number = self.service.finish_current_workspace()
if issue_number is None:
OutputFormatter.error("No active issue workspace")
print(" Nothing to finish")
OutputFormatter.exit_with_error("", 1)
return # Explicit return for type checker
# Get test count before finishing
summary = self.service.get_workspace_summary()
test_count = summary.get('test_count', 0)
WorkspaceView.show_finish_success(issue_number, test_count)
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))
def add_test_guidance(self) -> None:
"""Show guidance for adding tests."""
try:
summary = self.service.get_workspace_summary()
if not summary['active']:
OutputFormatter.error("No active issue workspace")
print(" Run 'make tdd-start NUM=X' first")
OutputFormatter.exit_with_error("", 1)
issue_num = summary['issue_number']
issue_title = summary['issue_title']
workspace_dir = summary['workspace_dir']
print(f"🧪 Adding test to issue #{issue_num} workspace")
OutputFormatter.empty_line()
OutputFormatter.key_value("Issue", f"#{issue_num}: {issue_title}")
OutputFormatter.key_value("Workspace", f"{workspace_dir}/issue_{issue_num}/")
OutputFormatter.empty_line()
print("🤖 Please ask Claude Code to generate a test:")
OutputFormatter.empty_line()
print(" Command: 'Generate a test for the current workspace issue'")
OutputFormatter.empty_line()
print("📝 Test Requirements:")
print(f" - Save test in: {workspace_dir}/issue_{issue_num}/tests/")
print(f" - Name format: test_issue_{issue_num}_<scenario>.py")
print(f" - Include docstring referencing issue #{issue_num}")
print(" - Follow TDD principles (test should fail initially)")
print(" - Review requirements.md and test_plan.md for context")
OutputFormatter.empty_line()
print("📋 Issue Details:")
OutputFormatter.key_value("Title", issue_title)
# Note: Could fetch full issue details if needed
OutputFormatter.empty_line()
print("💡 After generation: Use 'make tdd-status' to see all tests")
except TddaiError as e:
OutputFormatter.exit_with_error(str(e))

View File

@@ -5,92 +5,15 @@ Provides the main CLI framework and command delegation.
"""
from typing import Any
from .commands import WorkspaceCommands, IssueCommands, ProjectCommands, ExportCommands, ConfigCommands
from .commands import ConfigCommands
class CLIFramework:
"""Main CLI framework that delegates to command classes."""
def __init__(self) -> None:
self.workspace = WorkspaceCommands()
self.issues = IssueCommands()
self.project = ProjectCommands()
self.export = ExportCommands()
self.config = ConfigCommands()
# Workspace operations
def workspace_status(self) -> None:
return self.workspace.status()
def start_issue(self, issue_number: int) -> None:
return self.workspace.start_issue(issue_number)
def finish_issue(self) -> None:
return self.workspace.finish_issue()
def add_test_guidance(self) -> None:
return self.workspace.add_test_guidance()
# Issue operations
def list_issues(self) -> None:
return self.issues.list_issues()
def list_open_issues(self) -> None:
return self.issues.list_open_issues()
def show_issue(self, issue_number: int) -> None:
return self.issues.show_issue(issue_number)
def create_issue(self, title: str, body: str, issue_type: str = "enhancement") -> None:
return self.issues.create_issue(title, body, issue_type)
def create_enhancement_issue(self, title: str, use_case: str, **kwargs: Any) -> None:
return self.issues.create_enhancement_issue(title, use_case, **kwargs)
def create_from_template(self, template_file: str, **kwargs: Any) -> None:
return self.issues.create_from_template(template_file, **kwargs)
def close_issue(self, issue_number: int, comment: str = "") -> None:
return self.issues.close_issue(issue_number, comment)
def analyze_coverage(self, issue_number: int) -> None:
return self.issues.analyze_coverage(issue_number)
# Project management operations
def setup_project_management(self) -> None:
return self.project.setup_project_management()
def move_issue_to_state(self, issue_number: int, state: str) -> None:
return self.project.move_issue_to_state(issue_number, state)
def set_issue_priority(self, issue_number: int, priority: str) -> None:
return self.project.set_issue_priority(issue_number, priority)
def create_milestone(self, title: str, description: str = "") -> None:
return self.project.create_milestone(title, description)
def list_milestones(self) -> None:
return self.project.list_milestones()
def assign_issue_to_milestone(self, issue_number: int, milestone_id: int) -> None:
return self.project.assign_issue_to_milestone(issue_number, milestone_id)
def project_overview(self) -> None:
return self.project.project_overview()
# Export operations
def issue_index(self, **kwargs: Any) -> None:
return self.export.issue_index(**kwargs)
def export_issues_csv(self, output_file: str = None) -> None:
return self.export.export_issues_csv(output_file)
def export_issues_json(self, output_file: str = None) -> None:
return self.export.export_issues_json(output_file)
def export_issue_index(self, output_file: str = None) -> None:
return self.export.issue_index(format_type="tsv", output_file=output_file)
# Configuration operations
def show_config(self, show_sensitive: bool = False) -> None:
return self.config.show_config(show_sensitive)

View File

@@ -1,180 +0,0 @@
#!/usr/bin/env python3
"""
Pure Issue Management CLI
Dedicated CLI interface for issue management operations, providing clean
separation from document processing and TDD workflow functionality.
This CLI focuses exclusively on issue operations:
- Listing and viewing issues
- Creating and closing issues
- Project management (milestones, priorities, states)
- Issue metadata and bulk operations
Architecture: Uses the unified cli/ framework for consistent command structure.
"""
import sys
import argparse
from pathlib import Path
from typing import Optional
# Add project root to path for imports
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from cli.core import CLIFramework
from tddai import TddaiError
def create_parser() -> argparse.ArgumentParser:
"""Create argument parser for issue CLI."""
parser = argparse.ArgumentParser(
prog='issue',
description='Pure Issue Management CLI - Dedicated interface for issue operations',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
issue list # List all issues
issue list --open # List only open issues
issue show 42 # Show issue details
issue create "Bug fix" "Description" # Create new issue
issue close 42 "Fixed the problem" # Close issue with comment
issue assign 42 milestone-1 # Assign to milestone
issue priority 42 high # Set priority
issue state 42 "In Progress" # Set project state
Focus Areas:
- Issue browsing and management
- Project organization (milestones, priorities)
- Bulk operations and metadata management
- Integration with various issue tracking backends
Related Commands:
tddai - TDD workflow management with issue context
markitect - Document processing and template operations
"""
)
subparsers = parser.add_subparsers(dest='command', help='Available issue commands')
# List issues
list_parser = subparsers.add_parser('list', help='List issues')
list_parser.add_argument('--open', action='store_true', help='Show only open issues')
list_parser.add_argument('--format', choices=['table', 'json', 'csv'], default='table', help='Output format')
# Show issue details
show_parser = subparsers.add_parser('show', help='Show issue details')
show_parser.add_argument('issue_number', type=int, help='Issue number')
# Create issue
create_parser = subparsers.add_parser('create', help='Create new issue')
create_parser.add_argument('title', help='Issue title')
create_parser.add_argument('description', help='Issue description')
create_parser.add_argument('--type', choices=['bug', 'enhancement', 'feature'], default='enhancement', help='Issue type')
create_parser.add_argument('--priority', choices=['low', 'medium', 'high', 'critical'], help='Issue priority')
# Close issue
close_parser = subparsers.add_parser('close', help='Close issue')
close_parser.add_argument('issue_number', type=int, help='Issue number')
close_parser.add_argument('comment', nargs='?', default='', help='Closing comment')
# Assign to milestone
assign_parser = subparsers.add_parser('assign', help='Assign issue to milestone')
assign_parser.add_argument('issue_number', type=int, help='Issue number')
assign_parser.add_argument('milestone_id', type=int, help='Milestone ID')
# Set priority
priority_parser = subparsers.add_parser('priority', help='Set issue priority')
priority_parser.add_argument('issue_number', type=int, help='Issue number')
priority_parser.add_argument('priority', choices=['low', 'medium', 'high', 'critical'], help='Priority level')
# Set state
state_parser = subparsers.add_parser('state', help='Set issue project state')
state_parser.add_argument('issue_number', type=int, help='Issue number')
state_parser.add_argument('state', help='Project state')
# Export/bulk operations
export_parser = subparsers.add_parser('export', help='Export issues in various formats')
export_parser.add_argument('--format', choices=['csv', 'json', 'tsv'], default='csv', help='Export format')
export_parser.add_argument('--output', help='Output file (default: stdout)')
export_parser.add_argument('--filter', choices=['open', 'closed', 'all'], default='all', help='Filter issues')
# Milestones
milestone_parser = subparsers.add_parser('milestones', help='List milestones')
return parser
def main():
"""Main entry point for issue CLI."""
parser = create_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
# Initialize CLI framework
try:
cli = CLIFramework()
except Exception as e:
print(f"Error initializing CLI framework: {e}")
sys.exit(1)
# Execute commands
try:
if args.command == 'list':
if args.open:
cli.list_open_issues()
else:
cli.list_issues()
elif args.command == 'show':
cli.show_issue(args.issue_number)
elif args.command == 'create':
kwargs = {}
if hasattr(args, 'priority') and args.priority:
kwargs['priority'] = args.priority
cli.create_issue(args.title, args.description, args.type, **kwargs)
elif args.command == 'close':
cli.close_issue(args.issue_number, args.comment)
elif args.command == 'assign':
cli.assign_issue_to_milestone(args.issue_number, args.milestone_id)
elif args.command == 'priority':
cli.set_issue_priority(args.issue_number, args.priority)
elif args.command == 'state':
cli.move_issue_to_state(args.issue_number, args.state)
elif args.command == 'export':
# Export functionality
if args.format == 'csv':
cli.export_issues_csv(args.output)
elif args.format == 'json':
cli.export_issues_json(args.output)
elif args.format == 'tsv':
cli.export_issue_index(args.output)
elif args.command == 'milestones':
cli.list_milestones()
else:
print(f"Unknown command: {args.command}")
parser.print_help()
sys.exit(1)
except TddaiError as e:
print(f"Issue CLI Error: {e}")
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -1,646 +0,0 @@
"""
Gitea repository implementation with async HTTP client.
Provides high-performance, reliable access to Gitea API with connection pooling,
retry mechanisms, and proper error handling.
"""
import asyncio
import json
from infrastructure.logging import get_logger
from typing import List, Optional, Dict, Any
from datetime import datetime, timezone
import aiohttp
from domain.issues.models import Issue, Label, IssueState
from domain.projects.models import Project, Milestone, ProjectState
from infrastructure.repositories.interfaces import IssueRepository, ProjectRepository
from infrastructure.connection_manager import ConnectionManager, retry_with_backoff, RetryConfig
from infrastructure.exceptions import (
ErrorContext, OperationType, GiteaApiError, NetworkError,
ResourceNotFoundError, ValidationError, ConcurrencyError
)
logger = get_logger(__name__)
class GiteaIssueRepository(IssueRepository):
"""
Gitea implementation of IssueRepository using async HTTP client.
Provides efficient access to Gitea issues API with connection pooling,
automatic retries, and proper error handling.
"""
def __init__(self, connection_manager: ConnectionManager, retry_config: Optional[RetryConfig] = None):
self.connection_manager = connection_manager
self.retry_config = retry_config or RetryConfig()
self.repo_owner = None
self.repo_name = None
def set_repo_info(self, repo_owner: str, repo_name: str):
"""Set repository owner and name for API endpoints."""
self.repo_owner = repo_owner
self.repo_name = repo_name
@retry_with_backoff(RetryConfig())
async def get_issue(self, issue_number: int, context: Optional[ErrorContext] = None) -> Issue:
"""Retrieve an issue by its number from Gitea API."""
if context is None:
context = ErrorContext(
operation_id=f"get_issue_{issue_number}",
operation_type=OperationType.READ,
resource_type="Issue",
resource_id=str(issue_number)
)
try:
session = await self.connection_manager.get_http_session()
if not self.repo_owner or not self.repo_name:
raise ValidationError("repo_info", None, "Repository owner and name must be set", context)
endpoint = f"/api/v1/repos/{self.repo_owner}/{self.repo_name}/issues/{issue_number}"
async with session.get(endpoint) as response:
await self._handle_response_errors(response, context)
data = await response.json()
return self._map_api_issue_to_domain(data)
except aiohttp.ClientError as e:
logger.error(f"Network error getting issue {issue_number}: {e}")
raise NetworkError(f"get issue {issue_number}", e, context)
@retry_with_backoff(RetryConfig())
async def get_issues(
self,
project_id: Optional[str] = None,
state: Optional[str] = None,
labels: Optional[List[str]] = None,
limit: int = 100,
offset: int = 0,
context: Optional[ErrorContext] = None
) -> List[Issue]:
"""Retrieve multiple issues with filtering and pagination."""
if context is None:
context = ErrorContext(
operation_id=f"get_issues_{project_id or 'all'}",
operation_type=OperationType.READ,
resource_type="Issue",
metadata={
"project_id": project_id,
"state": state,
"labels": labels,
"limit": limit,
"offset": offset
}
)
try:
session = await self.connection_manager.get_http_session()
# Build query parameters
params = {
"limit": limit,
"page": (offset // limit) + 1 # Gitea uses 1-based pagination
}
if state:
params["state"] = state
if labels:
params["labels"] = ",".join(labels)
if not self.repo_owner or not self.repo_name:
raise ValidationError("repo_info", None, "Repository owner and name must be set", context)
endpoint = f"/api/v1/repos/{self.repo_owner}/{self.repo_name}/issues"
async with session.get(endpoint, params=params) as response:
await self._handle_response_errors(response, context)
data = await response.json()
return [self._map_api_issue_to_domain(issue_data) for issue_data in data]
except aiohttp.ClientError as e:
logger.error(f"Network error getting issues: {e}")
raise NetworkError("get issues", e, context)
@retry_with_backoff(RetryConfig())
async def create_issue(
self,
title: str,
body: str,
labels: Optional[List[str]] = None,
assignees: Optional[List[str]] = None,
context: Optional[ErrorContext] = None
) -> Issue:
"""Create a new issue via Gitea API."""
if context is None:
context = ErrorContext(
operation_id=f"create_issue_{title[:50]}",
operation_type=OperationType.WRITE,
resource_type="Issue",
request_data={
"title": title,
"body": body,
"labels": labels,
"assignees": assignees
}
)
# Validate input
if not title or not title.strip():
raise ValidationError("title", title, "Title cannot be empty", context)
if len(title) > 255:
raise ValidationError("title", title, "Title cannot exceed 255 characters", context)
try:
session = await self.connection_manager.get_http_session()
# Prepare request payload
payload = {
"title": title.strip(),
"body": body or ""
}
if labels:
payload["labels"] = labels
if assignees:
payload["assignees"] = assignees
if not self.repo_owner or not self.repo_name:
raise ValidationError("repo_info", None, "Repository owner and name must be set", context)
endpoint = f"/api/v1/repos/{self.repo_owner}/{self.repo_name}/issues"
async with session.post(endpoint, json=payload) as response:
await self._handle_response_errors(response, context)
data = await response.json()
created_issue = self._map_api_issue_to_domain(data)
logger.info(f"Created issue #{created_issue.number}: {title}")
return created_issue
except aiohttp.ClientError as e:
logger.error(f"Network error creating issue '{title}': {e}")
raise NetworkError(f"create issue '{title}'", e, context)
@retry_with_backoff(RetryConfig())
async def update_issue(
self,
issue_number: int,
title: Optional[str] = None,
body: Optional[str] = None,
state: Optional[str] = None,
labels: Optional[List[str]] = None,
context: Optional[ErrorContext] = None
) -> Issue:
"""Update an existing issue via Gitea API."""
if context is None:
context = ErrorContext(
operation_id=f"update_issue_{issue_number}",
operation_type=OperationType.UPDATE,
resource_type="Issue",
resource_id=str(issue_number),
request_data={
"title": title,
"body": body,
"state": state,
"labels": labels
}
)
# Validate input
if title is not None:
if not title.strip():
raise ValidationError("title", title, "Title cannot be empty", context)
if len(title) > 255:
raise ValidationError("title", title, "Title cannot exceed 255 characters", context)
if state is not None and state not in ["open", "closed"]:
raise ValidationError("state", state, "State must be 'open' or 'closed'", context)
try:
session = await self.connection_manager.get_http_session()
# First, get current issue to check for concurrent modifications
current_issue = await self.get_issue(issue_number, context)
# Prepare update payload
payload = {}
if title is not None:
payload["title"] = title.strip()
if body is not None:
payload["body"] = body
if state is not None:
payload["state"] = state
if labels is not None:
payload["labels"] = labels
# Only update if there are changes
if not payload:
return current_issue
if not self.repo_owner or not self.repo_name:
raise ValidationError("repo_info", None, "Repository owner and name must be set", context)
endpoint = f"/api/v1/repos/{self.repo_owner}/{self.repo_name}/issues/{issue_number}"
async with session.patch(endpoint, json=payload) as response:
# Handle potential concurrent modification
if response.status == 409:
raise ConcurrencyError("Issue", str(issue_number), context)
await self._handle_response_errors(response, context)
data = await response.json()
updated_issue = self._map_api_issue_to_domain(data)
logger.info(f"Updated issue #{issue_number}")
return updated_issue
except aiohttp.ClientError as e:
logger.error(f"Network error updating issue {issue_number}: {e}")
raise NetworkError(f"update issue {issue_number}", e, context)
async def get_issue_project_info(
self,
issue_number: int,
context: Optional[ErrorContext] = None
) -> Dict[str, Any]:
"""Get project-related information for an issue."""
if context is None:
context = ErrorContext(
operation_id=f"get_issue_project_info_{issue_number}",
operation_type=OperationType.READ,
resource_type="ProjectInfo",
resource_id=str(issue_number)
)
try:
session = await self.connection_manager.get_http_session()
# Get issue details first
issue = await self.get_issue(issue_number, context)
# Get repository information
if not self.repo_owner or not self.repo_name:
raise ValidationError("repo_info", None, "Repository owner and name must be set", context)
repo_endpoint = f"/api/v1/repos/{self.repo_owner}/{self.repo_name}"
async with session.get(repo_endpoint) as response:
await self._handle_response_errors(response, context)
repo_data = await response.json()
# Get project boards if available
project_info = {
"repository": repo_data,
"kanban_columns": ["Todo", "In Progress", "Review", "Done"], # Default columns
"issue": {
"number": issue.number,
"title": issue.title,
"state": issue.state.value,
"labels": [label.name for label in issue.labels]
}
}
# Try to get actual project boards
try:
projects_endpoint = f"/api/v1/repos/{self.repo_owner}/{self.repo_name}/projects"
async with session.get(projects_endpoint) as projects_response:
if projects_response.status == 200:
projects_data = await projects_response.json()
if projects_data:
# Use first project's columns if available
project_info["projects"] = projects_data
except Exception:
# Projects API might not be available, use defaults
pass
return project_info
except aiohttp.ClientError as e:
logger.error(f"Network error getting project info for issue {issue_number}: {e}")
raise NetworkError(f"get project info for issue {issue_number}", e, context)
def _map_api_issue_to_domain(self, api_data: Dict[str, Any]) -> Issue:
"""Map Gitea API issue data to domain Issue object."""
# Map labels
labels = []
if "labels" in api_data:
for label_data in api_data["labels"]:
label = Label(
name=label_data["name"],
color=label_data.get("color", ""),
description=label_data.get("description", "")
)
labels.append(label)
# Map state
state_value = api_data.get("state", "open")
issue_state = IssueState.OPEN if state_value == "open" else IssueState.CLOSED
# Parse dates
created_at = datetime.fromisoformat(api_data["created_at"].replace("Z", "+00:00"))
updated_at = datetime.fromisoformat(api_data["updated_at"].replace("Z", "+00:00"))
closed_at = None
if api_data.get("closed_at"):
closed_at = datetime.fromisoformat(api_data["closed_at"].replace("Z", "+00:00"))
return Issue(
number=api_data["number"],
title=api_data["title"],
state=issue_state,
labels=labels,
created_at=created_at,
updated_at=updated_at,
milestone=api_data.get("milestone", {}).get("title") if api_data.get("milestone") else None,
assignee=api_data.get("assignees", [{}])[0].get("login") if api_data.get("assignees") else None,
closed_at=closed_at
)
async def _handle_response_errors(self, response: aiohttp.ClientResponse, context: ErrorContext):
"""Handle HTTP response errors and convert to appropriate exceptions."""
if response.status == 200 or response.status == 201:
return
response_text = await response.text()
if response.status == 404:
resource_id = context.resource_id or "unknown"
raise ResourceNotFoundError(context.resource_type, resource_id, context)
elif response.status == 401:
raise GiteaApiError(
response.status,
"Authentication failed - check API token",
str(response.url),
context
)
elif response.status == 403:
raise GiteaApiError(
response.status,
"Access forbidden - check API permissions",
str(response.url),
context
)
elif response.status == 409:
# Conflict - usually concurrent modification
raise ConcurrencyError(context.resource_type, context.resource_id or "unknown", context)
elif response.status == 422:
# Validation error
try:
error_data = await response.json()
error_message = error_data.get("message", response_text)
except:
error_message = response_text
raise ValidationError("request", None, error_message, context)
elif response.status >= 500:
raise GiteaApiError(
response.status,
f"Server error: {response_text}",
str(response.url),
context
)
else:
raise GiteaApiError(
response.status,
response_text,
str(response.url),
context
)
class GiteaProjectRepository(ProjectRepository):
"""
Gitea implementation of ProjectRepository.
Provides access to project and milestone information via Gitea API.
"""
def __init__(self, connection_manager: ConnectionManager, retry_config: Optional[RetryConfig] = None):
self.connection_manager = connection_manager
self.retry_config = retry_config or RetryConfig()
@retry_with_backoff(RetryConfig())
async def get_project(self, project_id: str, context: Optional[ErrorContext] = None) -> Project:
"""Retrieve a project by its ID from Gitea API."""
if context is None:
context = ErrorContext(
operation_id=f"get_project_{project_id}",
operation_type=OperationType.READ,
resource_type="Project",
resource_id=project_id
)
try:
session = await self.connection_manager.get_http_session()
async with session.get(f"/api/v1/repos/projects/{project_id}") as response:
await self._handle_response_errors(response, context)
data = await response.json()
return self._map_api_project_to_domain(data)
except aiohttp.ClientError as e:
logger.error(f"Network error getting project {project_id}: {e}")
raise NetworkError(f"get project {project_id}", e, context)
@retry_with_backoff(RetryConfig())
async def get_projects(
self,
organization: Optional[str] = None,
limit: int = 100,
offset: int = 0,
context: Optional[ErrorContext] = None
) -> List[Project]:
"""Retrieve multiple projects with pagination."""
if context is None:
context = ErrorContext(
operation_id=f"get_projects_{organization or 'all'}",
operation_type=OperationType.READ,
resource_type="Project",
metadata={
"organization": organization,
"limit": limit,
"offset": offset
}
)
try:
session = await self.connection_manager.get_http_session()
params = {
"limit": limit,
"page": (offset // limit) + 1
}
endpoint = "/api/v1/repos/projects"
if organization:
endpoint = f"/api/v1/orgs/{organization}/projects"
async with session.get(endpoint, params=params) as response:
await self._handle_response_errors(response, context)
data = await response.json()
return [self._map_api_project_to_domain(project_data) for project_data in data]
except aiohttp.ClientError as e:
logger.error(f"Network error getting projects: {e}")
raise NetworkError("get projects", e, context)
@retry_with_backoff(RetryConfig())
async def get_milestones(
self,
project_id: str,
state: Optional[str] = None,
context: Optional[ErrorContext] = None
) -> List[Milestone]:
"""Retrieve milestones for a project."""
if context is None:
context = ErrorContext(
operation_id=f"get_milestones_{project_id}",
operation_type=OperationType.READ,
resource_type="Milestone",
metadata={"project_id": project_id, "state": state}
)
try:
session = await self.connection_manager.get_http_session()
params = {}
if state:
params["state"] = state
# Note: This would need repo info from GiteaIssueRepository, but for now use general endpoint
async with session.get("/api/v1/repos/milestones", params=params) as response:
await self._handle_response_errors(response, context)
data = await response.json()
return [self._map_api_milestone_to_domain(milestone_data) for milestone_data in data]
except aiohttp.ClientError as e:
logger.error(f"Network error getting milestones for project {project_id}: {e}")
raise NetworkError(f"get milestones for project {project_id}", e, context)
@retry_with_backoff(RetryConfig())
async def create_milestone(
self,
project_id: str,
title: str,
description: str,
due_date: Optional[str] = None,
context: Optional[ErrorContext] = None
) -> Milestone:
"""Create a new milestone for a project."""
if context is None:
context = ErrorContext(
operation_id=f"create_milestone_{title[:50]}",
operation_type=OperationType.WRITE,
resource_type="Milestone",
request_data={
"project_id": project_id,
"title": title,
"description": description,
"due_date": due_date
}
)
# Validate input
if not title or not title.strip():
raise ValidationError("title", title, "Milestone title cannot be empty", context)
try:
session = await self.connection_manager.get_http_session()
payload = {
"title": title.strip(),
"description": description or ""
}
if due_date:
payload["due_on"] = due_date
# Note: This would need repo info from GiteaIssueRepository, but for now use general endpoint
async with session.post("/api/v1/repos/milestones", json=payload) as response:
await self._handle_response_errors(response, context)
data = await response.json()
created_milestone = self._map_api_milestone_to_domain(data)
logger.info(f"Created milestone: {title}")
return created_milestone
except aiohttp.ClientError as e:
logger.error(f"Network error creating milestone '{title}': {e}")
raise NetworkError(f"create milestone '{title}'", e, context)
def _map_api_project_to_domain(self, api_data: Dict[str, Any]) -> Project:
"""Map Gitea API project data to domain Project object."""
# For now, create a basic project since Gitea projects API might be limited
created_at = datetime.fromisoformat(api_data.get("created_at", datetime.now(timezone.utc).isoformat()).replace("Z", "+00:00"))
updated_at = datetime.fromisoformat(api_data.get("updated_at", datetime.now(timezone.utc).isoformat()).replace("Z", "+00:00"))
return Project(
id=str(api_data.get("id", 0)),
name=api_data.get("title", api_data.get("name", "Unknown Project")),
description=api_data.get("body", api_data.get("description", "")),
state=ProjectState.ACTIVE, # Default to active
milestones=[], # Will be populated separately
created_at=created_at,
updated_at=updated_at
)
def _map_api_milestone_to_domain(self, api_data: Dict[str, Any]) -> Milestone:
"""Map Gitea API milestone data to domain Milestone object."""
created_at = datetime.fromisoformat(api_data["created_at"].replace("Z", "+00:00"))
updated_at = datetime.fromisoformat(api_data["updated_at"].replace("Z", "+00:00"))
due_date = None
if api_data.get("due_on"):
due_date = datetime.fromisoformat(api_data["due_on"].replace("Z", "+00:00"))
return Milestone(
id=api_data["id"],
title=api_data["title"],
description=api_data.get("description", ""),
state=api_data.get("state", "open"),
open_issues=api_data.get("open_issues", 0),
closed_issues=api_data.get("closed_issues", 0),
due_date=due_date,
created_at=created_at,
updated_at=updated_at
)
async def _handle_response_errors(self, response: aiohttp.ClientResponse, context: ErrorContext):
"""Handle HTTP response errors and convert to appropriate exceptions."""
# Reuse the same error handling logic from GiteaIssueRepository
if response.status == 200 or response.status == 201:
return
response_text = await response.text()
if response.status == 404:
resource_id = context.resource_id or "unknown"
raise ResourceNotFoundError(context.resource_type, resource_id, context)
elif response.status >= 400:
raise GiteaApiError(
response.status,
response_text,
str(response.url),
context
)

View File

@@ -109,8 +109,6 @@ from .schema_generator import SchemaGenerator
from .schema_validator import SchemaValidator
from .exceptions import FileNotFoundError, InvalidDepthError, SchemaValidationError, InvalidSchemaError
# Issue management commands - also available via dedicated 'issue' CLI or 'tddai' CLI
from .issues.commands import issues_group
# Global options for CLI configuration
pass_config = click.make_pass_decorator(dict, ensure=True)
@@ -6184,23 +6182,12 @@ cli.add_command(wishlist_group)
# Register issue management commands
cli.add_command(issues_group)
# Register issue activity tracking commands
from markitect.issues.activity_commands import activity as activity_group
cli.add_command(activity_group)
# Register worktime tracking commands
from markitect.finance.worktime_commands import worktime as worktime_group
cli.add_command(worktime_group)
# Register day wrap-up commands
from markitect.finance.day_wrapup_commands import wrapup as wrapup_group
cli.add_command(wrapup_group)
# Register issue wrap-up commands
from markitect.issues.issue_wrapup_commands import issue_wrapup as issue_wrapup_group
cli.add_command(issue_wrapup_group)
# Query Paradigm Commands - Issue #62

View File

@@ -1,566 +0,0 @@
"""
Cost Allocation Engine for MarkiTect Issue Cost Distribution.
This module implements the core allocation engine that distributes operational
costs across active issues using the defined algorithm from Issue #88.
The engine handles:
- Equal distribution of costs across active issues in a period
- Loss carried forward when no active issues exist
- Transaction audit trail creation
- Edge case handling and validation
- Integration with period management and activity tracking
"""
import sqlite3
from datetime import date, datetime
from decimal import Decimal
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
from .models import FinanceModels
from .cost_manager import CostItemManager
from .period_manager import PeriodManager, Period, PeriodStatus
from ..issues.activity_tracker import IssueActivityTracker, ActivityType
class AllocationStatus(Enum):
"""Status enumeration for allocation operations."""
SUCCESS = "success"
NO_ACTIVE_ISSUES = "no_active_issues"
NO_COSTS_TO_ALLOCATE = "no_costs_to_allocate"
PERIOD_CLOSED = "period_closed"
ERROR = "error"
@dataclass
class AllocationResult:
"""Result of a cost allocation operation."""
status: AllocationStatus
period_id: int
total_costs: Decimal = Decimal('0.00')
active_issues: List[int] = None
cost_per_issue: Decimal = Decimal('0.00')
allocations_created: int = 0
transactions_created: int = 0
loss_carried_forward: Decimal = Decimal('0.00')
message: str = ""
def __post_init__(self):
if self.active_issues is None:
self.active_issues = []
@dataclass
class IssueAllocation:
"""Represents a cost allocation to a specific issue."""
issue_id: int
allocated_amount: Decimal
allocation_date: date
period_id: int
transaction_id: Optional[int] = None
class TransactionManager:
"""Manages cost transaction audit trails for allocations."""
def __init__(self, db_path: str):
"""
Initialize transaction manager.
Args:
db_path: Path to the SQLite database
"""
self.db_path = db_path
self.finance_models = FinanceModels(db_path)
def create_allocation_transaction(
self,
period_id: int,
amount: Decimal,
issue_id: int,
transaction_date: date,
description: str
) -> int:
"""
Create a cost allocation transaction record.
Args:
period_id: ID of the cost period
amount: Amount allocated to the issue
issue_id: ID of the issue receiving allocation
transaction_date: Date of the transaction
description: Description of the allocation
Returns:
ID of the created transaction
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO cost_transactions
(period_id, transaction_type, amount_eur, issue_id,
transaction_date, description)
VALUES (?, 'cost_allocated', ?, ?, ?, ?)
''', (period_id, float(amount), issue_id, transaction_date.isoformat() if hasattr(transaction_date, 'isoformat') else transaction_date, description))
return cursor.lastrowid
def create_loss_forward_transaction(
self,
from_period_id: int,
to_period_id: int,
amount: Decimal,
transaction_date: date,
description: str
) -> int:
"""
Create a loss carried forward transaction.
Args:
from_period_id: Source period ID
to_period_id: Destination period ID
amount: Amount being carried forward
transaction_date: Date of the transaction
description: Description of the carry forward
Returns:
ID of the created transaction
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO cost_transactions
(period_id, transaction_type, amount_eur, transaction_date, description)
VALUES (?, 'loss_forward', ?, ?, ?)
''', (to_period_id, float(amount), transaction_date.isoformat() if hasattr(transaction_date, 'isoformat') else transaction_date, description))
return cursor.lastrowid
class AllocationEngine:
"""
Core cost allocation engine for distributing operational costs to active issues.
Implements the algorithm defined in Issue #88:
1. Calculate total costs for the period (monthly + one-time + carried forward)
2. Identify active issues (created/modified during period)
3. Distribute costs equally among active issues
4. Handle edge cases (no active issues -> carry forward loss)
5. Create audit trail transactions
6. Update period statistics
"""
def __init__(self, db_path: str = "markitect.db"):
"""
Initialize the allocation engine.
Args:
db_path: Path to the SQLite database
"""
self.db_path = db_path
self.finance_models = FinanceModels(db_path)
self.cost_manager = CostItemManager(db_path)
self.period_manager = PeriodManager(db_path)
self.activity_tracker = IssueActivityTracker(db_path)
self.transaction_manager = TransactionManager(db_path)
# Ensure database schema is initialized
self.finance_models.initialize_finance_schema()
def allocate_period_costs(self, period_id: int) -> AllocationResult:
"""
Allocate costs for a specific period to active issues.
Args:
period_id: ID of the period to process
Returns:
AllocationResult with operation details and status
"""
try:
# Get period details
period = self._get_period(period_id)
if not period:
return AllocationResult(
status=AllocationStatus.ERROR,
period_id=period_id,
message=f"Period {period_id} not found"
)
# Check if period is already closed
if period.status == PeriodStatus.CLOSED.value:
return AllocationResult(
status=AllocationStatus.PERIOD_CLOSED,
period_id=period_id,
message=f"Period {period_id} is already closed"
)
# Set period status to calculating
self._update_period_status(period_id, PeriodStatus.CALCULATING)
# Step 1: Calculate total costs for period
total_costs = self._calculate_period_total_costs(period)
if total_costs == Decimal('0.00'):
self._update_period_status(period_id, PeriodStatus.CLOSED)
return AllocationResult(
status=AllocationStatus.NO_COSTS_TO_ALLOCATE,
period_id=period_id,
total_costs=total_costs,
message="No costs to allocate for this period"
)
# Step 2: Identify active issues for the period
active_issues = self._get_active_issues_for_period(period)
if not active_issues:
# No active issues - carry forward loss to next period
next_period_id = self._get_or_create_next_period(period)
if next_period_id:
self._carry_forward_loss(period_id, next_period_id, total_costs)
# Update period and close
self._update_period_totals(period_id, total_costs, 0, Decimal('0.00'), total_costs)
self._update_period_status(period_id, PeriodStatus.CLOSED)
return AllocationResult(
status=AllocationStatus.NO_ACTIVE_ISSUES,
period_id=period_id,
total_costs=total_costs,
active_issues=[],
loss_carried_forward=total_costs,
message=f"No active issues found. Carried forward €{total_costs:.2f} to next period"
)
# Step 3: Calculate cost per issue (equal distribution)
cost_per_issue = total_costs / len(active_issues)
# Step 4: Create allocations and transactions
allocations_created = 0
transactions_created = 0
allocation_date = date.today()
for issue_id in active_issues:
# Create allocation record
allocation_id = self._create_issue_allocation(
issue_id, period_id, cost_per_issue, allocation_date
)
if allocation_id:
allocations_created += 1
# Create audit transaction
transaction_id = self.transaction_manager.create_allocation_transaction(
period_id=period_id,
amount=cost_per_issue,
issue_id=issue_id,
transaction_date=allocation_date,
description=f"Cost allocation for period {period.period_start} to {period.period_end}"
)
if transaction_id:
transactions_created += 1
# Link transaction to allocation
self._update_allocation_transaction_id(allocation_id, transaction_id)
# Step 5: Update period totals
self._update_period_totals(
period_id, total_costs, len(active_issues), cost_per_issue, Decimal('0.00')
)
# Step 6: Close the period
self._update_period_status(period_id, PeriodStatus.CLOSED)
return AllocationResult(
status=AllocationStatus.SUCCESS,
period_id=period_id,
total_costs=total_costs,
active_issues=active_issues,
cost_per_issue=cost_per_issue,
allocations_created=allocations_created,
transactions_created=transactions_created,
message=f"Successfully allocated €{total_costs:.2f} across {len(active_issues)} issues"
)
except Exception as e:
# Reset period status on error
self._update_period_status(period_id, PeriodStatus.OPEN)
return AllocationResult(
status=AllocationStatus.ERROR,
period_id=period_id,
message=f"Allocation failed: {str(e)}"
)
def get_issue_allocations(self, issue_id: int) -> List[Dict[str, Any]]:
"""
Get all cost allocations for a specific issue.
Args:
issue_id: ID of the issue
Returns:
List of allocation records
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT
ica.id,
ica.issue_id,
ica.period_id,
ica.allocated_amount,
ica.allocation_date,
ica.transaction_id,
cp.period_start,
cp.period_end,
cp.period_type
FROM issue_cost_allocations ica
JOIN cost_periods cp ON ica.period_id = cp.id
WHERE ica.issue_id = ?
ORDER BY ica.allocation_date DESC
''', (issue_id,))
rows = cursor.fetchall()
allocations = []
for row in rows:
allocation = {
'id': row[0],
'issue_id': row[1],
'period_id': row[2],
'allocated_amount': float(row[3]),
'allocation_date': row[4],
'transaction_id': row[5],
'period_start': row[6],
'period_end': row[7],
'period_type': row[8]
}
allocations.append(allocation)
return allocations
def get_period_allocations(self, period_id: int) -> List[Dict[str, Any]]:
"""
Get all allocations for a specific period.
Args:
period_id: ID of the period
Returns:
List of allocation records
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT
ica.id,
ica.issue_id,
ica.allocated_amount,
ica.allocation_date,
ica.transaction_id
FROM issue_cost_allocations ica
WHERE ica.period_id = ?
ORDER BY ica.issue_id
''', (period_id,))
rows = cursor.fetchall()
allocations = []
for row in rows:
allocation = {
'id': row[0],
'issue_id': row[1],
'allocated_amount': float(row[2]),
'allocation_date': row[3],
'transaction_id': row[4]
}
allocations.append(allocation)
return allocations
def reverse_allocation(self, allocation_id: int) -> bool:
"""
Reverse a cost allocation (for corrections).
Args:
allocation_id: ID of the allocation to reverse
Returns:
True if successful, False otherwise
"""
try:
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Get allocation details
cursor.execute('''
SELECT issue_id, period_id, allocated_amount, transaction_id
FROM issue_cost_allocations
WHERE id = ?
''', (allocation_id,))
result = cursor.fetchone()
if not result:
return False
issue_id, period_id, amount, transaction_id = result
# Create reversal transaction using adjustment type (allows negative amounts)
with self.finance_models.get_connection() as conn2:
cursor2 = conn2.cursor()
cursor2.execute('''
INSERT INTO cost_transactions
(period_id, transaction_type, amount_eur, issue_id,
transaction_date, description)
VALUES (?, 'adjustment', ?, ?, ?, ?)
''', (period_id, float(-amount), issue_id, date.today().isoformat(), f"Reversal of allocation #{allocation_id}"))
reversal_transaction_id = cursor2.lastrowid
# Only delete if reversal transaction was created successfully
if reversal_transaction_id:
cursor.execute('DELETE FROM issue_cost_allocations WHERE id = ?', (allocation_id,))
return cursor.rowcount > 0
else:
return False
except Exception as e:
# Log the exception for debugging in tests
print(f"Reversal failed with exception: {e}")
return False
def _get_period(self, period_id: int) -> Optional[Period]:
"""Get period details by ID."""
period_data = self.period_manager.get_period_by_id(period_id)
if not period_data:
return None
# Convert dict to Period object
return Period(
id=period_data['id'],
period_start=datetime.strptime(period_data['period_start'], '%Y-%m-%d').date() if period_data['period_start'] else None,
period_end=datetime.strptime(period_data['period_end'], '%Y-%m-%d').date() if period_data['period_end'] else None,
period_type=period_data['period_type'],
status=period_data['status'],
total_costs=Decimal(str(period_data['total_costs'])),
active_issues_count=period_data['active_issues_count'],
cost_per_issue=Decimal(str(period_data['cost_per_issue'])),
loss_carried_forward=Decimal(str(period_data['loss_carried_forward'] or 0))
)
def _update_period_status(self, period_id: int, status: PeriodStatus):
"""Update period status."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE cost_periods SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(status.value, period_id)
)
def _calculate_period_total_costs(self, period: Period) -> Decimal:
"""Calculate total costs for a period including carried forward amounts."""
calculations = self.cost_manager.calculate_period_costs(
period.period_start, period.period_end
)
period_costs = calculations['total_period']
carried_forward = period.loss_carried_forward or Decimal('0.00')
return Decimal(str(period_costs)) + carried_forward
def _get_active_issues_for_period(self, period: Period) -> List[int]:
"""Get list of active issue IDs for a period."""
activities = self.activity_tracker.get_activities_by_period(
period.id,
activity_types=[
ActivityType.CREATED,
ActivityType.MODIFIED,
ActivityType.COMMENTED,
ActivityType.STATUS_CHANGED
]
)
# Get unique issue IDs
active_issues = list(set(activity.issue_id for activity in activities))
return active_issues
def _get_or_create_next_period(self, current_period: Period) -> Optional[int]:
"""Get or create the next period for loss carry forward."""
# For now, return None - next period creation will be handled separately
# This is a placeholder for future automatic period creation
return None
def _carry_forward_loss(self, from_period_id: int, to_period_id: int, amount: Decimal):
"""Carry forward loss to next period."""
# Update the destination period's carried forward amount
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE cost_periods
SET loss_carried_forward = loss_carried_forward + ?
WHERE id = ?
''', (float(amount), to_period_id))
# Create audit transaction
self.transaction_manager.create_loss_forward_transaction(
from_period_id=from_period_id,
to_period_id=to_period_id,
amount=amount,
transaction_date=date.today(),
description=f"Loss carried forward from period {from_period_id}"
)
def _create_issue_allocation(
self, issue_id: int, period_id: int, amount: Decimal, allocation_date: date
) -> Optional[int]:
"""Create an issue cost allocation record."""
try:
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (?, ?, ?, ?)
''', (issue_id, period_id, float(amount), allocation_date.isoformat() if hasattr(allocation_date, 'isoformat') else allocation_date))
return cursor.lastrowid
except sqlite3.IntegrityError:
# Allocation already exists for this issue/period
return None
def _update_allocation_transaction_id(self, allocation_id: int, transaction_id: int):
"""Link allocation to its audit transaction."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE issue_cost_allocations
SET transaction_id = ?
WHERE id = ?
''', (transaction_id, allocation_id))
def _update_period_totals(
self,
period_id: int,
total_costs: Decimal,
active_issues_count: int,
cost_per_issue: Decimal,
loss_carried_forward: Decimal
):
"""Update period summary statistics."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE cost_periods
SET total_costs = ?,
active_issues_count = ?,
cost_per_issue = ?,
loss_carried_forward = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (float(total_costs), active_issues_count, float(cost_per_issue), float(loss_carried_forward), period_id))

View File

@@ -1,507 +0,0 @@
"""
Single Command Day Wrap-Up functionality.
This module provides a comprehensive end-of-day command that consolidates
daily work summaries, activity tracking, cost distribution, and reporting
into a single convenient command.
"""
import click
from datetime import datetime, date, timedelta
from typing import Optional, Dict, Any, List
from decimal import Decimal
from tabulate import tabulate
import json
from .worktime_tracker import WorktimeTracker
from ..issues.activity_tracker import IssueActivityTracker
from .session_tracker import SessionCostTracker
class DayWrapUpService:
"""Service for comprehensive day wrap-up functionality."""
def __init__(self, db_path: str = "markitect.db"):
"""Initialize the day wrap-up service."""
self.db_path = db_path
self.worktime_tracker = WorktimeTracker(db_path)
self.activity_tracker = IssueActivityTracker(db_path)
self.session_tracker = SessionCostTracker(db_path)
def generate_daily_summary(self, target_date: date) -> Dict[str, Any]:
"""
Generate comprehensive daily summary.
Args:
target_date: Date to generate summary for
Returns:
Dictionary containing complete daily summary
"""
summary = {
'date': target_date,
'worktime': self._get_worktime_summary(target_date),
'activities': self._get_activity_summary(target_date),
'costs': self._get_cost_summary(target_date),
'recommendations': []
}
# Add recommendations based on data
summary['recommendations'] = self._generate_recommendations(summary)
return summary
def _get_worktime_summary(self, target_date: date) -> Dict[str, Any]:
"""Get worktime summary for the date."""
daily_summary = self.worktime_tracker.get_daily_summary(target_date)
if not daily_summary:
return {
'total_minutes': 0,
'total_hours': 0.0,
'issues_worked': 0,
'entries': [],
'cost_allocated': None,
'cost_per_minute': None
}
# Get issue breakdown
issue_breakdown = {}
for entry in daily_summary.entries:
if entry.issue_id not in issue_breakdown:
issue_breakdown[entry.issue_id] = {
'minutes': 0,
'entries': 0,
'descriptions': []
}
issue_breakdown[entry.issue_id]['minutes'] += entry.duration_minutes
issue_breakdown[entry.issue_id]['entries'] += 1
if entry.description:
issue_breakdown[entry.issue_id]['descriptions'].append(entry.description)
return {
'total_minutes': daily_summary.total_minutes,
'total_hours': daily_summary.total_minutes / 60,
'issues_worked': daily_summary.issue_count,
'entries': len(daily_summary.entries),
'issue_breakdown': issue_breakdown,
'cost_allocated': float(daily_summary.total_cost_allocated) if daily_summary.total_cost_allocated else None,
'cost_per_minute': float(daily_summary.cost_per_minute) if daily_summary.cost_per_minute else None
}
def _get_activity_summary(self, target_date: date) -> Dict[str, Any]:
"""Get activity summary for the date."""
summary = self.activity_tracker.get_activity_summary(
start_date=target_date,
end_date=target_date
)
# Get detailed activities for the day
activities = []
if summary['total_activities'] > 0:
# Get activities by checking each issue that had activity
with self.activity_tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT issue_id, activity_type, activity_details, created_at
FROM issue_activity_log
WHERE activity_date = ?
ORDER BY created_at DESC
''', (target_date.isoformat() if hasattr(target_date, 'isoformat') else target_date,))
for row in cursor.fetchall():
activities.append({
'issue_id': row[0],
'activity_type': row[1],
'details': row[2],
'created_at': row[3]
})
return {
'total_activities': summary['total_activities'],
'unique_issues': summary['unique_issues'],
'activities_by_type': summary['activities_by_type'],
'activities': activities
}
def _get_cost_summary(self, target_date: date) -> Dict[str, Any]:
"""Get cost summary for the date."""
# Get session costs from cost notes for the day
cost_summary = self.session_tracker.get_issue_costs_summary()
# Filter for today's costs (this is approximate - would need better filtering in real implementation)
daily_costs = 0.0
issue_costs = {}
# Get worktime cost distribution if available
with self.worktime_tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT issue_id, cost_allocated
FROM worktime_cost_distributions
WHERE work_date = ?
''', (target_date.isoformat() if hasattr(target_date, 'isoformat') else target_date,))
for row in cursor.fetchall():
issue_id, cost = row
issue_costs[issue_id] = cost
daily_costs += cost
return {
'daily_total': daily_costs,
'issue_costs': issue_costs,
'has_cost_allocation': len(issue_costs) > 0
}
def _generate_recommendations(self, summary: Dict[str, Any]) -> List[str]:
"""Generate recommendations based on daily summary."""
recommendations = []
# Worktime recommendations
worktime = summary['worktime']
if worktime['total_minutes'] == 0:
recommendations.append("⚠️ No worktime logged for today. Consider logging time spent on issues.")
elif worktime['total_hours'] < 4:
recommendations.append("⏰ Low worktime logged today. Is this accurate or should more time be added?")
elif worktime['total_hours'] > 10:
recommendations.append("🔥 High worktime logged today. Make sure to take breaks!")
# Activity recommendations
activities = summary['activities']
if activities['total_activities'] == 0:
recommendations.append("📝 No issue activities logged today. Consider what issues you worked on.")
elif activities['unique_issues'] > 5:
recommendations.append("🤹 Many issues worked on today. Consider focusing on fewer issues for better productivity.")
# Cost recommendations
costs = summary['costs']
if worktime['total_minutes'] > 0 and not costs['has_cost_allocation']:
recommendations.append("💰 Time logged but no costs distributed. Run cost distribution to allocate daily expenses.")
return recommendations
def perform_auto_estimation(self, target_date: date, total_hours: float = 8.0) -> Dict[str, Any]:
"""
Perform automatic worktime estimation if no time is logged.
Args:
target_date: Date to estimate for
total_hours: Total hours to distribute
Returns:
Estimation results
"""
# Check if any time is already logged
summary = self.worktime_tracker.get_daily_summary(target_date)
if summary and summary.total_minutes > 0:
return {
'estimated': False,
'reason': 'Time already logged for this date',
'existing_minutes': summary.total_minutes
}
# Get active issues for the day from activity log
with self.activity_tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT DISTINCT issue_id
FROM issue_activity_log
WHERE activity_date = ?
''', (target_date.isoformat() if hasattr(target_date, 'isoformat') else target_date,))
active_issues = [row[0] for row in cursor.fetchall()]
if not active_issues:
return {
'estimated': False,
'reason': 'No active issues found for this date',
'active_issues': []
}
# Perform estimation
estimation_result = self.worktime_tracker.estimate_daily_worktime(
work_date=target_date,
total_hours=total_hours,
issues=active_issues,
distribution_method="activity_based"
)
return {
'estimated': True,
'estimation_result': estimation_result
}
def distribute_daily_costs(self, target_date: date, daily_cost: Decimal) -> Dict[str, Any]:
"""
Distribute daily costs based on worktime allocation.
Args:
target_date: Date to distribute costs for
daily_cost: Total daily cost to distribute
Returns:
Distribution results
"""
return self.worktime_tracker.distribute_daily_costs(
work_date=target_date,
total_daily_cost=daily_cost
)
@click.group()
def wrapup():
"""Day wrap-up commands for end-of-day summaries and automation."""
pass
@wrapup.command()
@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']), required=False)
@click.option('--auto-estimate', is_flag=True,
help='Automatically estimate worktime if none logged')
@click.option('--estimate-hours', type=float, default=8.0,
help='Hours to estimate (used with --auto-estimate)')
@click.option('--distribute-cost', type=float,
help='Daily cost to distribute (€)')
@click.option('--format', 'output_format', type=click.Choice(['summary', 'detailed', 'json']),
default='summary', help='Output format')
def daily(date: Optional[datetime], auto_estimate: bool, estimate_hours: float,
distribute_cost: Optional[float], output_format: str):
"""Generate comprehensive daily wrap-up summary.
If no date is provided, uses today's date.
"""
from datetime import date as date_module
target_date = date.date() if date else date_module.today()
service = DayWrapUpService()
try:
# Auto-estimate worktime if requested
if auto_estimate:
click.echo(f"🤖 Auto-estimating worktime for {target_date}...")
estimation = service.perform_auto_estimation(target_date, estimate_hours)
if estimation['estimated']:
result = estimation['estimation_result']
click.echo(f"✅ Estimated {estimate_hours}h across {result['issues_count']} issues")
else:
click.echo(f" {estimation['reason']}")
# Distribute costs if requested
if distribute_cost:
click.echo(f"💰 Distributing €{distribute_cost:.2f} for {target_date}...")
distribution = service.distribute_daily_costs(target_date, Decimal(str(distribute_cost)))
if 'message' in distribution:
click.echo(f"⚠️ {distribution['message']}")
else:
click.echo(f"✅ Distributed €{distribute_cost:.2f} across {distribution['issues_count']} issues")
# Generate summary
summary = service.generate_daily_summary(target_date)
if output_format == 'json':
# Convert date to string for JSON serialization
summary['date'] = summary['date'].isoformat()
click.echo(json.dumps(summary, indent=2))
return
# Display summary
_display_daily_summary(summary, output_format)
except Exception as e:
click.echo(f"❌ Error generating daily wrap-up: {e}", err=True)
raise click.Abort()
@wrapup.command()
@click.argument('start_date', type=click.DateTime(formats=['%Y-%m-%d']))
@click.argument('end_date', type=click.DateTime(formats=['%Y-%m-%d']))
@click.option('--format', 'output_format', type=click.Choice(['summary', 'json']),
default='summary', help='Output format')
def period(start_date: datetime, end_date: datetime, output_format: str):
"""Generate wrap-up summary for a date range."""
service = DayWrapUpService()
try:
# Get worktime report for period
worktime_report = service.worktime_tracker.get_worktime_report(
start_date=start_date.date(),
end_date=end_date.date()
)
# Get activity summary for period
activity_summary = service.activity_tracker.get_activity_summary(
start_date=start_date.date(),
end_date=end_date.date()
)
period_summary = {
'period': f"{start_date.date()} to {end_date.date()}",
'worktime': worktime_report,
'activities': activity_summary
}
if output_format == 'json':
click.echo(json.dumps(period_summary, indent=2))
else:
_display_period_summary(period_summary)
except Exception as e:
click.echo(f"❌ Error generating period wrap-up: {e}", err=True)
raise click.Abort()
@wrapup.command()
@click.argument('date', type=click.DateTime(formats=['%Y-%m-%d']), required=False)
@click.option('--hours', type=float, default=8.0, help='Total hours worked')
@click.option('--method', type=click.Choice(['equal', 'activity_based']),
default='activity_based', help='Estimation method')
def estimate(date: Optional[datetime], hours: float, method: str):
"""Estimate and log worktime for a day based on issue activities."""
from datetime import date as date_module
target_date = date.date() if date else date_module.today()
service = DayWrapUpService()
try:
estimation = service.perform_auto_estimation(target_date, hours)
if not estimation['estimated']:
click.echo(f"⚠️ {estimation['reason']}")
return
result = estimation['estimation_result']
click.echo(f"✅ Estimated worktime for {target_date}")
click.echo(f"Total Hours: {hours}h")
click.echo(f"Distribution Method: {method}")
click.echo(f"Issues: {result['issues_count']}")
# Show breakdown
headers = ['Issue', 'Time', 'Percentage']
rows = []
total_minutes = result['total_minutes']
for issue_id, minutes in result['issue_estimates'].items():
percentage = (minutes / total_minutes) * 100
hours_mins = f"{minutes//60}h{minutes%60}m" if minutes >= 60 else f"{minutes}m"
rows.append([f"#{issue_id}", hours_mins, f"{percentage:.1f}%"])
click.echo("\nEstimated Time Distribution:")
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
except Exception as e:
click.echo(f"❌ Error estimating worktime: {e}", err=True)
raise click.Abort()
def _display_daily_summary(summary: Dict[str, Any], format_type: str):
"""Display daily summary in formatted output."""
date_str = summary['date']
worktime = summary['worktime']
activities = summary['activities']
costs = summary['costs']
recommendations = summary['recommendations']
click.echo(f"\n📊 Daily Wrap-Up for {date_str}")
click.echo("=" * 50)
# Worktime section
click.echo(f"\n⏰ WORKTIME SUMMARY")
if worktime['total_minutes'] > 0:
hours = int(worktime['total_hours'])
minutes = int((worktime['total_hours'] - hours) * 60)
click.echo(f"Total Time: {hours}h {minutes}m ({worktime['total_minutes']} minutes)")
click.echo(f"Issues Worked: {worktime['issues_worked']}")
click.echo(f"Time Entries: {worktime['entries']}")
if worktime['cost_allocated']:
click.echo(f"Cost Allocated: €{worktime['cost_allocated']:.2f}")
click.echo(f"Cost per Minute: €{worktime['cost_per_minute']:.4f}")
if format_type == 'detailed' and worktime['issue_breakdown']:
click.echo("\nTime by Issue:")
headers = ['Issue', 'Time', 'Entries', 'Percentage']
rows = []
for issue_id, data in worktime['issue_breakdown'].items():
percentage = (data['minutes'] / worktime['total_minutes']) * 100
time_str = f"{data['minutes']//60}h{data['minutes']%60}m" if data['minutes'] >= 60 else f"{data['minutes']}m"
rows.append([f"#{issue_id}", time_str, data['entries'], f"{percentage:.1f}%"])
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
else:
click.echo("No worktime logged today")
# Activities section
click.echo(f"\n📝 ACTIVITIES SUMMARY")
if activities['total_activities'] > 0:
click.echo(f"Total Activities: {activities['total_activities']}")
click.echo(f"Issues with Activity: {activities['unique_issues']}")
if activities['activities_by_type']:
click.echo("\nActivity Breakdown:")
for activity_type, count in activities['activities_by_type'].items():
click.echo(f" {activity_type.title()}: {count}")
if format_type == 'detailed' and activities['activities']:
click.echo("\nRecent Activities:")
for activity in activities['activities'][:5]: # Show last 5
details = f" - {activity['details']}" if activity['details'] else ""
click.echo(f" #{activity['issue_id']}: {activity['activity_type']}{details}")
else:
click.echo("No activities logged today")
# Costs section
click.echo(f"\n💰 COST SUMMARY")
if costs['has_cost_allocation']:
click.echo(f"Daily Total: €{costs['daily_total']:.2f}")
click.echo("Cost Allocation:")
for issue_id, cost in costs['issue_costs'].items():
click.echo(f" Issue #{issue_id}: €{cost:.2f}")
else:
click.echo("No cost allocation for today")
# Recommendations section
if recommendations:
click.echo(f"\n💡 RECOMMENDATIONS")
for rec in recommendations:
click.echo(f" {rec}")
click.echo()
def _display_period_summary(summary: Dict[str, Any]):
"""Display period summary in formatted output."""
click.echo(f"\n📈 Period Wrap-Up: {summary['period']}")
click.echo("=" * 60)
worktime = summary['worktime']
activities = summary['activities']
# Worktime summary
click.echo(f"\n⏰ WORKTIME OVERVIEW")
click.echo(f"Total Time: {worktime['total_time']['hours']}h {worktime['total_time']['minutes']}m")
click.echo(f"Total Entries: {worktime['total_entries']}")
click.echo(f"Unique Issues: {worktime['unique_issues']}")
click.echo(f"Unique Dates: {worktime['unique_dates']}")
if worktime['unique_dates'] > 0:
avg_minutes = worktime['average_minutes_per_day']
avg_hours = int(avg_minutes // 60)
avg_mins = int(avg_minutes % 60)
click.echo(f"Average per Day: {avg_hours}h {avg_mins}m")
# Activities summary
click.echo(f"\n📝 ACTIVITIES OVERVIEW")
click.echo(f"Total Activities: {activities['total_activities']}")
click.echo(f"Unique Issues: {activities['unique_issues']}")
if activities['activities_by_type']:
click.echo("\nActivity Types:")
for activity_type, count in activities['activities_by_type'].items():
percentage = (count / activities['total_activities']) * 100
click.echo(f" {activity_type.title()}: {count} ({percentage:.1f}%)")
click.echo()
if __name__ == '__main__':
wrapup()

View File

@@ -1,7 +0,0 @@
"""
Issue management module for MarkiTect.
Provides unified CLI interface for issue management with pluggable backend support.
"""
__version__ = "1.0.0"

View File

@@ -1,271 +0,0 @@
"""
CLI commands for issue activity tracking.
This module provides command-line interface for logging, viewing, and managing
issue activities for cost allocation and project management purposes.
"""
import click
from datetime import datetime, date
from typing import List, Optional
from tabulate import tabulate
from markitect.issues.activity_tracker import IssueActivityTracker, ActivityType, IssueActivity
@click.group()
def activity():
"""Issue activity tracking commands."""
pass
@activity.command()
@click.argument('issue_id', type=int)
@click.argument('activity_type', type=click.Choice([at.value for at in ActivityType]))
@click.option('--date', '-d', type=click.DateTime(formats=['%Y-%m-%d']),
help='Activity date (defaults to today)')
@click.option('--details', '-m', help='Activity details/message')
@click.option('--period-id', type=int, help='Cost period ID for allocation')
def log(issue_id: int, activity_type: str, date: Optional[datetime],
details: Optional[str], period_id: Optional[int]):
"""Log an activity for an issue."""
tracker = IssueActivityTracker()
activity_date = date.date() if date else None
activity_enum = ActivityType(activity_type)
try:
activity_id = tracker.log_activity(
issue_id=issue_id,
activity_type=activity_enum,
activity_date=activity_date,
activity_details=details,
period_id=period_id
)
click.echo(f"✅ Logged {activity_type} activity for issue #{issue_id} (ID: {activity_id})")
if details:
click.echo(f" Details: {details}")
except Exception as e:
click.echo(f"❌ Error logging activity: {e}", err=True)
raise click.Abort()
@activity.command()
@click.argument('issue_id', type=int)
@click.option('--limit', '-l', type=int, default=20, help='Maximum number of activities to show')
@click.option('--offset', type=int, default=0, help='Number of activities to skip')
@click.option('--format', 'output_format', type=click.Choice(['table', 'json']),
default='table', help='Output format')
def show(issue_id: int, limit: int, offset: int, output_format: str):
"""Show activities for a specific issue."""
tracker = IssueActivityTracker()
try:
activities = tracker.get_issue_activities(issue_id, limit=limit, offset=offset)
if not activities:
click.echo(f"📝 No activities found for issue #{issue_id}")
return
if output_format == 'json':
import json
activity_data = [activity.to_dict() for activity in activities]
click.echo(json.dumps(activity_data, indent=2))
else:
# Table format
click.echo(f"\n📋 Activities for Issue #{issue_id}\n")
headers = ['ID', 'Type', 'Date', 'Period', 'Details', 'Logged']
rows = []
for activity in activities:
rows.append([
activity.id,
activity.activity_type_display,
activity.formatted_date,
activity.period_id or 'N/A',
activity.truncated_details,
activity.formatted_datetime
])
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
if len(activities) == limit:
click.echo(f"\n💡 Showing {limit} most recent activities. Use --limit and --offset for pagination.")
except Exception as e:
click.echo(f"❌ Error retrieving activities: {e}", err=True)
raise click.Abort()
@activity.command()
@click.option('--period-id', type=int, help='Filter by cost period ID')
@click.option('--activity-type', type=click.Choice([at.value for at in ActivityType]),
multiple=True, help='Filter by activity types (can specify multiple)')
@click.option('--format', 'output_format', type=click.Choice(['table', 'json']),
default='table', help='Output format')
def list(period_id: Optional[int], activity_type: List[str], output_format: str):
"""List activities across issues."""
tracker = IssueActivityTracker()
try:
if period_id:
activity_types = [ActivityType(at) for at in activity_type] if activity_type else None
activities = tracker.get_activities_by_period(period_id, activity_types)
title = f"Activities for Period #{period_id}"
else:
# For now, show recent activities across all issues (could be enhanced)
click.echo("❌ Currently only period-based listing is supported. Use --period-id option.")
return
if not activities:
click.echo(f"📝 No activities found for the specified criteria")
return
if output_format == 'json':
import json
activity_data = [activity.to_dict() for activity in activities]
click.echo(json.dumps(activity_data, indent=2))
else:
# Table format
click.echo(f"\n📊 {title}\n")
headers = ['ID', 'Issue', 'Type', 'Date', 'Details']
rows = []
for activity in activities:
rows.append([
activity.id,
f"#{activity.issue_id}",
activity.activity_type.value.title(),
activity.activity_date.strftime('%Y-%m-%d') if activity.activity_date else 'N/A',
(activity.activity_details[:50] + '...') if activity.activity_details and len(activity.activity_details) > 50 else (activity.activity_details or '')
])
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
click.echo(f"\n📈 Total: {len(activities)} activities")
except Exception as e:
click.echo(f"❌ Error retrieving activities: {e}", err=True)
raise click.Abort()
@activity.command()
@click.option('--issue-id', type=int, help='Filter by specific issue ID')
@click.option('--start-date', type=click.DateTime(formats=['%Y-%m-%d']),
help='Start date for summary period')
@click.option('--end-date', type=click.DateTime(formats=['%Y-%m-%d']),
help='End date for summary period')
def summary(issue_id: Optional[int], start_date: Optional[datetime],
end_date: Optional[datetime]):
"""Show activity summary statistics."""
tracker = IssueActivityTracker()
try:
summary_data = tracker.get_activity_summary(
issue_id=issue_id,
start_date=start_date.date() if start_date else None,
end_date=end_date.date() if end_date else None
)
click.echo("\n📊 Issue Activity Summary\n")
# Basic stats
click.echo(f"Total Activities: {summary_data['total_activities']}")
click.echo(f"Unique Issues: {summary_data['unique_issues']}")
# Date range
date_range = summary_data['date_range']
if date_range['start'] and date_range['end']:
click.echo(f"Date Range: {date_range['start']} to {date_range['end']}")
elif date_range['start']:
click.echo(f"Since: {date_range['start']}")
# Activity breakdown
if summary_data['activities_by_type']:
click.echo("\nActivity Breakdown:")
for activity_type, count in summary_data['activities_by_type'].items():
percentage = (count / summary_data['total_activities']) * 100
click.echo(f" {activity_type.title()}: {count} ({percentage:.1f}%)")
# Filters applied
filters = summary_data['filters']
applied_filters = []
if filters['issue_id']:
applied_filters.append(f"Issue #{filters['issue_id']}")
if filters['start_date']:
applied_filters.append(f"From {filters['start_date']}")
if filters['end_date']:
applied_filters.append(f"Until {filters['end_date']}")
if applied_filters:
click.echo(f"\nFilters Applied: {', '.join(applied_filters)}")
except Exception as e:
click.echo(f"❌ Error generating summary: {e}", err=True)
raise click.Abort()
@activity.command()
@click.argument('activity_id', type=int)
@click.confirmation_option(prompt='Are you sure you want to delete this activity?')
def delete(activity_id: int):
"""Delete an activity record."""
tracker = IssueActivityTracker()
try:
if tracker.delete_activity(activity_id):
click.echo(f"✅ Deleted activity #{activity_id}")
else:
click.echo(f"❌ Activity #{activity_id} not found")
raise click.Abort()
except Exception as e:
click.echo(f"❌ Error deleting activity: {e}", err=True)
raise click.Abort()
@activity.command()
@click.argument('file_path', type=click.Path(exists=True))
@click.option('--format', 'input_format', type=click.Choice(['json', 'csv']),
default='json', help='Input file format')
def import_activities(file_path: str, input_format: str):
"""Import activities from a file."""
tracker = IssueActivityTracker()
try:
if input_format == 'json':
import json
with open(file_path, 'r') as f:
activities = json.load(f)
elif input_format == 'csv':
import csv
activities = []
with open(file_path, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
activity = {
'issue_id': int(row['issue_id']),
'activity_type': row['activity_type'],
'activity_date': datetime.strptime(row['activity_date'], '%Y-%m-%d').date() if row.get('activity_date') else None,
'activity_details': row.get('activity_details'),
'period_id': int(row['period_id']) if row.get('period_id') else None
}
activities.append(activity)
activity_ids = tracker.bulk_log_activities(activities)
click.echo(f"✅ Successfully imported {len(activity_ids)} activities")
except Exception as e:
click.echo(f"❌ Error importing activities: {e}", err=True)
raise click.Abort()
if __name__ == '__main__':
activity()

View File

@@ -1,417 +0,0 @@
"""
Issue Activity Tracking Service
This module provides comprehensive issue activity tracking functionality,
building on the existing database infrastructure to log and retrieve
issue activities for cost allocation and project management.
"""
import sqlite3
from datetime import datetime, date
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, asdict
from enum import Enum
from markitect.finance.models import FinanceModels
class ActivityType(Enum):
"""Enumeration of supported issue activity types."""
CREATED = "created"
MODIFIED = "modified"
CLOSED = "closed"
REOPENED = "reopened"
COMMENTED = "commented"
STATUS_CHANGED = "status_changed"
@dataclass
class IssueActivity:
"""Data class representing an issue activity record with convenient methods."""
id: Optional[int] = None
issue_id: int = None
activity_type: ActivityType = None
activity_date: date = None
period_id: Optional[int] = None
activity_details: Optional[str] = None
created_at: Optional[datetime] = None
@property
def activity_type_value(self) -> str:
"""Get the string value of the activity type."""
return self.activity_type.value if self.activity_type else ''
@property
def activity_type_display(self) -> str:
"""Get the display-friendly activity type."""
return self.activity_type_value.replace('_', ' ').title()
@property
def formatted_date(self) -> str:
"""Get formatted activity date string."""
return self.activity_date.strftime('%Y-%m-%d') if self.activity_date else 'N/A'
@property
def formatted_datetime(self) -> str:
"""Get formatted created datetime string."""
return self.created_at.strftime('%Y-%m-%d %H:%M') if self.created_at else 'N/A'
@property
def truncated_details(self) -> str:
"""Get truncated activity details for display (max 40 chars)."""
if not self.activity_details:
return ''
return (self.activity_details[:40] + '...') if len(self.activity_details) > 40 else self.activity_details
def contains_keyword(self, keyword: str, case_sensitive: bool = False) -> bool:
"""Check if activity contains a keyword in type or details."""
search_text = f"{self.activity_type_value} {self.activity_details or ''}".strip()
if not case_sensitive:
search_text = search_text.lower()
keyword = keyword.lower()
return keyword in search_text
def has_implementation_activity(self) -> bool:
"""Check if this activity indicates implementation work."""
return (self.contains_keyword('implement') or
self.contains_keyword('code') or
self.contains_keyword('develop'))
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation."""
return {
'id': self.id,
'issue_id': self.issue_id,
'activity_type': self.activity_type_value,
'activity_date': self.activity_date.isoformat() if self.activity_date else None,
'period_id': self.period_id,
'activity_details': self.activity_details,
'created_at': self.created_at.isoformat() if self.created_at else None
}
class IssueActivityTracker:
"""
Service for tracking and managing issue activities.
Provides functionality to log issue activities, retrieve activity history,
and generate activity reports for cost allocation and project management.
"""
def __init__(self, db_path: str = "markitect.db"):
"""
Initialize the issue activity tracker.
Args:
db_path: Path to the SQLite database file
"""
self.db_path = db_path
self.finance_models = FinanceModels(db_path)
self._ensure_schema()
def _ensure_schema(self):
"""Ensure the database schema is properly initialized."""
self.finance_models.initialize_finance_schema()
def log_activity(
self,
issue_id: int,
activity_type: ActivityType,
activity_date: Optional[date] = None,
activity_details: Optional[str] = None,
period_id: Optional[int] = None
) -> int:
"""
Log an issue activity.
Args:
issue_id: ID of the issue
activity_type: Type of activity performed
activity_date: Date when activity occurred (defaults to today)
activity_details: Additional details about the activity
period_id: Optional period ID for cost allocation
Returns:
ID of the created activity record
Raises:
sqlite3.Error: If database operation fails
"""
if activity_date is None:
activity_date = date.today()
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# If period_id is not provided, try to get the current period
if period_id is None:
cursor.execute('''
SELECT id FROM cost_periods
WHERE ? BETWEEN period_start AND period_end
ORDER BY created_at DESC LIMIT 1
''', (activity_date.isoformat(),))
result = cursor.fetchone()
if result:
period_id = result[0]
cursor.execute('''
INSERT INTO issue_activity_log
(issue_id, activity_type, activity_date, period_id, activity_details)
VALUES (?, ?, ?, ?, ?)
''', (issue_id, activity_type.value, activity_date.isoformat(), period_id, activity_details))
return cursor.lastrowid
def get_issue_activities(
self,
issue_id: int,
limit: Optional[int] = None,
offset: int = 0
) -> List[IssueActivity]:
"""
Get activities for a specific issue.
Args:
issue_id: ID of the issue
limit: Maximum number of activities to return
offset: Number of activities to skip
Returns:
List of issue activities, ordered by activity date (most recent first)
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
query = '''
SELECT id, issue_id, activity_type, activity_date,
period_id, activity_details, created_at
FROM issue_activity_log
WHERE issue_id = ?
ORDER BY activity_date DESC, created_at DESC
'''
params = [issue_id]
if limit:
query += ' LIMIT ? OFFSET ?'
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
activities = []
for row in rows:
activity = IssueActivity(
id=row[0],
issue_id=row[1],
activity_type=ActivityType(row[2]),
activity_date=datetime.strptime(row[3], '%Y-%m-%d').date() if row[3] else None,
period_id=row[4],
activity_details=row[5],
created_at=datetime.fromisoformat(row[6]) if row[6] else None
)
activities.append(activity)
return activities
def get_activities_by_period(
self,
period_id: int,
activity_types: Optional[List[ActivityType]] = None
) -> List[IssueActivity]:
"""
Get all activities within a specific cost period.
Args:
period_id: ID of the cost period
activity_types: Optional list of activity types to filter by
Returns:
List of issue activities within the period
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
query = '''
SELECT id, issue_id, activity_type, activity_date,
period_id, activity_details, created_at
FROM issue_activity_log
WHERE period_id = ?
'''
params = [period_id]
if activity_types:
placeholders = ','.join(['?' for _ in activity_types])
query += f' AND activity_type IN ({placeholders})'
params.extend([at.value for at in activity_types])
query += ' ORDER BY activity_date DESC, created_at DESC'
cursor.execute(query, params)
rows = cursor.fetchall()
activities = []
for row in rows:
activity = IssueActivity(
id=row[0],
issue_id=row[1],
activity_type=ActivityType(row[2]),
activity_date=datetime.strptime(row[3], '%Y-%m-%d').date() if row[3] else None,
period_id=row[4],
activity_details=row[5],
created_at=datetime.fromisoformat(row[6]) if row[6] else None
)
activities.append(activity)
return activities
def get_activity_summary(
self,
issue_id: Optional[int] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None
) -> Dict[str, Any]:
"""
Get activity summary statistics.
Args:
issue_id: Optional issue ID to filter by
start_date: Optional start date filter
end_date: Optional end date filter
Returns:
Dictionary containing activity summary statistics
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Build base query
base_conditions = []
params = []
if issue_id:
base_conditions.append('issue_id = ?')
params.append(issue_id)
if start_date:
base_conditions.append('activity_date >= ?')
params.append(start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date)
if end_date:
base_conditions.append('activity_date <= ?')
params.append(end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date)
where_clause = ' AND '.join(base_conditions) if base_conditions else '1=1'
# Get total activity count
cursor.execute(f'''
SELECT COUNT(*) FROM issue_activity_log WHERE {where_clause}
''', params)
total_activities = cursor.fetchone()[0]
# Get activity count by type
cursor.execute(f'''
SELECT activity_type, COUNT(*)
FROM issue_activity_log
WHERE {where_clause}
GROUP BY activity_type
ORDER BY COUNT(*) DESC
''', params)
activities_by_type = dict(cursor.fetchall())
# Get unique issues count
cursor.execute(f'''
SELECT COUNT(DISTINCT issue_id)
FROM issue_activity_log
WHERE {where_clause}
''', params)
unique_issues = cursor.fetchone()[0]
# Get date range
cursor.execute(f'''
SELECT MIN(activity_date), MAX(activity_date)
FROM issue_activity_log
WHERE {where_clause}
''', params)
date_range = cursor.fetchone()
return {
'total_activities': total_activities,
'activities_by_type': activities_by_type,
'unique_issues': unique_issues,
'date_range': {
'start': date_range[0] if date_range[0] else None,
'end': date_range[1] if date_range[1] else None
},
'filters': {
'issue_id': issue_id,
'start_date': start_date.isoformat() if start_date else None,
'end_date': end_date.isoformat() if end_date else None
}
}
def delete_activity(self, activity_id: int) -> bool:
"""
Delete an activity record.
Args:
activity_id: ID of the activity to delete
Returns:
True if activity was deleted, False if not found
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM issue_activity_log WHERE id = ?', (activity_id,))
return cursor.rowcount > 0
def bulk_log_activities(self, activities: List[Dict[str, Any]]) -> List[int]:
"""
Log multiple activities in a single transaction.
Args:
activities: List of activity dictionaries with keys:
issue_id, activity_type, activity_date (optional),
activity_details (optional), period_id (optional)
Returns:
List of created activity IDs
Raises:
ValueError: If activity data is invalid
sqlite3.Error: If database operation fails
"""
activity_ids = []
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
for activity_data in activities:
if 'issue_id' not in activity_data or 'activity_type' not in activity_data:
raise ValueError("Each activity must have 'issue_id' and 'activity_type'")
issue_id = activity_data['issue_id']
activity_type = ActivityType(activity_data['activity_type'])
activity_date = activity_data.get('activity_date', date.today())
activity_details = activity_data.get('activity_details')
period_id = activity_data.get('period_id')
# Auto-determine period if not provided
if period_id is None:
cursor.execute('''
SELECT id FROM cost_periods
WHERE ? BETWEEN period_start AND period_end
ORDER BY created_at DESC LIMIT 1
''', (activity_date.isoformat() if hasattr(activity_date, 'isoformat') else activity_date,))
result = cursor.fetchone()
if result:
period_id = result[0]
cursor.execute('''
INSERT INTO issue_activity_log
(issue_id, activity_type, activity_date, period_id, activity_details)
VALUES (?, ?, ?, ?, ?)
''', (issue_id, activity_type.value, activity_date.isoformat() if hasattr(activity_date, 'isoformat') else activity_date, period_id, activity_details))
activity_ids.append(cursor.lastrowid)
return activity_ids

View File

@@ -1,109 +0,0 @@
"""
Abstract base class for issue management backends.
This module defines the interface that all issue management backends must implement.
"""
from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
import sys
from pathlib import Path
# Add project root to path so domain module can be imported
project_root = Path(__file__).parent.parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from domain.issues.models import Issue
class IssueBackend(ABC):
"""Abstract base class for issue management backends."""
def __init__(self, config: Dict[str, Any]):
"""Initialize backend with configuration."""
self.config = config
@abstractmethod
def list_issues(self, state: Optional[str] = None) -> List[Issue]:
"""
List issues with optional state filter.
Args:
state: Filter by state ('open', 'closed', 'all', or None for all)
Returns:
List of Issue objects
"""
pass
@abstractmethod
def get_issue(self, issue_id: str) -> Issue:
"""
Get specific issue by ID.
Args:
issue_id: The issue identifier
Returns:
Issue object
Raises:
Exception: If issue not found
"""
pass
@abstractmethod
def create_issue(self, title: str, body: str, **kwargs) -> Issue:
"""
Create new issue.
Args:
title: Issue title
body: Issue body/description
**kwargs: Additional issue properties (labels, assignees, etc.)
Returns:
Created Issue object
"""
pass
@abstractmethod
def add_comment(self, issue_id: str, comment: str) -> Dict[str, Any]:
"""
Add comment to issue.
Args:
issue_id: The issue identifier
comment: Comment text
Returns:
Comment metadata (id, timestamp, etc.)
"""
pass
@abstractmethod
def close_issue(self, issue_id: str) -> Issue:
"""
Close issue.
Args:
issue_id: The issue identifier
Returns:
Updated Issue object with closed state
"""
pass
@abstractmethod
def update_issue(self, issue_id: str, **kwargs) -> Issue:
"""
Update issue properties.
Args:
issue_id: The issue identifier
**kwargs: Properties to update (title, body, state, etc.)
Returns:
Updated Issue object
"""
pass

View File

@@ -1,165 +0,0 @@
"""
CLI commands for issue management.
This module provides Click commands for the unified issue management interface.
"""
import click
from typing import Optional
from .manager import IssuePluginManager
from .exceptions import PluginNotFoundError, ConfigurationError
from cli.presenters.views import IssueView
@click.group()
def issues():
"""Issue management with multiple backend support."""
pass
@issues.command()
@click.option('--state', type=click.Choice(['open', 'closed', 'all']), default='all',
help='Filter issues by state')
@click.option('--backend', help='Override configured backend')
def list(state: str, backend: Optional[str]):
"""List issues from configured backend."""
try:
manager = IssuePluginManager()
backend_instance = manager.get_backend(backend)
issues_list = backend_instance.list_issues(state=state)
if state == 'open':
IssueView.show_open_issues(issues_list)
else:
IssueView.show_list(issues_list, f"Issues ({state})")
except (PluginNotFoundError, ConfigurationError) as e:
click.echo(f"Error: {e}", err=True)
raise click.Abort()
except Exception as e:
click.echo(f"Unexpected error: {e}", err=True)
raise click.Abort()
@issues.command()
@click.argument('issue_id')
@click.option('--backend', help='Override configured backend')
def show(issue_id: str, backend: Optional[str]):
"""Show details of a specific issue."""
try:
manager = IssuePluginManager()
backend_instance = manager.get_backend(backend)
issue = backend_instance.get_issue(issue_id)
# Convert issue to dict for display
issue_data = {
'number': issue.number,
'title': issue.title,
'body': getattr(issue, '_body', ''),
'state': issue.state.value if hasattr(issue.state, 'value') else str(issue.state),
'created_at': issue.created_at,
'updated_at': getattr(issue, 'updated_at', issue.created_at),
'labels': [label.name if hasattr(label, 'name') else str(label) for label in issue.labels],
'assignees': getattr(issue, 'assignees', []) or [],
'assignee': getattr(issue, 'assignee', None),
'milestone': getattr(issue, 'milestone', None),
'html_url': getattr(issue, 'html_url', ''),
'state_label': getattr(issue, 'state_label', issue.state.value if hasattr(issue.state, 'value') else str(issue.state)),
'priority_label': getattr(issue, 'priority_label', 'Normal'),
'type_labels': getattr(issue, 'type_labels', []),
'other_labels': getattr(issue, 'other_labels', []),
'kanban_column': getattr(issue, 'kanban_column', 'To Do')
}
IssueView.show_issue_details(issue_data)
except FileNotFoundError:
click.echo(f"Error: Issue {issue_id} not found", err=True)
raise click.Abort()
except (PluginNotFoundError, ConfigurationError) as e:
click.echo(f"Error: {e}", err=True)
raise click.Abort()
except Exception as e:
click.echo(f"Unexpected error: {e}", err=True)
raise click.Abort()
@issues.command()
@click.argument('title')
@click.argument('body')
@click.option('--backend', help='Override configured backend')
def create(title: str, body: str, backend: Optional[str]):
"""Create a new issue."""
try:
manager = IssuePluginManager()
backend_instance = manager.get_backend(backend)
issue = backend_instance.create_issue(title, body)
# Convert issue to dict for display
result = {
'number': issue.number,
'title': issue.title,
'state': issue.state.value if hasattr(issue.state, 'value') else str(issue.state)
}
IssueView.show_creation_success(result, "issue")
click.echo(f"Issue #{issue.number} created successfully")
except (PluginNotFoundError, ConfigurationError) as e:
click.echo(f"Error: {e}", err=True)
raise click.Abort()
except Exception as e:
click.echo(f"Unexpected error: {e}", err=True)
raise click.Abort()
@issues.command()
@click.argument('issue_id')
@click.argument('comment')
@click.option('--backend', help='Override configured backend')
def comment(issue_id: str, comment: str, backend: Optional[str]):
"""Add a comment to an issue."""
try:
manager = IssuePluginManager()
backend_instance = manager.get_backend(backend)
result = backend_instance.add_comment(issue_id, comment)
click.echo(f"Comment added to issue #{issue_id}")
except ValueError as e:
click.echo(f"Error: {e}", err=True)
raise click.Abort()
except (PluginNotFoundError, ConfigurationError) as e:
click.echo(f"Error: {e}", err=True)
raise click.Abort()
except Exception as e:
click.echo(f"Unexpected error: {e}", err=True)
raise click.Abort()
@issues.command()
@click.argument('issue_id')
@click.option('--backend', help='Override configured backend')
def close(issue_id: str, backend: Optional[str]):
"""Close an issue."""
try:
manager = IssuePluginManager()
backend_instance = manager.get_backend(backend)
issue = backend_instance.close_issue(issue_id)
click.echo(f"Issue #{issue_id} closed successfully")
except FileNotFoundError:
click.echo(f"Error: Issue {issue_id} not found", err=True)
raise click.Abort()
except (PluginNotFoundError, ConfigurationError) as e:
click.echo(f"Error: {e}", err=True)
raise click.Abort()
except Exception as e:
click.echo(f"Unexpected error: {e}", err=True)
raise click.Abort()
# Make issues_group available for import
issues_group = issues

View File

@@ -1,18 +0,0 @@
"""
Exceptions for the issue management module.
"""
class IssuePluginError(Exception):
"""Base exception for issue plugin errors."""
pass
class PluginNotFoundError(IssuePluginError):
"""Raised when a requested plugin is not found."""
pass
class ConfigurationError(IssuePluginError):
"""Raised when there's a configuration error."""
pass

View File

@@ -1,600 +0,0 @@
"""
Single Command Issue Wrap-Up functionality.
This module provides comprehensive issue completion automation including:
- Requirement validation and verification
- Test execution and validation
- Cost note creation and database updates
- Git operations (add, commit with cost notes)
- Comprehensive completion summary
The system automates the entire issue closure workflow in a single command.
"""
import click
import subprocess
import os
import json
from datetime import datetime, date
from typing import Optional, Dict, Any, List
from decimal import Decimal
from pathlib import Path
from tabulate import tabulate
from ..finance.worktime_tracker import WorktimeTracker
from ..finance.session_tracker import SessionCostTracker
from ..finance.cost_manager import CostItemManager
from .activity_tracker import IssueActivityTracker
from .manager import IssuePluginManager
class IssueWrapUpService:
"""Service for comprehensive issue wrap-up functionality."""
def __init__(self, db_path: str = "markitect.db"):
"""Initialize the issue wrap-up service."""
self.db_path = db_path
self.worktime_tracker = WorktimeTracker(db_path)
self.activity_tracker = IssueActivityTracker(db_path)
self.session_tracker = SessionCostTracker(db_path)
self.cost_manager = CostItemManager(db_path)
self.issue_manager = IssuePluginManager()
def wrap_up_issue(self, issue_number: int, force: bool = False) -> Dict[str, Any]:
"""
Perform comprehensive issue wrap-up.
Args:
issue_number: Issue number to wrap up
force: Skip validation checks if True
Returns:
Dictionary containing wrap-up results
"""
wrap_up_results = {
'issue_number': issue_number,
'timestamp': datetime.now(),
'steps': {}
}
# Step 1: Get issue details
click.echo(f"🔍 Retrieving issue #{issue_number} details...")
issue_details = self._get_issue_details(issue_number)
wrap_up_results['issue_details'] = issue_details
wrap_up_results['steps']['issue_retrieval'] = {'success': bool(issue_details)}
if not issue_details and not force:
wrap_up_results['steps']['issue_retrieval']['error'] = "Issue not found"
return wrap_up_results
# Step 2: Review requirements (placeholder - would need issue analysis)
click.echo("📋 Reviewing requirements...")
req_check = self._review_requirements(issue_number, issue_details, force)
wrap_up_results['steps']['requirement_review'] = req_check
# Step 3: Run associated tests
click.echo("🧪 Running associated tests...")
test_results = self._run_issue_tests(issue_number, force)
wrap_up_results['steps']['test_execution'] = test_results
# Step 4: Run full test suite
click.echo("🔬 Running full test suite...")
full_test_results = self._run_full_tests(force)
wrap_up_results['steps']['full_test_execution'] = full_test_results
# Step 5: Calculate and update costs
click.echo("💰 Calculating and updating costs...")
cost_results = self._update_cost_tracking(issue_number, issue_details)
wrap_up_results['steps']['cost_tracking'] = cost_results
# Step 6: Create/update cost note
click.echo("📄 Creating/updating cost note...")
cost_note_results = self._create_cost_note(issue_number, issue_details, cost_results)
wrap_up_results['steps']['cost_note'] = cost_note_results
# Step 7: Git operations
click.echo("📦 Adding and committing changes...")
git_results = self._git_operations(issue_number, issue_details)
wrap_up_results['steps']['git_operations'] = git_results
# Step 8: Close issue
click.echo("🔒 Closing issue...")
closure_results = self._close_issue(issue_number)
wrap_up_results['steps']['issue_closure'] = closure_results
return wrap_up_results
def _get_issue_details(self, issue_number: int) -> Optional[Dict[str, Any]]:
"""Retrieve issue details from the backend."""
try:
backend = self.issue_manager.get_backend()
# This would call the actual backend API
# For now, simulate with basic info
return {
'number': issue_number,
'title': f"Issue #{issue_number}",
'status': 'open',
'description': 'Issue description would be retrieved from backend'
}
except Exception as e:
return None
def _review_requirements(self, issue_number: int, issue_details: Optional[Dict], force: bool) -> Dict[str, Any]:
"""Review that requirements have been met."""
if force:
return {'success': True, 'forced': True}
# This would implement actual requirement checking logic
# For now, check if there are recent activities
activities = self.activity_tracker.get_issue_activities(
issue_id=issue_number,
limit=10
)
has_implementation = any(
activity.has_implementation_activity()
for activity in activities
)
return {
'success': has_implementation or len(activities) > 0,
'activities_count': len(activities),
'has_implementation_activity': has_implementation
}
def _run_issue_tests(self, issue_number: int, force: bool) -> Dict[str, Any]:
"""Run tests associated with the issue."""
test_files = [
f"tests/test_issue_{issue_number}_*.py",
f"tests/test_issue_{issue_number}.py"
]
results = {
'success': True,
'test_files': [],
'output': []
}
for test_pattern in test_files:
# Check if test files exist
test_files_found = list(Path('.').glob(test_pattern))
for test_file in test_files_found:
results['test_files'].append(str(test_file))
try:
if force:
results['output'].append(f"FORCED: Skipping test execution for {test_file}")
continue
# Run the specific test
cmd = ['.venv/bin/python', '-m', 'pytest', str(test_file), '-v']
result = subprocess.run(cmd, capture_output=True, text=True, cwd='.')
results['output'].append({
'file': str(test_file),
'returncode': result.returncode,
'stdout': result.stdout,
'stderr': result.stderr
})
if result.returncode != 0:
results['success'] = False
except Exception as e:
results['success'] = False
results['output'].append({
'file': str(test_file),
'error': str(e)
})
if not results['test_files']:
results['output'].append(f"No specific test files found for issue #{issue_number}")
return results
def _run_full_tests(self, force: bool) -> Dict[str, Any]:
"""Run the full test suite to ensure no regressions."""
if force:
return {
'success': True,
'forced': True,
'output': 'FORCED: Skipped full test suite execution'
}
try:
# Try to determine the test command from Makefile or common patterns
test_commands = [
['make', 'test'],
['.venv/bin/python', '-m', 'pytest', '-v'],
['python', '-m', 'pytest', '-v'],
['pytest', '-v']
]
for cmd in test_commands:
try:
result = subprocess.run(cmd, capture_output=True, text=True, cwd='.', timeout=300)
return {
'success': result.returncode == 0,
'command': ' '.join(cmd),
'returncode': result.returncode,
'stdout': result.stdout,
'stderr': result.stderr
}
except (subprocess.TimeoutExpired, FileNotFoundError):
continue
return {
'success': False,
'error': 'No suitable test command found'
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _update_cost_tracking(self, issue_number: int, issue_details: Optional[Dict]) -> Dict[str, Any]:
"""Calculate and register time and cost data in database."""
try:
# Get activity data
activities = self.activity_tracker.get_issue_activities(issue_id=issue_number)
# Get session cost data - method may not exist
session_costs = []
try:
if hasattr(self.session_tracker, 'get_issue_costs'):
session_costs = self.session_tracker.get_issue_costs(issue_number)
elif hasattr(self.session_tracker, 'get_costs_for_issue'):
session_costs = self.session_tracker.get_costs_for_issue(issue_number)
except Exception:
# If session cost tracking fails, continue with empty list
session_costs = []
# Try to get worktime data - method name may vary
total_minutes = 0
try:
# Try different possible methods for getting worktime data
if hasattr(self.worktime_tracker, 'get_issue_summary'):
worktime_summary = self.worktime_tracker.get_issue_summary(issue_number)
total_minutes = worktime_summary.get('total_minutes', 0) if worktime_summary else 0
elif hasattr(self.worktime_tracker, 'get_issue_worktime'):
worktime_data = self.worktime_tracker.get_issue_worktime(issue_number)
total_minutes = worktime_data.get('total_minutes', 0) if worktime_data else 0
# If no specific method available, try to calculate from entries
elif hasattr(self.worktime_tracker, 'get_entries'):
entries = self.worktime_tracker.get_entries()
total_minutes = sum(
entry.duration_minutes for entry in entries
if hasattr(entry, 'issue_id') and entry.issue_id == issue_number
)
except Exception:
# If worktime tracking fails, continue with 0
total_minutes = 0
# Calculate totals
total_cost = sum(cost.get('cost_eur', 0) for cost in session_costs)
cost_data = {
'issue_number': issue_number,
'total_minutes': total_minutes,
'total_hours': total_minutes / 60 if total_minutes else 0,
'total_cost_eur': total_cost,
'activity_count': len(activities),
'session_count': len(session_costs)
}
# This would register in a centralized cost tracking system
# For now, just return the calculated data
return {
'success': True,
'cost_data': cost_data
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _create_cost_note(self, issue_number: int, issue_details: Optional[Dict], cost_results: Dict) -> Dict[str, Any]:
"""Create or update cost note for the issue."""
try:
cost_data = cost_results.get('cost_data', {})
# Create cost note content
cost_note_content = self._generate_cost_note_content(
issue_number, issue_details, cost_data
)
# Write cost note file
cost_note_path = Path(f"cost_notes/issue_{issue_number}_cost_{date.today().isoformat()}.md")
cost_note_path.parent.mkdir(exist_ok=True)
with open(cost_note_path, 'w') as f:
f.write(cost_note_content)
return {
'success': True,
'cost_note_path': str(cost_note_path)
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _generate_cost_note_content(self, issue_number: int, issue_details: Optional[Dict], cost_data: Dict) -> str:
"""Generate cost note content."""
title = issue_details.get('title', f'Issue #{issue_number}') if issue_details else f'Issue #{issue_number}'
total_cost_eur = cost_data.get('total_cost_eur', 0)
total_cost_usd = total_cost_eur / 0.92 if total_cost_eur else 0 # Approximate conversion
content = f"""---
note_type: "issue_cost_tracking"
issue_id: {issue_number}
issue_title: "{title}"
session_date: "{date.today().isoformat()}"
claude_model: "claude-sonnet-4"
total_cost_eur: {total_cost_eur:.4f}
total_cost_usd: {total_cost_usd:.3f}
total_minutes: {cost_data.get('total_minutes', 0)}
implementation_time_minutes: {cost_data.get('total_minutes', 0)}
generated_at: "{datetime.now().isoformat()}"
---
# Issue #{issue_number} Implementation Cost
**Issue**: {title}
**Date**: {date.today().isoformat()}
**Claude Model**: claude-sonnet-4
## Cost Summary
- **Total Cost**: €{total_cost_eur:.4f} (${total_cost_usd:.4f} USD)
- **Implementation Time**: {cost_data.get('total_hours', 0):.1f} hours ({cost_data.get('total_minutes', 0)} minutes)
- **Activities Tracked**: {cost_data.get('activity_count', 0)} activities
- **Sessions**: {cost_data.get('session_count', 0)} cost sessions
## Implementation Summary
Issue #{issue_number} "{title}" has been completed and wrapped up through automated process.
## Cost Allocation
This cost has been allocated to issue #{issue_number} implementation.
## Notes
- Currency conversion rate: 1 USD = 0.920 EUR
- Pricing based on claude-sonnet-4 rates as of {date.today().isoformat()}
- Implementation time includes design, coding, testing, and validation
<!--
contentmatter:
{{
"cost_tracking": {{
"issue": {{
"id": {issue_number},
"title": "{title}",
"completion_date": "{date.today().isoformat()}",
"implementation_time_minutes": {cost_data.get('total_minutes', 0)},
"status": "completed"
}},
"costs": {{
"total_cost_usd": {total_cost_usd:.4f},
"total_cost_eur": {total_cost_eur:.4f},
"conversion_rate": 0.92
}},
"tracking": {{
"activity_count": {cost_data.get('activity_count', 0)},
"session_count": {cost_data.get('session_count', 0)}
}}
}}
}}
-->
"""
return content
def _git_operations(self, issue_number: int, issue_details: Optional[Dict]) -> Dict[str, Any]:
"""Perform git add and commit operations."""
try:
# Add all changes including cost notes
result_add = subprocess.run(['git', 'add', '.'], capture_output=True, text=True)
if result_add.returncode != 0:
return {
'success': False,
'error': f'Git add failed: {result_add.stderr}'
}
# Create commit message
title = issue_details.get('title', f'Issue #{issue_number}') if issue_details else f'Issue #{issue_number}'
commit_message = f"""feat: complete issue #{issue_number} - {title}
Automated issue wrap-up including:
- Implementation completion verification
- Test execution and validation
- Cost tracking and note generation
- Repository state commit
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>"""
# Commit changes
result_commit = subprocess.run(
['git', 'commit', '-m', commit_message],
capture_output=True, text=True
)
return {
'success': result_commit.returncode == 0,
'add_output': result_add.stdout,
'commit_output': result_commit.stdout,
'commit_error': result_commit.stderr if result_commit.returncode != 0 else None
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _close_issue(self, issue_number: int) -> Dict[str, Any]:
"""Close the issue using the issue management system."""
try:
# Log closing activity
self.activity_tracker.log_activity(
issue_id=issue_number,
activity_type="close",
description=f"Issue #{issue_number} completed via automated wrap-up process"
)
# Try to close via make command (most reliable method)
try:
result = subprocess.run(
['make', 'close-issue', f'NUM={issue_number}'],
capture_output=True, text=True, cwd='.'
)
return {
'success': result.returncode == 0,
'method': 'make',
'output': result.stdout,
'error': result.stderr if result.returncode != 0 else None
}
except Exception:
# Fallback to direct backend call
try:
backend = self.issue_manager.get_backend()
# This would call backend.close_issue(issue_number)
return {
'success': False,
'method': 'backend',
'error': 'Backend closure not implemented'
}
except Exception as e:
return {
'success': False,
'method': 'backend',
'error': str(e)
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def format_summary(self, results: Dict[str, Any]) -> str:
"""Format wrap-up results as a readable summary."""
issue_num = results['issue_number']
timestamp = results['timestamp'].strftime('%Y-%m-%d %H:%M:%S')
summary = [
f"\n🎉 Issue #{issue_num} Wrap-Up Complete",
f"📅 Completed: {timestamp}",
"=" * 50
]
# Step-by-step results
steps = results.get('steps', {})
step_names = {
'issue_retrieval': '🔍 Issue Retrieval',
'requirement_review': '📋 Requirement Review',
'test_execution': '🧪 Associated Tests',
'full_test_execution': '🔬 Full Test Suite',
'cost_tracking': '💰 Cost Tracking',
'cost_note': '📄 Cost Note',
'git_operations': '📦 Git Operations',
'issue_closure': '🔒 Issue Closure'
}
for step_key, step_name in step_names.items():
if step_key in steps:
step_result = steps[step_key]
success = step_result.get('success', False)
status = "✅ SUCCESS" if success else "❌ FAILED"
summary.append(f"{step_name}: {status}")
if not success and 'error' in step_result:
summary.append(f" Error: {step_result['error']}")
# Cost information
if 'cost_tracking' in steps and steps['cost_tracking'].get('success'):
cost_data = steps['cost_tracking'].get('cost_data', {})
if cost_data:
summary.extend([
"",
"💰 Cost Summary:",
f" Time: {cost_data.get('total_hours', 0):.1f} hours",
f" Cost: €{cost_data.get('total_cost_eur', 0):.4f}",
f" Activities: {cost_data.get('activity_count', 0)}"
])
# Overall status
all_critical_success = all(
steps.get(step, {}).get('success', False)
for step in ['test_execution', 'full_test_execution', 'git_operations']
)
summary.extend([
"",
"🎯 Overall Status:",
"✅ SUCCESS - Issue wrap-up completed successfully!" if all_critical_success
else "⚠️ PARTIAL - Some steps had issues, please review above"
])
return "\n".join(summary)
@click.group()
def issue_wrapup():
"""Issue wrap-up commands for comprehensive issue completion."""
pass
@issue_wrapup.command()
@click.argument('issue_number', type=int)
@click.option('--force', is_flag=True, help='Skip validation checks and force completion')
@click.option('--format', 'output_format', type=click.Choice(['summary', 'detailed', 'json']),
default='summary', help='Output format')
def complete(issue_number: int, force: bool, output_format: str):
"""Complete comprehensive wrap-up for an issue.
Performs all steps needed to properly close an issue:
- Verifies requirements have been met
- Runs associated tests and full test suite
- Calculates and updates cost tracking
- Creates/updates cost notes
- Commits changes to repository
- Closes the issue
- Provides completion summary
"""
service = IssueWrapUpService()
try:
results = service.wrap_up_issue(issue_number, force=force)
if output_format == 'json':
# Convert datetime objects to strings for JSON serialization
json_results = json.loads(json.dumps(results, default=str))
click.echo(json.dumps(json_results, indent=2))
elif output_format == 'detailed':
click.echo(service.format_summary(results))
# Add detailed step information
for step_name, step_data in results.get('steps', {}).items():
if 'output' in step_data:
click.echo(f"\n--- {step_name.title()} Details ---")
click.echo(json.dumps(step_data['output'], indent=2, default=str))
else: # summary
click.echo(service.format_summary(results))
except Exception as e:
click.echo(f"❌ Error during issue wrap-up: {str(e)}", err=True)
raise click.ClickException(f"Issue wrap-up failed: {str(e)}")
if __name__ == '__main__':
issue_wrapup()

View File

@@ -1,151 +0,0 @@
"""
Plugin manager for issue backends.
This module handles discovery, loading, and configuration of issue management backends.
"""
import importlib
import yaml
from pathlib import Path
from typing import Dict, Any, Type, Optional
from .base import IssueBackend
from .exceptions import PluginNotFoundError, ConfigurationError
class IssuePluginManager:
"""Manages issue backend plugins and configuration."""
def __init__(self, config_path: Optional[str] = None):
"""
Initialize plugin manager.
Args:
config_path: Optional path to configuration file
"""
self.config = self._load_config(config_path)
self.plugins = self._discover_plugins()
def get_backend(self, backend_name: Optional[str] = None) -> IssueBackend:
"""
Get configured backend instance.
Args:
backend_name: Backend name to use, or None for default
Returns:
IssueBackend instance
Raises:
PluginNotFoundError: If backend not found
"""
backend_name = backend_name or self.config.get('default_backend', 'gitea')
plugin_class = self.plugins.get(backend_name)
if not plugin_class:
raise PluginNotFoundError(f"Unknown backend: {backend_name}")
backend_config = self.config.get('backends', {}).get(backend_name, {})
return plugin_class(backend_config)
def _load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
"""
Load configuration from file or return defaults.
Args:
config_path: Path to configuration file
Returns:
Configuration dictionary
"""
from config.manager import UnifiedConfigManager
# Get main markitect configuration to extract API token and settings
try:
config_manager = UnifiedConfigManager()
markitect_config = config_manager.get_config()
main_config = markitect_config.__dict__ if hasattr(markitect_config, '__dict__') else {}
except Exception:
main_config = {}
if config_path is None:
config_path = Path('.markitect/config/issues.yml')
else:
config_path = Path(config_path)
# Extract configuration from main markitect config
gitea_url = main_config.get('gitea_url', 'http://92.205.130.254:32166')
api_token = main_config.get('api_token', '')
repo_owner = main_config.get('repo_owner', 'coulomb')
repo_name = main_config.get('repo_name', 'markitect_project')
# Default configuration using main config values
default_config = {
'default_backend': 'gitea',
'backends': {
'gitea': {
'url': gitea_url,
'token': api_token,
'repo_owner': repo_owner,
'repo_name': repo_name,
'repo': f'{repo_owner}/{repo_name}'
},
'local': {
'directory': '.markitect/issues',
'auto_git': True
}
}
}
if not config_path.exists():
return default_config
try:
with open(config_path, 'r') as f:
config = yaml.safe_load(f) or {}
# Merge with defaults
merged_config = default_config.copy()
merged_config.update(config)
# Ensure gitea backend has token from main config if not overridden
if 'backends' in merged_config and 'gitea' in merged_config['backends']:
gitea_config = merged_config['backends']['gitea']
if not gitea_config.get('token'):
gitea_config['token'] = api_token
if not gitea_config.get('repo_owner'):
gitea_config['repo_owner'] = repo_owner
if not gitea_config.get('repo_name'):
gitea_config['repo_name'] = repo_name
return merged_config
except Exception:
# Return defaults if config loading fails
return default_config
def _discover_plugins(self) -> Dict[str, Type[IssueBackend]]:
"""
Discover available backend plugins.
Returns:
Dictionary mapping backend names to plugin classes
"""
plugins = {}
# Try to import known plugins
plugin_modules = [
('gitea', 'markitect.issues.plugins.gitea', 'GiteaPlugin'),
('local', 'markitect.issues.plugins.local', 'LocalPlugin'),
]
for name, module_path, class_name in plugin_modules:
try:
module = importlib.import_module(module_path)
plugin_class = getattr(module, class_name)
if issubclass(plugin_class, IssueBackend):
plugins[name] = plugin_class
except (ImportError, AttributeError):
# Plugin not available, skip
continue
return plugins

View File

@@ -1,5 +0,0 @@
"""
Issue management plugins.
This package contains backend implementations for different issue management systems.
"""

View File

@@ -1,102 +0,0 @@
"""
Gitea backend plugin for issue management.
This plugin integrates with existing GiteaIssueRepository infrastructure.
"""
import asyncio
from typing import List, Optional, Dict, Any
from ..base import IssueBackend
from domain.issues.models import Issue
from infrastructure.repositories.gitea_repository import GiteaIssueRepository
from infrastructure.connection_manager import ConnectionManager, DataSourceConfig
class GiteaPlugin(IssueBackend):
"""Gitea backend plugin using existing repository infrastructure."""
def __init__(self, config: Dict[str, Any]):
"""Initialize Gitea plugin with configuration."""
super().__init__(config)
# Store repo info for API endpoints
self.repo_owner = config.get('repo_owner', 'coulomb')
self.repo_name = config.get('repo_name', 'markitect_project')
self.repo_full_name = f"{self.repo_owner}/{self.repo_name}"
# Create connection manager with configuration
datasource_config = DataSourceConfig(
gitea_base_url=config.get('url', 'http://92.205.130.254:32166'),
gitea_token=config.get('token', ''),
database_path=config.get('database_path', 'markitect.db')
)
connection_manager = ConnectionManager(datasource_config)
# Create repository with repo info
self.repository = GiteaIssueRepository(connection_manager)
self.repository.set_repo_info(self.repo_owner, self.repo_name)
def list_issues(self, state: Optional[str] = None) -> List[Issue]:
"""List issues from Gitea."""
return asyncio.run(self._list_issues_async(state))
async def _list_issues_async(self, state: Optional[str] = None) -> List[Issue]:
"""Async implementation of list_issues."""
if state == 'all' or state is None:
state = None # Repository expects None for all issues
return await self.repository.get_issues(state=state)
def get_issue(self, issue_id: str) -> Issue:
"""Get specific issue from Gitea."""
return asyncio.run(self._get_issue_async(issue_id))
async def _get_issue_async(self, issue_id: str) -> Issue:
"""Async implementation of get_issue."""
issue_number = int(issue_id)
return await self.repository.get_issue(issue_number)
def create_issue(self, title: str, body: str, **kwargs) -> Issue:
"""Create new issue in Gitea."""
return asyncio.run(self._create_issue_async(title, body, **kwargs))
async def _create_issue_async(self, title: str, body: str, **kwargs) -> Issue:
"""Async implementation of create_issue."""
return await self.repository.create_issue(title=title, body=body, **kwargs)
def add_comment(self, issue_id: str, comment: str) -> Dict[str, Any]:
"""Add comment to Gitea issue."""
return asyncio.run(self._add_comment_async(issue_id, comment))
async def _add_comment_async(self, issue_id: str, comment: str) -> Dict[str, Any]:
"""Async implementation of add_comment."""
if not comment.strip():
raise ValueError("Comment cannot be empty")
if not issue_id.strip():
raise ValueError("Issue ID cannot be empty")
# For now, return mock comment data
# This will be implemented when comment support is added to repository
return {
'id': 'comment_123',
'body': comment,
'issue_id': issue_id
}
def close_issue(self, issue_id: str) -> Issue:
"""Close issue in Gitea."""
return asyncio.run(self._close_issue_async(issue_id))
async def _close_issue_async(self, issue_id: str) -> Issue:
"""Async implementation of close_issue."""
issue_number = int(issue_id)
return await self.repository.update_issue(issue_number, state='closed')
def update_issue(self, issue_id: str, **kwargs) -> Issue:
"""Update issue in Gitea."""
return asyncio.run(self._update_issue_async(issue_id, **kwargs))
async def _update_issue_async(self, issue_id: str, **kwargs) -> Issue:
"""Async implementation of update_issue."""
issue_number = int(issue_id)
return await self.repository.update_issue(issue_number, **kwargs)

View File

@@ -1,300 +0,0 @@
"""
Local file backend plugin for issue management.
This plugin provides offline issue management using markdown files with YAML frontmatter.
"""
import os
import re
import yaml
import subprocess
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Dict, Any
from ..base import IssueBackend
from domain.issues.models import Issue, IssueState, Label
class LocalPlugin(IssueBackend):
"""Local file-based backend plugin."""
def __init__(self, config: Dict[str, Any]):
"""Initialize local plugin with configuration."""
super().__init__(config)
self.issues_dir = Path(config.get('directory', '.markitect/issues'))
self.auto_git = config.get('auto_git', True)
self._setup_directory_structure()
self._load_local_config()
def _setup_directory_structure(self):
"""Create necessary directory structure."""
self.issues_dir.mkdir(parents=True, exist_ok=True)
(self.issues_dir / 'open').mkdir(exist_ok=True)
(self.issues_dir / 'closed').mkdir(exist_ok=True)
def _load_local_config(self):
"""Load or create local configuration."""
config_file = self.issues_dir / 'config.yml'
if config_file.exists():
with open(config_file, 'r') as f:
self.local_config = yaml.safe_load(f) or {}
else:
self.local_config = {'next_issue_number': self.config.get('numbering_start', 1)}
self._save_local_config()
def _save_local_config(self):
"""Save local configuration."""
config_file = self.issues_dir / 'config.yml'
with open(config_file, 'w') as f:
yaml.dump(self.local_config, f, default_flow_style=False)
def list_issues(self, state: Optional[str] = None) -> List[Issue]:
"""List issues from local files."""
issues = []
if state == 'open' or state is None or state == 'all':
issues.extend(self._read_issues_from_directory(self.issues_dir / 'open'))
if state == 'closed' or state is None or state == 'all':
issues.extend(self._read_issues_from_directory(self.issues_dir / 'closed'))
# Sort by issue number
issues.sort(key=lambda x: x.number)
return issues
def _read_issues_from_directory(self, directory: Path) -> List[Issue]:
"""Read all issues from a directory."""
issues = []
if not directory.exists():
return issues
for file_path in directory.glob('*.md'):
try:
issue = self._read_issue_file(file_path)
issues.append(issue)
except Exception:
# Skip malformed files
continue
return issues
def _read_issue_file(self, file_path: Path) -> Issue:
"""Read issue from markdown file with YAML frontmatter."""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Split frontmatter and body
if content.startswith('---\n'):
try:
parts = content.split('---\n', 2)
if len(parts) >= 3:
frontmatter = yaml.safe_load(parts[1])
body = parts[2].strip()
else:
frontmatter = {}
body = content
except yaml.YAMLError:
raise yaml.YAMLError(f"Invalid YAML in {file_path}")
else:
frontmatter = {}
body = content
# Convert string labels to Label objects
label_objects = []
for label in frontmatter.get('labels', []):
if isinstance(label, str):
label_objects.append(Label(name=label))
else:
label_objects.append(label)
# Map state string to IssueState enum
state_str = frontmatter.get('state', 'open')
issue_state = IssueState.OPEN if state_str == 'open' else IssueState.CLOSED
# Create Issue object
issue = Issue(
number=frontmatter.get('number', 0),
title=frontmatter.get('title', ''),
state=issue_state,
labels=label_objects,
created_at=datetime.fromisoformat(frontmatter.get('created_at', datetime.now().isoformat())),
updated_at=datetime.fromisoformat(frontmatter.get('updated_at', datetime.now().isoformat())),
assignee=frontmatter.get('assignee'),
milestone=frontmatter.get('milestone')
)
# Store body separately since domain model doesn't have it
issue._body = body
return issue
def get_issue(self, issue_id: str) -> Issue:
"""Get specific issue by ID."""
file_path = self._find_issue_file(issue_id)
if not file_path:
raise FileNotFoundError(f"Issue {issue_id} not found")
return self._read_issue_file(file_path)
def _find_issue_file(self, issue_id: str) -> Optional[Path]:
"""Find issue file in open or closed directories."""
# Convert issue_id to 3-digit format to match filename pattern
issue_num = f"{int(issue_id):03d}"
pattern = f"{issue_num}-*.md"
# Search in open directory
for file_path in (self.issues_dir / 'open').glob(pattern):
return file_path
# Search in closed directory
for file_path in (self.issues_dir / 'closed').glob(pattern):
return file_path
return None
def create_issue(self, title: str, body: str, **kwargs) -> Issue:
"""Create new issue as local file."""
issue_number = self.local_config.get('next_issue_number', 1)
# Convert string labels to Label objects
label_objects = []
for label in kwargs.get('labels', []):
if isinstance(label, str):
label_objects.append(Label(name=label))
else:
label_objects.append(label)
# Create Issue object
issue = Issue(
number=issue_number,
title=title,
state=IssueState.OPEN,
labels=label_objects,
created_at=datetime.now(),
updated_at=datetime.now(),
assignee=kwargs.get('assignee'),
milestone=kwargs.get('milestone')
)
# Store body separately since domain model doesn't have it
issue._body = body # Temporary storage for body content
# Write to file
self._write_issue_file(issue, self.issues_dir / 'open')
# Update counter
self.local_config['next_issue_number'] = issue_number + 1
self._save_local_config()
# Git integration
if self.auto_git:
self._git_add_and_commit(f"Create issue #{issue_number}: {title}")
return issue
def _write_issue_file(self, issue: Issue, directory: Path):
"""Write issue to markdown file with YAML frontmatter."""
filename = self._generate_filename(issue)
file_path = directory / filename
# Convert Label objects to strings for YAML
label_names = [label.name for label in issue.labels]
# Prepare frontmatter
frontmatter = {
'number': issue.number,
'title': issue.title,
'state': issue.state.value,
'created_at': issue.created_at.isoformat(),
'updated_at': issue.updated_at.isoformat(),
'labels': label_names,
'assignee': issue.assignee,
'milestone': issue.milestone
}
# Write file
with open(file_path, 'w', encoding='utf-8') as f:
f.write('---\n')
yaml.dump(frontmatter, f, default_flow_style=False)
f.write('---\n\n')
f.write(getattr(issue, '_body', ''))
def _generate_filename(self, issue: Issue) -> str:
"""Generate safe filename from issue."""
# Sanitize title for filename
safe_title = re.sub(r'[^\w\s-]', '', issue.title.lower())
safe_title = re.sub(r'[\s_-]+', '-', safe_title)
safe_title = safe_title.strip('-')[:50] # Limit length
return f"{issue.number:03d}-{safe_title}.md"
def add_comment(self, issue_id: str, comment: str) -> Dict[str, Any]:
"""Add comment to local issue file."""
if not comment.strip():
raise ValueError("Comment cannot be empty")
if not issue_id.strip():
raise ValueError("Issue ID cannot be empty")
# For now, return mock comment data
# Full implementation would append to issue file
return {
'id': f"comment_{datetime.now().timestamp()}",
'body': comment,
'timestamp': datetime.now().isoformat()
}
def close_issue(self, issue_id: str) -> Issue:
"""Close issue by moving to closed directory."""
return self.update_issue(issue_id, state=IssueState.CLOSED)
def update_issue(self, issue_id: str, **kwargs) -> Issue:
"""Update issue properties."""
file_path = self._find_issue_file(issue_id)
if not file_path:
raise FileNotFoundError(f"Issue {issue_id} not found")
# Read current issue
issue = self._read_issue_file(file_path)
# Update properties
for key, value in kwargs.items():
if hasattr(issue, key):
setattr(issue, key, value)
# Handle state change (move file if needed)
old_state = 'open' if 'open' in str(file_path) else 'closed'
new_state_obj = kwargs.get('state', issue.state)
new_state = new_state_obj.value if hasattr(new_state_obj, 'value') else str(new_state_obj)
if new_state != old_state:
# Remove old file
file_path.unlink()
# Write to new directory
new_directory = self.issues_dir / new_state
self._write_issue_file(issue, new_directory)
else:
# Update existing file
self._write_issue_file(issue, file_path.parent)
# Git integration
if self.auto_git:
self._git_add_and_commit(f"Update issue #{issue.number}")
return issue
def _git_add_and_commit(self, message: str):
"""Add and commit changes to git."""
try:
subprocess.run(['git', 'add', str(self.issues_dir)],
cwd='.', check=True, capture_output=True)
subprocess.run(['git', 'commit', '-m', message],
cwd='.', check=True, capture_output=True)
except (subprocess.CalledProcessError, FileNotFoundError):
# Git not available or not a git repo, ignore
pass

View File

@@ -1,178 +0,0 @@
"""
Issue service - business logic for issue operations.
"""
from typing import List, Dict, Any, Optional
from datetime import datetime
from tddai import IssueFetcher, TddaiError
from tddai.issue_creator import IssueCreator
from gitea.models import Issue, Priority
class IssueService:
"""Service for issue operations."""
def __init__(self):
self.issue_fetcher = IssueFetcher()
self.issue_creator = IssueCreator()
def get_issue(self, issue_number: int) -> Issue:
"""Get a specific issue by number."""
return self.issue_fetcher.fetch_issue(issue_number)
def list_issues(self, state: str = "all") -> List[Issue]:
"""List issues with optional state filter."""
return self.issue_fetcher.fetch_issues(state)
def list_open_issues(self) -> List[Issue]:
"""List only open issues."""
return self.issue_fetcher.fetch_open_issues()
def create_issue(self, title: str, body: str, **kwargs) -> Dict[str, Any]:
"""Create a new issue."""
return self.issue_creator.create_issue(title, body, **kwargs)
def create_enhancement_issue(self, title: str, use_case: str,
technical_requirements: str = "",
acceptance_criteria: Optional[List[str]] = None,
dependencies: Optional[List[str]] = None,
priority: str = "Medium") -> Dict[str, Any]:
"""Create a structured enhancement issue."""
return self.issue_creator.create_enhancement_issue(
title, use_case, technical_requirements,
acceptance_criteria, dependencies, priority
)
def create_from_template(self, template_file: str, **kwargs) -> Dict[str, Any]:
"""Create issue from template file."""
return self.issue_creator.create_from_template(template_file, **kwargs)
def close_issue(self, issue_number: int, comment: str = "") -> Dict[str, Any]:
"""Close an issue with optional comment."""
from gitea import GiteaClient, GiteaConfig
from tddai.config import get_config
import os
# Get config and create Gitea client
config = get_config()
gitea_config = GiteaConfig.from_tddai_config(config)
auth_token = os.getenv('GITEA_API_TOKEN')
if auth_token:
gitea_config.auth_token = auth_token
gitea_client = GiteaClient(gitea_config)
# Close the issue
issue = gitea_client.issues.close(issue_number)
# If comment provided, add it (this would need API support for comments)
# For now, we'll just close the issue
# Convert to dict format for consistency
return gitea_client.issues.to_dict(issue)
def get_issue_details(self, issue_number: int) -> Dict[str, Any]:
"""Get comprehensive issue details for display purposes."""
issue = self.get_issue(issue_number)
# Get additional project management information
from tddai.project_manager import ProjectManager
project_mgr = ProjectManager()
# Get detailed issue data via API for milestone and project information
from tddai.config import get_config
config = get_config()
issue_url = f"{config.issues_api_url}/{issue_number}"
detailed_issue = project_mgr._make_api_call('GET', issue_url)
# Process labels
labels = detailed_issue.get('labels', [])
state_labels = [l['name'] for l in labels if l['name'].startswith('status:')]
priority_labels = [l['name'] for l in labels if l['name'].startswith('priority:')]
type_labels = [l['name'] for l in labels if l['name'].startswith('type:')]
other_labels = [l['name'] for l in labels if not any(l['name'].startswith(p) for p in ['status:', 'priority:', 'type:'])]
# Determine project column/state
if detailed_issue.get('state') == 'closed':
if any(l['name'] == 'status:done' for l in labels):
column = "Done"
else:
column = "Closed"
else:
state_labels = [l['name'] for l in labels if l['name'].startswith('status:')]
if state_labels:
state = state_labels[0].replace('status:', '')
column_map = {
'todo': 'Todo',
'active': 'Active',
'review': 'Review',
'blocked': 'Blocked'
}
column = column_map.get(state, 'Todo')
else:
column = "Todo"
return {
'number': issue.number,
'title': issue.title,
'body': issue.body,
'state': issue.state,
'created_at': issue.created_at,
'updated_at': issue.updated_at,
'html_url': issue.html_url,
'assignee': issue.assignee.login if issue.assignee else None,
'milestone': detailed_issue.get('milestone'),
'state_label': state_labels[0].replace('status:', '').title() if state_labels else "No state label",
'priority_label': priority_labels[0].replace('priority:', '').title() if priority_labels else "No priority set",
'type_labels': [l.replace('type:', '').title() for l in type_labels],
'other_labels': other_labels,
'kanban_column': column
}
def get_issue_summary(self, issue_number: int) -> Dict[str, Any]:
"""Get issue summary for display purposes."""
issue = self.get_issue(issue_number)
return {
'number': issue.number,
'title': issue.title,
'body': issue.body,
'state': issue.state,
'priority': issue.priority,
'status': issue.status,
'created_at': issue.created_at,
'updated_at': issue.updated_at,
'html_url': issue.html_url,
'assignee': issue.assignee.login if issue.assignee else None,
'labels': [label.name for label in issue.labels],
'has_milestone': issue.milestone is not None,
'milestone_title': issue.milestone.title if issue.milestone else None
}
def analyze_coverage(self, issue_number: int) -> Dict[str, Any]:
"""Analyze test coverage for a specific issue.
Args:
issue_number: The issue number to analyze
Returns:
Coverage analysis data for the issue
Raises:
IssueError: When coverage analysis fails
"""
from tddai.coverage_analyzer import CoverageAnalyzer
analyzer = CoverageAnalyzer()
assessment = analyzer.analyze_issue_coverage(issue_number)
return {
'issue_number': assessment.issue_number,
'issue_title': assessment.issue_title,
'coverage_percentage': assessment.coverage_percentage,
'requirements': assessment.requirements,
'existing_tests': assessment.existing_tests,
'coverage_gaps': assessment.coverage_gaps,
'recommendations': assessment.recommendations
}

View File

@@ -1,76 +0,0 @@
"""
Project service - business logic for project management operations.
"""
from typing import List, Dict, Any, Optional
from tddai.project_manager import ProjectManager, ProjectState, Priority, Milestone, Label
from tddai import TddaiError
class ProjectService:
"""Service for project management operations."""
def __init__(self):
self.project_manager = ProjectManager()
def setup_project_management(self) -> None:
"""Setup project management labels and structure."""
self.project_manager.ensure_project_labels()
def create_milestone(self, title: str, description: str = "", due_date: Optional[str] = None) -> Milestone:
"""Create a new milestone (project)."""
return self.project_manager.create_milestone(title, description, due_date)
def list_milestones(self, state: str = "open") -> List[Milestone]:
"""List milestones."""
return self.project_manager.list_milestones(state)
def list_labels(self) -> List[Label]:
"""List repository labels."""
return self.project_manager.list_labels()
def set_issue_state(self, issue_number: int, state_name: str) -> Dict[str, Any]:
"""Set issue project state."""
# Convert string to ProjectState enum
state_map = {
'todo': ProjectState.TODO,
'active': ProjectState.ACTIVE,
'review': ProjectState.REVIEW,
'done': ProjectState.DONE,
'blocked': ProjectState.BLOCKED
}
if state_name not in state_map:
raise TddaiError(f"Invalid state '{state_name}'. Valid states: {list(state_map.keys())}")
project_state = state_map[state_name]
return self.project_manager.set_issue_state(issue_number, project_state)
def set_issue_priority(self, issue_number: int, priority_name: str) -> Dict[str, Any]:
"""Set issue priority."""
# Convert string to Priority enum
priority_map = {
'low': Priority.LOW,
'medium': Priority.MEDIUM,
'high': Priority.HIGH,
'critical': Priority.CRITICAL
}
if priority_name not in priority_map:
raise TddaiError(f"Invalid priority '{priority_name}'. Valid priorities: {list(priority_map.keys())}")
priority_level = priority_map[priority_name]
return self.project_manager.set_issue_priority(issue_number, priority_level)
def move_issue_to_done(self, issue_number: int) -> Dict[str, Any]:
"""Move issue to done state and close it."""
return self.project_manager.move_issue_to_done(issue_number)
def assign_issue_to_milestone(self, issue_number: int, milestone_id: int) -> Dict[str, Any]:
"""Assign issue to a milestone."""
return self.project_manager.assign_issue_to_milestone(issue_number, milestone_id)
def get_project_overview(self) -> Dict[str, Any]:
"""Get project management overview."""
return self.project_manager.get_project_overview()

View File

@@ -1,116 +0,0 @@
"""
Workspace service - business logic for TDD workspace operations.
"""
from typing import Optional, Dict, Any
from pathlib import Path
from tddai import WorkspaceManager, IssueFetcher, WorkspaceStatus, TddaiError
class WorkspaceInfo:
"""Value object for workspace information."""
def __init__(self, status: WorkspaceStatus, workspace=None):
self.status = status
self.workspace = workspace
@property
def is_clean(self) -> bool:
return self.status == WorkspaceStatus.CLEAN
@property
def is_dirty(self) -> bool:
return self.status == WorkspaceStatus.DIRTY
@property
def is_active(self) -> bool:
return self.status == WorkspaceStatus.ACTIVE
def get_test_files(self) -> list:
"""Get list of test files in workspace."""
if not self.workspace or not self.workspace.tests_dir.exists():
return []
return list(self.workspace.tests_dir.glob("*.py"))
class WorkspaceService:
"""Service for workspace operations."""
def __init__(self):
self.workspace_manager = WorkspaceManager()
self.issue_fetcher = IssueFetcher()
def get_workspace_info(self) -> WorkspaceInfo:
"""Get current workspace information."""
status = self.workspace_manager.get_status()
workspace = None
if status == WorkspaceStatus.ACTIVE:
workspace = self.workspace_manager.get_current_workspace()
return WorkspaceInfo(status, workspace)
def start_issue_workspace(self, issue_number: int) -> WorkspaceInfo:
"""Start working on an issue.
Returns:
WorkspaceInfo with the created workspace
Raises:
TddaiError: If workspace already active or issue cannot be fetched
"""
# Check if workspace already active
current_info = self.get_workspace_info()
if current_info.is_active:
raise TddaiError(f"Already working on issue #{current_info.workspace.issue_number}")
# Fetch issue data
issue_data = self.issue_fetcher.get_issue_data_dict(issue_number)
# Create workspace
workspace = self.workspace_manager.create_workspace(issue_data)
return WorkspaceInfo(WorkspaceStatus.ACTIVE, workspace)
def finish_current_workspace(self) -> Optional[int]:
"""Finish current workspace and return the issue number.
Returns:
Issue number that was finished, or None if no active workspace
Raises:
TddaiError: If workspace operations fail
"""
current_info = self.get_workspace_info()
if not current_info.is_active:
return None
issue_number = current_info.workspace.issue_number
self.workspace_manager.finish_workspace()
return issue_number
def get_workspace_summary(self) -> Dict[str, Any]:
"""Get workspace summary for display purposes."""
info = self.get_workspace_info()
summary = {
'status': info.status,
'active': info.is_active,
'clean': info.is_clean,
'dirty': info.is_dirty
}
if info.workspace:
test_files = info.get_test_files()
summary.update({
'issue_number': info.workspace.issue_number,
'issue_title': info.workspace.issue_title,
'issue_state': info.workspace.issue_state,
'workspace_dir': str(info.workspace.workspace_dir),
'test_count': len(test_files),
'test_files': [f.name for f in test_files],
'requirements_file': str(info.workspace.requirements_file),
'test_plan_file': str(info.workspace.test_plan_file)
})
return summary

View File

@@ -1,13 +0,0 @@
#!/bin/bash
# TDDAi environment setup for MarkiTect project
# Source this file to configure tddai for this specific project
export TDDAI_WORKSPACE_DIR=.markitect_workspace
export TDDAI_GITEA_URL=http://92.205.130.254:32166
export TDDAI_REPO_OWNER=coulomb
export TDDAI_REPO_NAME=markitect_project
echo "✅ TDDAi configured for MarkiTect project"
echo " Workspace: $TDDAI_WORKSPACE_DIR"
echo " Repository: $TDDAI_REPO_OWNER/$TDDAI_REPO_NAME"
echo " URL: $TDDAI_GITEA_URL"

View File

@@ -1,37 +0,0 @@
"""
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 .issue_creator import IssueCreator
from .project_manager import ProjectManager, ProjectState, Priority
from .test_generator import TestGenerator
from .coverage_analyzer import CoverageAnalyzer, CoverageAssessment, TestRequirement, CoverageGap
from .exceptions import TddaiError, WorkspaceError, IssueError, ConfigurationError, TestGenerationError
__version__ = "0.1.0"
__all__ = [
"WorkspaceManager",
"Workspace",
"WorkspaceStatus",
"IssueFetcher",
"Issue",
"IssueCreator",
"ProjectManager",
"ProjectState",
"Priority",
"TestGenerator",
"CoverageAnalyzer",
"CoverageAssessment",
"TestRequirement",
"CoverageGap",
"TddaiError",
"WorkspaceError",
"IssueError",
"ConfigurationError",
"TestGenerationError",
]

View File

@@ -1,171 +0,0 @@
"""
Configuration management for tddai.
DEPRECATED: This module is kept for backward compatibility only.
New code should use the unified configuration system in the `config` module.
The tddai framework is project-agnostic and can be configured per project
via environment variables:
- TDDAI_WORKSPACE_DIR: Workspace directory name (default: .tddai_workspace)
- TDDAI_GITEA_URL: Git platform URL
- TDDAI_REPO_OWNER: Repository owner/organization
- TDDAI_REPO_NAME: Repository name
Example .env file for a project:
```
TDDAI_WORKSPACE_DIR=.myproject_workspace
TDDAI_GITEA_URL=https://github.com
TDDAI_REPO_OWNER=myusername
TDDAI_REPO_NAME=myproject
```
"""
import os
from pathlib import Path
from typing import Optional
from dataclasses import dataclass
from .exceptions import ConfigurationError
# Import unified configuration system
try:
from config import get_tddai_config as _get_unified_tddai_config
_UNIFIED_CONFIG_AVAILABLE = True
except ImportError:
_UNIFIED_CONFIG_AVAILABLE = False
def load_dotenv_file(env_file: Path) -> None:
"""Load environment variables from a .env file.
DEPRECATED: Use config.loaders.load_env_file() instead.
"""
if not env_file.exists():
return
with open(env_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
os.environ.setdefault(key.strip(), value.strip())
@dataclass
class TddaiConfig:
"""Configuration settings for tddai.
DEPRECATED: Use config.TddaiConfigCompat instead.
"""
# Workspace settings
workspace_dir: Path = Path(".tddai_workspace")
current_issue_file: str = "current_issue.json"
# Git repository settings (must be configured per project)
gitea_url: str = ""
repo_owner: str = ""
repo_name: str = ""
# 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 and .env files."""
# Auto-load .env.tddai file if it exists
env_file = Path(".env.tddai")
load_dotenv_file(env_file)
config = cls()
# Override with environment variables if present
gitea_url = os.getenv("TDDAI_GITEA_URL")
if gitea_url:
config.gitea_url = gitea_url
repo_owner = os.getenv("TDDAI_REPO_OWNER")
if repo_owner:
config.repo_owner = repo_owner
repo_name = os.getenv("TDDAI_REPO_NAME")
if repo_name:
config.repo_name = repo_name
workspace_dir = os.getenv("TDDAI_WORKSPACE_DIR")
if 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.
DEPRECATED: Use config.get_tddai_config() instead for new code.
This function maintains backward compatibility.
"""
global _config
if _config is None:
if _UNIFIED_CONFIG_AVAILABLE:
# Use unified configuration system if available
try:
unified_config = _get_unified_tddai_config()
_config = TddaiConfig(
workspace_dir=unified_config.workspace_dir,
gitea_url=unified_config.gitea_url,
repo_owner=unified_config.repo_owner,
repo_name=unified_config.repo_name,
tests_dir=unified_config.tests_dir,
test_file_pattern=unified_config.test_file_pattern,
claude_code_command=unified_config.claude_code_command
)
return _config
except Exception:
# Fall back to legacy behavior if unified config fails
pass
# Legacy fallback
_config = TddaiConfig.from_environment()
_config.validate()
return _config
def set_config(config: TddaiConfig) -> None:
"""Set the global configuration instance.
DEPRECATED: Use the unified configuration system instead.
"""
global _config
config.validate()
_config = config

View File

@@ -1,430 +0,0 @@
"""
Test coverage analyzer for issues.
This module analyzes issues and existing tests to identify gaps in functional test coverage.
"""
import json
import re
from pathlib import Path
from typing import Dict, List, Set, Tuple, Optional
from dataclasses import dataclass
from .issue_fetcher import IssueFetcher
from .config import get_config
@dataclass
class TestRequirement:
"""Represents a test requirement extracted from an issue."""
category: str
description: str
priority: str # "critical", "important", "nice-to-have"
keywords: List[str]
@dataclass
class ExistingTest:
"""Represents an existing test file and its coverage."""
file_path: Path
test_methods: List[str]
coverage_keywords: Set[str]
related_issue: Optional[int] = None
@dataclass
class CoverageGap:
"""Represents a gap in test coverage."""
requirement: TestRequirement
suggested_test_name: str
suggested_test_file: str
example_test: str
@dataclass
class CoverageAssessment:
"""Complete coverage assessment for an issue."""
issue_number: int
issue_title: str
requirements: List[TestRequirement]
existing_tests: List[ExistingTest]
coverage_gaps: List[CoverageGap]
coverage_percentage: float
recommendations: List[str]
class CoverageAnalyzer:
"""Analyzes test coverage for issues."""
def __init__(self, config=None):
self.config = config or get_config()
self.issue_fetcher = IssueFetcher(self.config)
def analyze_issue_coverage(self, issue_number: int) -> CoverageAssessment:
"""Analyze test coverage for a specific issue."""
# Fetch issue details
issue_data = self.issue_fetcher.fetch_issue(issue_number)
# Extract test requirements from issue
requirements = self._extract_requirements(issue_data)
# Find existing tests
existing_tests = self._find_existing_tests(issue_number)
# Identify coverage gaps
coverage_gaps = self._identify_gaps(requirements, existing_tests)
# Calculate coverage percentage
coverage_percentage = self._calculate_coverage(requirements, existing_tests)
# Generate recommendations
recommendations = self._generate_recommendations(issue_data, coverage_gaps)
return CoverageAssessment(
issue_number=issue_number,
issue_title=issue_data.title if hasattr(issue_data, 'title') else issue_data.get('title', ''),
requirements=requirements,
existing_tests=existing_tests,
coverage_gaps=coverage_gaps,
coverage_percentage=coverage_percentage,
recommendations=recommendations
)
def _extract_requirements(self, issue_data) -> List[TestRequirement]:
"""Extract test requirements from issue description."""
requirements = []
# Handle both dict and Issue object
if hasattr(issue_data, 'body'):
description = issue_data.body + ' ' + issue_data.title
else:
description = issue_data.get('body', '') + ' ' + issue_data.get('title', '')
# Define requirement patterns with priorities
requirement_patterns = [
# Critical functionality patterns
(r'user can\s+([^.]+)', 'critical', 'user_functionality'),
(r'must\s+([^.]+)', 'critical', 'requirement'),
(r'should\s+([^.]+)', 'important', 'requirement'),
(r'example:\s*([^.]+)', 'important', 'example_scenario'),
# API/Interface patterns
(r'(create|generate|parse|validate|convert|process)\s+([^.]+)', 'critical', 'core_function'),
(r'(read|store|save|load|retrieve|fetch)\s+([^.]+)', 'critical', 'data_operation'),
(r'(input|output|parameter|argument):\s*([^.]+)', 'important', 'io_validation'),
(r'(returns?|outputs?)\s+([^.]+)', 'important', 'output_validation'),
# Data operations - common in simple issues
(r'(file|database|storage|content)\s+([^.]+)', 'important', 'data_handling'),
(r'(schema|json|markdown|ast)\s+([^.]+)', 'important', 'format_handling'),
# Error handling patterns
(r'(error|exception|fail|invalid)\s+([^.]+)', 'important', 'error_handling'),
(r'edge case:\s*([^.]+)', 'important', 'edge_case'),
# Performance/behavior patterns
(r'(performance|speed|memory|optimization)\s+([^.]+)', 'nice-to-have', 'performance'),
(r'(depth|level|nesting)\s+([^.]+)', 'important', 'structural'),
]
# Extract requirements based on patterns
for pattern, priority, category in requirement_patterns:
matches = re.finditer(pattern, description, re.IGNORECASE)
for match in matches:
requirement_text = match.group(1) if match.groups() else match.group(0)
keywords = self._extract_keywords(requirement_text)
requirements.append(TestRequirement(
category=category,
description=requirement_text.strip(),
priority=priority,
keywords=keywords
))
# Add enhanced requirements if few found (especially for simple issues)
if len(requirements) <= 2:
title = issue_data.title if hasattr(issue_data, 'title') else issue_data.get('title', '')
# Extract more detailed requirements from title
title_words = title.lower().split()
# Add basic functionality requirement
requirements.append(TestRequirement(
category='basic_functionality',
description=f'Basic functionality: {title}',
priority='critical',
keywords=self._extract_keywords(title)
))
# Add specific requirements based on title analysis
if any(word in title_words for word in ['read', 'load', 'fetch', 'get']):
requirements.append(TestRequirement(
category='input_validation',
description='Input validation and file reading',
priority='critical',
keywords=['read', 'input', 'validation', 'file']
))
if any(word in title_words for word in ['store', 'save', 'write', 'database']):
requirements.append(TestRequirement(
category='storage_operation',
description='Data storage and persistence',
priority='critical',
keywords=['store', 'save', 'database', 'persistence']
))
if any(word in title_words for word in ['schema', 'json', 'format']):
requirements.append(TestRequirement(
category='format_handling',
description='Schema/format validation and processing',
priority='important',
keywords=['schema', 'json', 'format', 'validation']
))
# Add error handling requirement for all functionality
requirements.append(TestRequirement(
category='error_handling',
description='Error handling and edge cases',
priority='important',
keywords=['error', 'exception', 'validation', 'edge']
))
return requirements
def _extract_keywords(self, text: str) -> List[str]:
"""Extract relevant keywords from text."""
# Common technical keywords
keywords = []
text_lower = text.lower()
# Extract nouns and verbs that are likely important
important_patterns = [
r'\b(schema|json|markdown|ast|parse|generate|create|validate|convert|transform)\b',
r'\b(file|document|content|structure|element|heading|list|depth|level)\b',
r'\b(input|output|parameter|argument|result|data|format)\b',
r'\b(error|exception|validation|check|verify|ensure)\b'
]
for pattern in important_patterns:
matches = re.findall(pattern, text_lower)
keywords.extend(matches)
return list(set(keywords)) # Remove duplicates
def _find_existing_tests(self, issue_number: int) -> List[ExistingTest]:
"""Find existing tests related to the issue."""
existing_tests = []
tests_dir = Path('tests')
if not tests_dir.exists():
return existing_tests
# Find tests that mention the issue number
issue_patterns = [
f'test_issue_{issue_number}',
f'issue_{issue_number}',
f'#{issue_number}',
f'issue {issue_number}'
]
for test_file in tests_dir.glob('test_*.py'):
try:
content = test_file.read_text()
test_methods = self._extract_test_methods(content)
coverage_keywords = self._extract_coverage_keywords(content)
# Check if this test file is related to the issue
related_issue = None
for pattern in issue_patterns:
if pattern in content.lower():
related_issue = issue_number
break
existing_tests.append(ExistingTest(
file_path=test_file,
test_methods=test_methods,
coverage_keywords=coverage_keywords,
related_issue=related_issue
))
except (OSError, IOError, UnicodeDecodeError) as e:
# Skip files that can't be read due to file system or encoding issues
# Log the issue but continue processing other files
from infrastructure.logging import get_logger
logger = get_logger(__name__)
logger.warning(
f"Could not read test file {test_file}: {e}"
)
continue
except Exception as e:
# Unexpected errors should be logged but not silently ignored
from infrastructure.logging import get_logger
logger = get_logger(__name__)
logger.error(
f"Unexpected error processing test file {test_file}: {e}",
exc_info=True
)
continue
return existing_tests
def _extract_test_methods(self, content: str) -> List[str]:
"""Extract test method names from test file content."""
pattern = r'def\s+(test_[a-zA-Z_0-9]+)\s*\('
return re.findall(pattern, content)
def _extract_coverage_keywords(self, content: str) -> Set[str]:
"""Extract keywords that indicate what functionality is being tested."""
keywords = set()
content_lower = content.lower()
# Extract from test method names and docstrings
test_patterns = [
r'def\s+test_([a-zA-Z_0-9]+)',
r'"""([^"]+)"""',
r"'''([^']+)'''",
r'#\s*([^\n]+)'
]
for pattern in test_patterns:
matches = re.findall(pattern, content_lower)
for match in matches:
keywords.update(self._extract_keywords(match))
return keywords
def _identify_gaps(self, requirements: List[TestRequirement],
existing_tests: List[ExistingTest]) -> List[CoverageGap]:
"""Identify gaps between requirements and existing tests."""
gaps = []
# Get all covered keywords from existing tests
covered_keywords = set()
for test in existing_tests:
covered_keywords.update(test.coverage_keywords)
for requirement in requirements:
# Check if requirement is covered by existing tests
requirement_keywords = set(requirement.keywords)
if requirement_keywords:
coverage_overlap = requirement_keywords.intersection(covered_keywords)
# If less than 50% of keywords are covered, consider it a gap
coverage_ratio = len(coverage_overlap) / len(requirement_keywords)
if coverage_ratio < 0.5:
gap = self._create_coverage_gap(requirement)
gaps.append(gap)
else:
# If no keywords could be extracted, always consider it a gap
# (This prevents false positives where we can't determine coverage)
gap = self._create_coverage_gap(requirement)
gaps.append(gap)
return gaps
def _create_coverage_gap(self, requirement: TestRequirement) -> CoverageGap:
"""Create a coverage gap with suggestions."""
# Generate suggested test name
category_clean = requirement.category.replace('_', ' ').title().replace(' ', '')
suggested_name = f"test_{requirement.category}"
# Generate suggested file name
suggested_file = f"tests/test_{requirement.category}.py"
# Generate example test
example_test = self._generate_example_test(requirement)
return CoverageGap(
requirement=requirement,
suggested_test_name=suggested_name,
suggested_test_file=suggested_file,
example_test=example_test
)
def _generate_example_test(self, requirement: TestRequirement) -> str:
"""Generate an example test for a requirement."""
method_name = f"test_{requirement.category}"
return f'''def {method_name}(self):
"""Test {requirement.description}."""
# Arrange
# TODO: Set up test data for {requirement.description}
# Act
# TODO: Execute the functionality: {requirement.description}
# Assert
# TODO: Verify the expected behavior
assert True # Replace with actual assertions'''
def _calculate_coverage(self, requirements: List[TestRequirement],
existing_tests: List[ExistingTest]) -> float:
"""Calculate coverage percentage."""
if not requirements:
return 100.0
covered_requirements = 0
total_requirements = len(requirements)
# Get all covered keywords
covered_keywords = set()
issue_related_tests = []
for test in existing_tests:
if test.related_issue: # Only count tests specifically for this issue
covered_keywords.update(test.coverage_keywords)
issue_related_tests.append(test)
# If no issue-specific tests found, coverage should be 0%
if not issue_related_tests:
return 0.0
# Check coverage for each requirement
for requirement in requirements:
requirement_keywords = set(requirement.keywords)
if requirement_keywords:
# Need actual keyword overlap for coverage
coverage_ratio = len(requirement_keywords.intersection(covered_keywords)) / len(requirement_keywords)
if coverage_ratio >= 0.5: # Consider 50%+ keyword coverage as "covered"
covered_requirements += 1
else:
# If no keywords extracted, this requirement is NOT covered
# (This prevents false positives for untested functionality)
pass
return (covered_requirements / total_requirements) * 100 if total_requirements > 0 else 0.0
def _generate_recommendations(self, issue_data: Dict, gaps: List[CoverageGap]) -> List[str]:
"""Generate recommendations for improving test coverage."""
recommendations = []
if not gaps:
recommendations.append("✅ Good test coverage! All major requirements appear to be tested.")
return recommendations
# Prioritize recommendations by requirement priority
critical_gaps = [g for g in gaps if g.requirement.priority == 'critical']
important_gaps = [g for g in gaps if g.requirement.priority == 'important']
nice_gaps = [g for g in gaps if g.requirement.priority == 'nice-to-have']
if critical_gaps:
recommendations.append(f"🚨 CRITICAL: {len(critical_gaps)} critical requirements lack test coverage")
for gap in critical_gaps:
recommendations.append(f" - Add test for: {gap.requirement.description}")
if important_gaps:
recommendations.append(f"⚠️ IMPORTANT: {len(important_gaps)} important requirements need tests")
for gap in important_gaps[:3]: # Show top 3
recommendations.append(f" - Test needed: {gap.requirement.description}")
if nice_gaps:
recommendations.append(f"💡 ENHANCEMENT: {len(nice_gaps)} additional tests would improve coverage")
# Add specific recommendations
recommendations.append("📝 Recommended actions:")
issue_num = issue_data.number if hasattr(issue_data, 'number') else issue_data.get('number', 'X')
recommendations.append(f" 1. Use 'make tdd-start NUM={issue_num}' to create workspace")
recommendations.append(" 2. Use 'make tdd-add-test' to generate missing tests")
recommendations.append(" 3. Focus on critical requirements first")
return recommendations

View File

@@ -1,28 +0,0 @@
"""
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

View File

@@ -1,180 +0,0 @@
#!/usr/bin/env python3
"""
TDDAi Issue Closer
A dedicated module for closing issues in the selected issue tracking backend.
Provides both programmatic API and CLI functionality for easy issue closure.
This module integrates with the existing tddai framework and provides:
- Simple programmatic interface for closing issues
- Command-line utility for manual issue closure
- Integration with existing issue tracking backends
- Proper error handling and user feedback
"""
import sys
import argparse
from pathlib import Path
from typing import Optional
# Add project root to path for imports
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from cli import CLIFramework
from services import IssueService
from tddai import TddaiError
class IssueCloser:
"""Dedicated class for closing issues with the configured backend."""
def __init__(self):
"""Initialize the issue closer with CLI framework."""
self.cli = CLIFramework()
self.service = IssueService()
def close_issue(self, issue_number: int, comment: str = "") -> bool:
"""
Close an issue with optional comment.
Args:
issue_number: The issue number to close
comment: Optional closing comment
Returns:
bool: True if issue was closed successfully, False otherwise
"""
try:
self.cli.close_issue(issue_number, comment)
return True
except TddaiError as e:
print(f"Error closing issue #{issue_number}: {e}")
return False
except Exception as e:
print(f"Unexpected error closing issue #{issue_number}: {e}")
return False
def close_with_completion_message(self, issue_number: int, completed_work: str) -> bool:
"""
Close an issue with a standardized completion message.
Args:
issue_number: The issue number to close
completed_work: Description of what was completed
Returns:
bool: True if issue was closed successfully, False otherwise
"""
completion_comment = f"✅ Completed: {completed_work}"
return self.close_issue(issue_number, completion_comment)
def close_multiple_issues(self, issue_numbers: list, comment: str = "") -> dict:
"""
Close multiple issues with the same comment.
Args:
issue_numbers: List of issue numbers to close
comment: Comment to add to all issues
Returns:
dict: Results with 'successful' and 'failed' lists
"""
results = {'successful': [], 'failed': []}
for issue_num in issue_numbers:
if self.close_issue(issue_num, comment):
results['successful'].append(issue_num)
else:
results['failed'].append(issue_num)
return results
def main():
"""Command-line interface for the issue closer."""
parser = argparse.ArgumentParser(
description="TDDAi Issue Closer - Close issues in the configured tracking backend",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 issue_closer.py 42 # Close issue #42
python3 issue_closer.py 42 -c "Fixed the bug" # Close with comment
python3 issue_closer.py 42 -w "All tests passing" # Close with completion message
python3 issue_closer.py 42 43 44 # Close multiple issues
python3 issue_closer.py 42 43 -c "Batch closure" # Close multiple with comment
Integration with existing tddai CLI:
python3 tddai_cli.py close-issue 42 --comment "Done" # Alternative method
"""
)
parser.add_argument(
'issue_numbers',
type=int,
nargs='+',
help='Issue number(s) to close'
)
parser.add_argument(
'-c', '--comment',
type=str,
default='',
help='Optional closing comment'
)
parser.add_argument(
'-w', '--work-completed',
type=str,
help='Description of completed work (creates standardized completion message)'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Enable verbose output'
)
args = parser.parse_args()
# Initialize issue closer
closer = IssueCloser()
# Determine which closing method to use
if args.work_completed:
comment = f"✅ Completed: {args.work_completed}"
else:
comment = args.comment
# Handle single or multiple issues
if len(args.issue_numbers) == 1:
issue_num = args.issue_numbers[0]
if args.verbose:
print(f"Closing issue #{issue_num}...")
if comment:
print(f"Comment: {comment}")
success = closer.close_issue(issue_num, comment)
sys.exit(0 if success else 1)
else:
# Multiple issues
if args.verbose:
print(f"Closing {len(args.issue_numbers)} issues...")
if comment:
print(f"Comment: {comment}")
results = closer.close_multiple_issues(args.issue_numbers, comment)
if results['successful']:
print(f"✅ Successfully closed: {results['successful']}")
if results['failed']:
print(f"❌ Failed to close: {results['failed']}")
sys.exit(1)
print(f"✅ All {len(args.issue_numbers)} issues closed successfully!")
if __name__ == '__main__':
main()

View File

@@ -1,211 +0,0 @@
"""
Issue creation using the Gitea facade.
This module now acts as an adapter to the new gitea package,
maintaining backwards compatibility while using the cleaner API.
"""
import os
from typing import Dict, Any, Optional, List
from gitea import GiteaClient, GiteaConfig, Priority
from .config import get_config
from .exceptions import IssueError
class IssueCreator:
"""Creates new issues using the Gitea facade."""
def __init__(self, config=None, auth_token=None):
self.config = config or get_config()
self.auth_token = auth_token or os.getenv('GITEA_API_TOKEN')
# Create Gitea client from tddai config
gitea_config = GiteaConfig.from_tddai_config(self.config)
if self.auth_token:
gitea_config.auth_token = self.auth_token
self.gitea_client = GiteaClient(gitea_config)
def create_issue(self, title: str, body: str, **kwargs) -> Dict[str, Any]:
"""Create a new issue via POST operation.
Args:
title: Issue title (required)
body: Issue description/body (required)
**kwargs: Optional fields (assignees, milestone, labels, etc.)
Returns:
Dict containing created issue data including issue number
Raises:
IssueError: If creation fails
"""
# Validate input
if not title or not title.strip():
raise IssueError("Issue title cannot be empty")
try:
issue = self.gitea_client.issues.create(
title=title,
body=body,
assignees=kwargs.get('assignees', []),
milestone=kwargs.get('milestone'),
labels=kwargs.get('labels', [])
)
# Convert back to dict format for backwards compatibility
return {
'number': issue.number,
'title': issue.title,
'body': issue.body,
'state': issue.state,
'html_url': issue.html_url,
'created_at': issue.created_at.isoformat(),
'updated_at': issue.updated_at.isoformat(),
'assignee': {'login': issue.assignee.login} if issue.assignee else None,
'labels': [{'name': label.name} for label in issue.labels]
}
except Exception as e:
raise IssueError(f"Failed to create issue: {e}")
def create_enhancement_issue(self, title: str, use_case: str,
technical_requirements: str = "",
acceptance_criteria: Optional[List[str]] = None,
dependencies: Optional[List[str]] = None,
priority: str = "Medium") -> Dict[str, Any]:
"""Create an enhancement issue with structured format.
Args:
title: Issue title
use_case: UseCase description
technical_requirements: Technical implementation details
acceptance_criteria: List of acceptance criteria
dependencies: List of dependency descriptions
priority: Priority level (High, Medium, Low)
Returns:
Dict containing created issue data
"""
# Build structured body
body_parts = [f"UseCase: {use_case}"]
if technical_requirements:
body_parts.extend([
"",
"Technical Requirements:",
technical_requirements
])
if acceptance_criteria:
body_parts.extend([
"",
"Acceptance Criteria:"
])
for criterion in acceptance_criteria:
body_parts.append(f"- [ ] {criterion}")
if dependencies:
body_parts.extend([
"",
"Dependencies:"
])
for dep in dependencies:
body_parts.append(f"- {dep}")
body = "\n".join(body_parts)
# Create with enhancement label
return self.create_issue(
title=title,
body=body,
labels=[priority.lower(), "enhancement"]
)
def create_bug_issue(self, title: str, description: str,
steps_to_reproduce: Optional[List[str]] = None,
expected_behavior: str = "",
actual_behavior: str = "",
environment: str = "") -> Dict[str, Any]:
"""Create a bug issue with structured format.
Args:
title: Bug title
description: Bug description
steps_to_reproduce: List of reproduction steps
expected_behavior: What should happen
actual_behavior: What actually happens
environment: Environment details
Returns:
Dict containing created issue data
"""
body_parts = [description]
if steps_to_reproduce:
body_parts.extend([
"",
"Steps to Reproduce:"
])
for i, step in enumerate(steps_to_reproduce, 1):
body_parts.append(f"{i}. {step}")
if expected_behavior:
body_parts.extend([
"",
f"Expected Behavior: {expected_behavior}"
])
if actual_behavior:
body_parts.extend([
"",
f"Actual Behavior: {actual_behavior}"
])
if environment:
body_parts.extend([
"",
f"Environment: {environment}"
])
body = "\n".join(body_parts)
# Create with bug label
return self.create_issue(
title=title,
body=body,
labels=["bug"]
)
def create_from_template(self, template_file: str, **template_vars) -> Dict[str, Any]:
"""Create issue from a template file.
Args:
template_file: Path to template file
**template_vars: Variables to substitute in template
Returns:
Dict containing created issue data
"""
try:
with open(template_file, 'r') as f:
template_content = f.read()
# Simple template variable substitution
for key, value in template_vars.items():
template_content = template_content.replace(f"{{{key}}}", str(value))
# Extract title (first line) and body (rest)
lines = template_content.strip().split('\n')
if not lines or (len(lines) == 1 and not lines[0].strip()):
raise IssueError("Template file is empty")
title = lines[0].replace('Title: ', '').strip()
body = '\n'.join(lines[1:]).strip()
return self.create_issue(title=title, body=body)
except FileNotFoundError:
raise IssueError(f"Template file not found: {template_file}")
except Exception as e:
raise IssueError(f"Failed to process template: {e}")

View File

@@ -1,92 +0,0 @@
"""
Issue fetching using the Gitea facade.
This module now acts as an adapter to the new gitea package,
maintaining backwards compatibility while using the cleaner API.
"""
from typing import List, Dict, Any
from gitea import GiteaClient, Issue as GiteaIssue, GiteaConfig
from gitea.exceptions import GiteaError, GiteaNotFoundError, GiteaAuthError, GiteaApiError
from .config import get_config
from .exceptions import IssueError
# Re-export Issue for backwards compatibility
Issue = GiteaIssue
class IssueFetcher:
"""Fetches issues using the Gitea facade."""
def __init__(self, config=None):
self.config = config or get_config()
# Create Gitea client from tddai config
gitea_config = GiteaConfig.from_tddai_config(self.config)
self.gitea_client = GiteaClient(gitea_config)
def fetch_issue(self, issue_number: int) -> Issue:
"""Fetch a specific issue by number.
Raises:
IssueError: When issue cannot be fetched (with specific context)
"""
try:
return self.gitea_client.issues.get(issue_number)
except GiteaNotFoundError as e:
raise IssueError(f"Issue #{issue_number} not found") from e
except GiteaAuthError as e:
raise IssueError(f"Authentication failed when fetching issue #{issue_number}") from e
except GiteaApiError as e:
raise IssueError(f"API error fetching issue #{issue_number}: {e}") from e
except GiteaError as e:
raise IssueError(f"Gitea error fetching issue #{issue_number}: {e}") from e
def fetch_issues(self, state: str = "all") -> List[Issue]:
"""Fetch all issues with optional state filter.
Args:
state: Issue state filter ("all", "open", "closed")
Raises:
IssueError: When issues cannot be fetched (with specific context)
"""
try:
return self.gitea_client.issues.list(state=state)
except GiteaAuthError as e:
raise IssueError("Authentication failed when fetching issues") from e
except GiteaApiError as e:
raise IssueError(f"API error fetching issues with state '{state}': {e}") from e
except GiteaError as e:
raise IssueError(f"Gitea error fetching issues: {e}") from e
def fetch_open_issues(self) -> List[Issue]:
"""Fetch only open issues.
Raises:
IssueError: When open issues cannot be fetched (with specific context)
"""
try:
return self.gitea_client.issues.list_open()
except GiteaAuthError as e:
raise IssueError("Authentication failed when fetching open issues") from e
except GiteaApiError as e:
raise IssueError(f"API error fetching open issues: {e}") from e
except GiteaError as e:
raise IssueError(f"Gitea error fetching open issues: {e}") from 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.login} if issue.assignee else None,
'labels': [{'name': label.name} for label in issue.labels]
}

View File

@@ -1,120 +0,0 @@
"""
Issue writing using the Gitea integration facade.
This module now acts as an adapter to the new gitea package,
maintaining backwards compatibility while using the cleaner API.
"""
import os
from typing import Dict, Any, Optional
from gitea import GiteaClient, GiteaConfig
from .config import get_config
from .exceptions import IssueError
class IssueWriter:
"""Writes issue updates using the Gitea integration facade."""
def __init__(self, config=None, auth_token=None):
self.config = config or get_config()
self.auth_token = auth_token or os.getenv('GITEA_API_TOKEN')
# Create Gitea client from tddai config
gitea_config = GiteaConfig.from_tddai_config(self.config)
if self.auth_token:
gitea_config.auth_token = self.auth_token
self.gitea_client = GiteaClient(gitea_config)
def update_issue(self, issue_number: int, update_data: Dict[str, Any]) -> Dict[str, Any]:
"""Update an issue via the gitea integration."""
if not self.auth_token:
raise IssueError("Authentication token required for issue updates")
try:
issue = self.gitea_client.issues.update(issue_number, **update_data)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to update issue #{issue_number}: {e}")
def update_issue_title(self, issue_number: int, new_title: str) -> Dict[str, Any]:
"""Update only the title of an issue."""
try:
issue = self.gitea_client.issues.update_title(issue_number, new_title)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to update issue title #{issue_number}: {e}")
def update_issue_body(self, issue_number: int, new_body: str) -> Dict[str, Any]:
"""Update only the body of an issue."""
try:
issue = self.gitea_client.issues.update_body(issue_number, new_body)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to update issue body #{issue_number}: {e}")
def update_issue_state(self, issue_number: int, new_state: str) -> Dict[str, Any]:
"""Update only the state of an issue (open/closed)."""
if new_state not in ['open', 'closed']:
raise IssueError(f"Invalid state '{new_state}'. Must be 'open' or 'closed'")
try:
if new_state == 'closed':
issue = self.gitea_client.issues.close(issue_number)
else:
issue = self.gitea_client.issues.reopen(issue_number)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to update issue state #{issue_number}: {e}")
def close_issue(self, issue_number: int) -> Dict[str, Any]:
"""Close an issue."""
return self.update_issue_state(issue_number, 'closed')
def reopen_issue(self, issue_number: int) -> Dict[str, Any]:
"""Reopen a closed issue."""
return self.update_issue_state(issue_number, 'open')
def assign_to_milestone(self, issue_number: int, milestone_id: int) -> Dict[str, Any]:
"""Assign issue to a milestone (project)."""
try:
issue = self.gitea_client.issues.assign_to_milestone(issue_number, milestone_id)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to assign issue #{issue_number} to milestone: {e}")
def remove_from_milestone(self, issue_number: int) -> Dict[str, Any]:
"""Remove issue from its current milestone."""
try:
issue = self.gitea_client.issues.remove_from_milestone(issue_number)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to remove issue #{issue_number} from milestone: {e}")
def update_labels(self, issue_number: int, labels: list) -> Dict[str, Any]:
"""Update issue labels completely."""
if not self.auth_token:
raise IssueError("Authentication token required for label updates")
try:
issue = self.gitea_client.issues.set_labels(issue_number, labels)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to update labels for issue #{issue_number}: {e}")
def add_labels(self, issue_number: int, new_labels: list) -> Dict[str, Any]:
"""Add labels to issue (preserving existing labels)."""
try:
issue = self.gitea_client.issues.add_labels(issue_number, new_labels)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to add labels to issue #{issue_number}: {e}")
def remove_labels(self, issue_number: int, labels_to_remove: list) -> Dict[str, Any]:
"""Remove specific labels from issue."""
try:
issue = self.gitea_client.issues.remove_labels(issue_number, labels_to_remove)
return self.gitea_client.issues.to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to remove labels from issue #{issue_number}: {e}")

View File

@@ -1,244 +0,0 @@
"""
Project management functionality using the Gitea facade.
This module now acts as an adapter to the new gitea package,
maintaining backwards compatibility while using the cleaner API.
"""
import os
from typing import Dict, Any, List, Optional
from gitea import GiteaClient, GiteaConfig
from gitea.models import ProjectState, Priority, Milestone as GiteaMilestone, Label as GiteaLabel
from gitea.exceptions import GiteaError, GiteaNotFoundError, GiteaAuthError, GiteaApiError
from .config import get_config
from .exceptions import IssueError
# Re-export for backwards compatibility
Milestone = GiteaMilestone
Label = GiteaLabel
class ProjectManager:
"""Manages project organization using the Gitea facade."""
def __init__(self, config=None, auth_token=None):
self.config = config or get_config()
self.auth_token = auth_token or os.getenv('GITEA_API_TOKEN')
# Create Gitea client from tddai config
gitea_config = GiteaConfig.from_tddai_config(self.config)
if self.auth_token:
gitea_config.auth_token = self.auth_token
self.gitea_client = GiteaClient(gitea_config)
def _make_api_call(self, method: str, url: str, data: Dict[str, Any] = None) -> Dict[str, Any]:
"""Make authenticated API call to Gitea (kept for backwards compatibility).
Args:
method: HTTP method (GET, POST, etc.)
url: API endpoint URL
data: Optional request data
Raises:
IssueError: When API call fails (with specific context)
"""
# This method is kept for backwards compatibility but now delegates to the gitea client
# For new code, use the gitea_client directly
try:
if method == 'GET' and 'issues' in url and url.endswith('/issues'):
issues = self.gitea_client.issues.list()
return [self._issue_to_dict(issue) for issue in issues]
elif method == 'GET' and '/issues/' in url and not url.endswith('/labels'):
issue_number = int(url.split('/issues/')[-1])
issue = self.gitea_client.issues.get(issue_number)
return self._issue_to_dict(issue)
else:
raise IssueError(f"Legacy API call not supported: {method} {url}")
except GiteaNotFoundError as e:
raise IssueError(f"Resource not found for {method} {url}") from e
except GiteaAuthError as e:
raise IssueError(f"Authentication failed for {method} {url}") from e
except GiteaApiError as e:
raise IssueError(f"API error for {method} {url}: {e}") from e
except GiteaError as e:
raise IssueError(f"Gitea error for {method} {url}: {e}") from e
except (ValueError, IndexError) as e:
raise IssueError(f"Invalid URL format for {method} {url}") from e
def _issue_to_dict(self, issue) -> Dict[str, Any]:
"""Convert Issue object to dict for backwards compatibility."""
return {
'number': issue.number,
'title': issue.title,
'body': issue.body,
'state': issue.state,
'html_url': issue.html_url,
'created_at': issue.created_at.isoformat(),
'updated_at': issue.updated_at.isoformat(),
'assignee': {'login': issue.assignee.login} if issue.assignee else None,
'labels': [{'name': label.name, 'color': label.color} for label in issue.labels]
}
# Milestone Management (Projects)
def create_milestone(self, title: str, description: str = "", due_date: Optional[str] = None) -> Milestone:
"""Create a new milestone (project)."""
try:
return self.gitea_client.milestones.create(title, description, due_date)
except Exception as e:
raise IssueError(f"Failed to create milestone: {e}")
def list_milestones(self, state: str = "open") -> List[Milestone]:
"""List all milestones (projects)."""
try:
if state == "all":
return self.gitea_client.milestones.list()
elif state == "open":
return self.gitea_client.milestones.list_open()
elif state == "closed":
return self.gitea_client.milestones.list_closed()
else:
return self.gitea_client.milestones.list(state)
except Exception as e:
raise IssueError(f"Failed to list milestones: {e}")
def update_milestone(self, milestone_id: int, **kwargs) -> Milestone:
"""Update milestone details."""
url = f"{self.config.gitea_url}/api/v1/repos/{self.config.repo_owner}/{self.config.repo_name}/milestones/{milestone_id}"
# Only include fields that can be updated
valid_fields = ['title', 'description', 'state', 'due_on']
data = {k: v for k, v in kwargs.items() if k in valid_fields}
response = self._make_api_call('PATCH', url, data)
return Milestone(
id=response['id'],
title=response['title'],
description=response.get('description', ''),
state=response['state'],
open_issues=response['open_issues'],
closed_issues=response['closed_issues'],
due_on=response.get('due_on')
)
def close_milestone(self, milestone_id: int) -> Milestone:
"""Close a milestone (complete project)."""
return self.update_milestone(milestone_id, state='closed')
# Label Management (States & Priority)
def create_label(self, name: str, color: str, description: str = "") -> Label:
"""Create a new label."""
url = f"{self.config.gitea_url}/api/v1/repos/{self.config.repo_owner}/{self.config.repo_name}/labels"
data = {
'name': name,
'color': color,
'description': description
}
response = self._make_api_call('POST', url, data)
return Label(
id=response['id'],
name=response['name'],
color=response['color'],
description=response.get('description', '')
)
def list_labels(self) -> List[Label]:
"""List all repository labels."""
url = f"{self.config.gitea_url}/api/v1/repos/{self.config.repo_owner}/{self.config.repo_name}/labels"
response = self._make_api_call('GET', url)
return [
Label(
id=l['id'],
name=l['name'],
color=l['color'],
description=l.get('description', '')
)
for l in response
]
def ensure_project_labels(self) -> None:
"""Ensure all required project management labels exist."""
try:
self.gitea_client.labels.ensure_project_labels()
except Exception as e:
raise IssueError(f"Failed to ensure project labels: {e}")
def list_labels(self) -> List[Label]:
"""List all repository labels."""
try:
return self.gitea_client.labels.list()
except Exception as e:
raise IssueError(f"Failed to list labels: {e}")
def create_label(self, name: str, color: str, description: str = "") -> Label:
"""Create a new label."""
try:
return self.gitea_client.labels.create(name, color, description)
except Exception as e:
raise IssueError(f"Failed to create label: {e}")
# Project Management Operations
def assign_issue_to_milestone(self, issue_number: int, milestone_id: int) -> Dict[str, Any]:
"""Assign issue to a milestone (project)."""
url = f"{self.config.issues_api_url}/{issue_number}"
data = {'milestone': milestone_id}
return self._make_api_call('PATCH', url, data)
def set_issue_state(self, issue_number: int, state: ProjectState) -> Dict[str, Any]:
"""Set issue project state using labels."""
try:
issue = self.gitea_client.issues.set_status(issue_number, state)
return self._issue_to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to set issue state: {e}")
def set_issue_priority(self, issue_number: int, priority: Priority) -> Dict[str, Any]:
"""Set issue priority using labels."""
try:
issue = self.gitea_client.issues.set_priority(issue_number, priority)
return self._issue_to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to set issue priority: {e}")
def move_issue_to_done(self, issue_number: int) -> Dict[str, Any]:
"""Move issue to done state and close it."""
try:
# Set state to done
self.set_issue_state(issue_number, ProjectState.DONE)
# Close the issue
issue = self.gitea_client.issues.close(issue_number)
return self._issue_to_dict(issue)
except Exception as e:
raise IssueError(f"Failed to move issue to done: {e}")
def get_project_overview(self) -> Dict[str, Any]:
"""Get overview of project status."""
milestones = self.list_milestones("all")
labels = self.list_labels()
# Count issues by state
state_counts = {}
for state in ProjectState:
state_counts[state.value] = 0
# This would require fetching all issues to count by labels
# For now, return milestone overview
return {
'milestones': len(milestones),
'active_projects': len([m for m in milestones if m.state == 'open']),
'completed_projects': len([m for m in milestones if m.state == 'closed']),
'total_labels': len(labels),
'project_management_ready': len([l for l in labels if l.name.startswith('status:')]) > 0
}

View File

@@ -1,163 +0,0 @@
"""
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"))

View File

@@ -1,238 +0,0 @@
"""
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.strptime(issue_data['created_at'].replace('Z', '').split('.')[0], '%Y-%m-%dT%H:%M:%S'),
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)
def add_test_to_workspace(self, test_filename: str, test_content: str) -> None:
"""Add a test file to the current workspace."""
workspace = self.get_current_workspace()
if not workspace:
raise WorkspaceError("No active workspace. Create a workspace first.")
test_file_path = workspace.tests_dir / test_filename
# Ensure tests directory exists
workspace.tests_dir.mkdir(parents=True, exist_ok=True)
# Write test content to file
with open(test_file_path, 'w') as f:
f.write(test_content)
def get_workspace_status(self) -> WorkspaceStatus:
"""Alias for get_status() for API compatibility."""
return self.get_status()

View File

@@ -1,342 +0,0 @@
#!/usr/bin/env python3
"""
CLI interface for tddai library.
This module now uses the separated architecture with services and presenters.
Business logic is handled by services, presentation by CLI framework.
"""
import sys
import argparse
from pathlib import Path
from typing import Optional, Any
# Add current directory to path so we can import modules
sys.path.insert(0, str(Path(__file__).parent))
from cli import CLIFramework
# Lazy initialization of CLI framework
_cli_framework: Optional[CLIFramework] = None
def _get_cli() -> CLIFramework:
"""Get CLI framework instance (lazy initialization)."""
global _cli_framework
if _cli_framework is None:
_cli_framework = CLIFramework()
return _cli_framework
def workspace_status() -> None:
"""Show current workspace status."""
_get_cli().workspace_status()
def start_issue(issue_number: int) -> None:
"""Start working on an issue."""
_get_cli().start_issue(issue_number)
def finish_issue() -> None:
"""Finish current issue workspace."""
_get_cli().finish_issue()
def add_test_guidance() -> None:
"""Show guidance for adding tests."""
_get_cli().add_test_guidance()
def list_issues() -> None:
"""List all issues."""
_get_cli().list_issues()
def list_open_issues() -> None:
"""List only open issues."""
_get_cli().list_open_issues()
def show_issue(issue_number: int) -> None:
"""Show detailed issue information."""
_get_cli().show_issue(issue_number)
def create_issue(title: str, body: str, issue_type: str = "enhancement") -> None:
"""Create a new issue."""
_get_cli().create_issue(title, body, issue_type)
def create_enhancement_issue(title: str, use_case: str, technical_requirements: str = "",
acceptance_criteria: str = "", dependencies: str = "",
priority: str = "Medium") -> None:
"""Create a structured enhancement issue."""
# Parse acceptance criteria if provided
criteria_list = []
if acceptance_criteria:
criteria_list = [line.strip() for line in acceptance_criteria.split('\n') if line.strip()]
# Parse dependencies if provided
deps_list = []
if dependencies:
deps_list = [line.strip() for line in dependencies.split('\n') if line.strip()]
_get_cli().create_enhancement_issue(
title=title,
use_case=use_case,
technical_requirements=technical_requirements,
acceptance_criteria=criteria_list,
dependencies=deps_list,
priority=priority
)
def create_from_template(template_file: str, **kwargs: Any) -> None:
"""Create issue from template file."""
_get_cli().create_from_template(template_file, **kwargs)
def close_issue(issue_number: int, comment: str = "") -> None:
"""Close an issue with optional comment."""
_get_cli().close_issue(issue_number, comment)
def analyze_coverage(issue_number: int) -> None:
"""Analyze test coverage for a specific issue."""
_get_cli().analyze_coverage(issue_number)
def setup_project_management() -> None:
"""Setup project management labels and milestones."""
_get_cli().setup_project_management()
def move_issue_to_state(issue_number: int, state: str) -> None:
"""Move issue to a specific project state."""
_get_cli().move_issue_to_state(issue_number, state)
def set_issue_priority(issue_number: int, priority: str) -> None:
"""Set issue priority."""
_get_cli().set_issue_priority(issue_number, priority)
def create_milestone(title: str, description: str = "") -> None:
"""Create a new milestone (project)."""
_get_cli().create_milestone(title, description)
def list_milestones() -> None:
"""List all milestones."""
_get_cli().list_milestones()
def assign_issue_to_milestone(issue_number: int, milestone_id: int) -> None:
"""Assign issue to a milestone."""
_get_cli().assign_issue_to_milestone(issue_number, milestone_id)
def project_overview() -> None:
"""Show project management overview."""
_get_cli().project_overview()
def issue_index(format_type: str = "tsv", sort_by: str = "number", filter_state: Optional[str] = None, filter_priority: Optional[str] = None, include_state: bool = False) -> None:
"""Output compact index of all issues for Unix processing."""
_get_cli().issue_index(
format_type=format_type,
sort_by=sort_by,
filter_state=filter_state,
filter_priority=filter_priority,
include_state=include_state
)
def show_config(show_sensitive: bool = False) -> None:
"""Display current configuration values."""
_get_cli().show_config(show_sensitive)
def validate_config(verbose: bool = False) -> None:
"""Validate current configuration and show any issues."""
_get_cli().validate_config(verbose)
def troubleshoot_config() -> None:
"""Run comprehensive configuration troubleshooting."""
_get_cli().troubleshoot_config()
def check_config_files() -> None:
"""Check for configuration files and their status."""
_get_cli().check_config_files()
def main() -> None:
"""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')
index_parser = subparsers.add_parser('issue-index', help='Output compact issue index for Unix processing')
index_parser.add_argument('--format', choices=['tsv', 'csv', 'json', 'fields'], default='tsv',
help='Output format (default: tsv)')
index_parser.add_argument('--sort', choices=['number', 'title', 'priority', 'state', 'created', 'updated'],
default='number', help='Sort by field (default: number)')
index_parser.add_argument('--filter-state', choices=['open', 'closed'],
help='Filter by issue state')
index_parser.add_argument('--filter-priority', choices=['low', 'medium', 'high', 'critical', 'none'],
help='Filter by priority level')
index_parser.add_argument('--include-state', action='store_true',
help='Include state column in output')
show_parser = subparsers.add_parser('show-issue', help='Show issue details')
show_parser.add_argument('issue_number', type=int, help='Issue number')
coverage_parser = subparsers.add_parser('analyze-coverage', help='Analyze test coverage for issue')
coverage_parser.add_argument('issue_number', type=int, help='Issue number')
close_parser = subparsers.add_parser('close-issue', help='Close an issue')
close_parser.add_argument('issue_number', type=int, help='Issue number')
close_parser.add_argument('--comment', help='Optional closing comment', default='')
# Issue creation commands
create_parser = subparsers.add_parser('create-issue', help='Create a new issue')
create_parser.add_argument('title', help='Issue title')
create_parser.add_argument('body', help='Issue body/description')
create_parser.add_argument('--type', choices=['enhancement', 'bug'], default='enhancement', help='Issue type')
create_enh_parser = subparsers.add_parser('create-enhancement', help='Create a structured enhancement issue')
create_enh_parser.add_argument('title', help='Issue title')
create_enh_parser.add_argument('use_case', help='UseCase description')
create_enh_parser.add_argument('--technical', help='Technical requirements', default='')
create_enh_parser.add_argument('--criteria', help='Acceptance criteria (newline separated)', default='')
create_enh_parser.add_argument('--dependencies', help='Dependencies (newline separated)', default='')
create_enh_parser.add_argument('--priority', choices=['High', 'Medium', 'Low'], default='Medium', help='Priority level')
template_parser = subparsers.add_parser('create-from-template', help='Create issue from template')
template_parser.add_argument('template_file', help='Template file path')
template_parser.add_argument('--vars', help='Template variables in key=value format', nargs='*', default=[])
# Project management commands
subparsers.add_parser('setup-project-mgmt', help='Setup project management labels and milestones')
subparsers.add_parser('project-overview', help='Show project management overview')
state_parser = subparsers.add_parser('set-issue-state', help='Set issue project state')
state_parser.add_argument('issue_number', type=int, help='Issue number')
state_parser.add_argument('state', choices=['todo', 'active', 'review', 'done', 'blocked'], help='Project state')
priority_parser = subparsers.add_parser('set-issue-priority', help='Set issue priority')
priority_parser.add_argument('issue_number', type=int, help='Issue number')
priority_parser.add_argument('priority', choices=['low', 'medium', 'high', 'critical'], help='Priority level')
milestone_parser = subparsers.add_parser('create-milestone', help='Create a new milestone (project)')
milestone_parser.add_argument('title', help='Milestone title')
milestone_parser.add_argument('--description', help='Milestone description', default='')
subparsers.add_parser('list-milestones', help='List all milestones')
assign_parser = subparsers.add_parser('assign-to-milestone', help='Assign issue to milestone')
assign_parser.add_argument('issue_number', type=int, help='Issue number')
assign_parser.add_argument('milestone_id', type=int, help='Milestone ID')
# Configuration management commands
config_show_parser = subparsers.add_parser('config-show', help='Display current configuration values')
config_show_parser.add_argument('--show-sensitive', action='store_true', help='Show sensitive information like masked tokens')
config_validate_parser = subparsers.add_parser('config-validate', help='Validate current configuration')
config_validate_parser.add_argument('--verbose', '-v', action='store_true', help='Show detailed validation results')
subparsers.add_parser('config-troubleshoot', help='Run comprehensive configuration troubleshooting')
subparsers.add_parser('config-files', help='Check configuration files status')
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 == 'issue-index':
issue_index(
format_type=args.format,
sort_by=args.sort,
filter_state=args.filter_state,
filter_priority=args.filter_priority,
include_state=args.include_state
)
elif args.command == 'show-issue':
show_issue(args.issue_number)
elif args.command == 'analyze-coverage':
analyze_coverage(args.issue_number)
elif args.command == 'close-issue':
close_issue(args.issue_number, args.comment)
elif args.command == 'create-issue':
create_issue(args.title, args.body, args.type)
elif args.command == 'create-enhancement':
create_enhancement_issue(
args.title, args.use_case, args.technical,
args.criteria, args.dependencies, args.priority
)
elif args.command == 'create-from-template':
# Parse template variables
template_vars = {}
for var in args.vars:
if '=' in var:
key, value = var.split('=', 1)
template_vars[key] = value
create_from_template(args.template_file, **template_vars)
elif args.command == 'setup-project-mgmt':
setup_project_management()
elif args.command == 'project-overview':
project_overview()
elif args.command == 'set-issue-state':
move_issue_to_state(args.issue_number, args.state)
elif args.command == 'set-issue-priority':
set_issue_priority(args.issue_number, args.priority)
elif args.command == 'create-milestone':
create_milestone(args.title, args.description)
elif args.command == 'list-milestones':
list_milestones()
elif args.command == 'assign-to-milestone':
assign_issue_to_milestone(args.issue_number, args.milestone_id)
elif args.command == 'config-show':
show_config(args.show_sensitive)
elif args.command == 'config-validate':
validate_config(args.verbose)
elif args.command == 'config-troubleshoot':
troubleshoot_config()
elif args.command == 'config-files':
check_config_files()
except KeyboardInterrupt:
print("\n⚠️ Operation cancelled")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -1,411 +0,0 @@
#!/usr/bin/env python3
"""
CLI Consolidation Integration Tests
Tests to ensure proper CLI interface consolidation and prevent regression
of missing CLI commands. This test suite verifies:
1. All CLI entry points are properly installed
2. No functionality duplication between CLIs
3. Each CLI has clear separation of concerns
4. Help commands work for all CLIs
5. Core functionality is accessible
Purpose: Prevent the loss of CLI interfaces that occurred previously
due to lack of testing.
"""
import pytest
import subprocess
import shutil
import sys
from pathlib import Path
class TestCLIConsolidation:
"""Test suite for CLI consolidation and interface availability."""
def test_all_cli_commands_installed(self):
"""Ensure all CLI commands are properly installed and accessible."""
# Test that all three CLI commands exist
markitect_path = shutil.which("markitect")
tddai_path = shutil.which("tddai")
issue_path = shutil.which("issue")
# If not found in PATH, check if we're in a virtual environment
if markitect_path is None or tddai_path is None or issue_path is None:
# Check if we're in a virtual environment
venv_path = sys.prefix
if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix):
# We're in a virtual environment, check the bin directory
venv_bin = Path(venv_path) / "bin"
if not markitect_path:
markitect_path = venv_bin / "markitect" if (venv_bin / "markitect").exists() else None
if not tddai_path:
tddai_path = venv_bin / "tddai" if (venv_bin / "tddai").exists() else None
if not issue_path:
issue_path = venv_bin / "issue" if (venv_bin / "issue").exists() else None
assert markitect_path is not None, "markitect CLI command not found - check pyproject.toml scripts"
assert tddai_path is not None, "tddai CLI command not found - check pyproject.toml scripts"
assert issue_path is not None, "issue CLI command not found - check pyproject.toml scripts"
def test_cli_help_commands_work(self):
"""Verify help commands work for all CLIs without errors."""
cli_commands = ["markitect", "tddai", "issue"]
for cmd in cli_commands:
try:
# Try direct command first
result = subprocess.run(
[cmd, "--help"],
capture_output=True,
text=True,
timeout=30
)
assert result.returncode == 0, f"{cmd} --help failed with exit code {result.returncode}"
assert len(result.stdout) > 100, f"{cmd} --help produced minimal output: {result.stdout[:200]}"
except FileNotFoundError:
# Fallback: try running via python -m if command not found in PATH
try:
if cmd == "markitect":
result = subprocess.run(
[sys.executable, "-m", "markitect.cli", "--help"],
capture_output=True,
text=True,
timeout=30
)
elif cmd == "tddai":
result = subprocess.run(
[sys.executable, "-m", "tddai_cli", "--help"],
capture_output=True,
text=True,
timeout=30
)
elif cmd == "issue":
result = subprocess.run(
[sys.executable, "-m", "cli.issue_cli", "--help"],
capture_output=True,
text=True,
timeout=30
)
assert result.returncode == 0, f"{cmd} --help failed with exit code {result.returncode}"
assert len(result.stdout) > 100, f"{cmd} --help produced minimal output: {result.stdout[:200]}"
except subprocess.TimeoutExpired:
pytest.fail(f"{cmd} --help timed out")
except FileNotFoundError:
pytest.fail(f"{cmd} command not found and module execution failed")
except subprocess.TimeoutExpired:
pytest.fail(f"{cmd} --help timed out")
def test_markitect_focuses_on_documents(self):
"""Verify markitect CLI focuses on document processing, not issues."""
result = subprocess.run(
["markitect", "--help"],
capture_output=True,
text=True
)
help_text = result.stdout.lower()
# Should have document-related commands
document_keywords = ["md-ingest", "query", "template", "cache", "perf"]
for keyword in document_keywords:
assert keyword in help_text, f"markitect should include {keyword} functionality"
# Should have issue commands alongside dedicated CLIs for unified access
# NOTE: markitect provides issues group for unified interface while dedicated CLIs provide specialized access
assert "issues" in help_text, "markitect should include issues functionality for unified access"
def test_tddai_focuses_on_workflow(self):
"""Verify tddai CLI focuses on TDD workflow management."""
result = subprocess.run(
["tddai", "--help"],
capture_output=True,
text=True
)
help_text = result.stdout.lower()
# Should have TDD workflow commands
tdd_keywords = ["start-issue", "finish-issue", "workspace", "coverage", "test"]
for keyword in tdd_keywords:
assert keyword in help_text, f"tddai should include {keyword} functionality"
def test_issue_focuses_on_issue_management(self):
"""Verify issue CLI focuses purely on issue operations."""
result = subprocess.run(
["issue", "--help"],
capture_output=True,
text=True
)
help_text = result.stdout.lower()
# Should have issue management commands
issue_keywords = ["list", "show", "create", "close", "assign", "priority"]
for keyword in issue_keywords:
assert keyword in help_text, f"issue CLI should include {keyword} functionality"
def test_cli_separation_of_concerns(self):
"""Ensure CLIs maintain appropriate separation of concerns while allowing unified access."""
# Get help text for all CLIs
markitect_help = subprocess.run(["markitect", "--help"], capture_output=True, text=True).stdout
tddai_help = subprocess.run(["tddai", "--help"], capture_output=True, text=True).stdout
issue_help = subprocess.run(["issue", "--help"], capture_output=True, text=True).stdout
# markitect should have both document processing AND issues (unified interface)
assert "md-ingest" in markitect_help, "markitect should have document processing"
assert "issues" in markitect_help, "markitect should have unified issues access"
# tddai should focus on workflow
assert "workspace" in tddai_help.lower(), "tddai should have workflow features"
assert "start-issue" in tddai_help, "tddai should have TDD workflow"
# issue CLI should focus on pure issue management
assert "list" in issue_help, "issue CLI should have list functionality"
assert "create" in issue_help, "issue CLI should have create functionality"
def test_cli_integration_imports(self):
"""Test that CLI modules can be imported without errors."""
try:
# Test tddai_cli import
import tddai_cli
assert hasattr(tddai_cli, 'main'), "tddai_cli should have main() function"
# Test issue CLI import
from cli import issue_cli
assert hasattr(issue_cli, 'main'), "issue_cli should have main() function"
# Test markitect CLI import
from markitect import cli as markitect_cli
assert hasattr(markitect_cli, 'main'), "markitect.cli should have main() function"
except ImportError as e:
pytest.fail(f"CLI import failed: {e}")
def test_cli_framework_integration(self):
"""Test that the CLI framework is properly integrated."""
try:
from cli.core import CLIFramework
# Initialize framework (should not raise errors)
framework = CLIFramework()
# Test that key methods exist
required_methods = [
'list_issues', 'show_issue', 'close_issue', 'create_issue',
'start_issue', 'finish_issue', 'workspace_status'
]
for method in required_methods:
assert hasattr(framework, method), f"CLIFramework missing method: {method}"
except Exception as e:
pytest.fail(f"CLI framework integration failed: {e}")
def test_make_targets_work(self):
"""Test that Makefile targets work with the new CLI structure."""
# Test that make targets exist for issue operations
makefile_path = Path(__file__).parent.parent / "Makefile"
if makefile_path.exists():
makefile_content = makefile_path.read_text()
# Check for issue-related targets (using new issue- prefix convention)
expected_targets = [
"issue-close", "issue-close-enhanced", "issue-close-batch",
"issue-list", "issue-show"
]
for target in expected_targets:
assert target in makefile_content, f"Makefile missing target: {target}"
def test_pyproject_toml_entries(self):
"""Test that pyproject.toml has correct CLI entry points."""
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
if pyproject_path.exists():
content = pyproject_path.read_text()
# Check for all three CLI entry points
expected_entries = [
'markitect = "markitect.cli:main"',
'tddai = "tddai_cli:main"',
'issue = "cli.issue_cli:main"'
]
for entry in expected_entries:
assert entry in content, f"pyproject.toml missing entry: {entry}"
class TestCLIFunctionality:
"""Comprehensive functional tests for all CLI commands."""
def test_markitect_document_commands(self):
"""Test markitect document processing commands."""
# Test that markitect has core document commands
result = subprocess.run(["markitect", "--help"], capture_output=True, text=True)
help_text = result.stdout
# Core document processing commands should be present
expected_commands = [
"md-ingest", "md-list", "md-get", "stats", "metadata",
"schema-generate", "template-render", "perf-benchmark"
]
for cmd in expected_commands:
assert cmd in help_text, f"markitect missing document command: {cmd}"
def test_tddai_workflow_commands(self):
"""Test tddai TDD workflow commands."""
result = subprocess.run(["tddai", "--help"], capture_output=True, text=True)
help_text = result.stdout
# TDD workflow commands should be present
expected_commands = [
"workspace-status", "start-issue", "finish-issue",
"list-issues", "close-issue", "analyze-coverage"
]
for cmd in expected_commands:
assert cmd in help_text, f"tddai missing workflow command: {cmd}"
def test_issue_management_commands(self):
"""Test issue CLI management commands."""
result = subprocess.run(["issue", "--help"], capture_output=True, text=True)
help_text = result.stdout
# Issue management commands should be present
expected_commands = [
"list", "show", "create", "close",
"assign", "priority", "state", "export"
]
for cmd in expected_commands:
assert cmd in help_text, f"issue CLI missing command: {cmd}"
def test_cli_subcommand_help(self):
"""Test that subcommands have proper help text."""
test_cases = [
("tddai", "list-issues", "--help"),
("issue", "list", "--help"),
("markitect", "stats", "--help"),
]
for cli, subcommand, help_flag in test_cases:
try:
result = subprocess.run(
[cli, subcommand, help_flag],
capture_output=True,
text=True,
timeout=10
)
# Should either succeed or show usage (not crash)
assert result.returncode in [0, 2], f"{cli} {subcommand} help failed"
assert len(result.stdout) > 10 or len(result.stderr) > 10, f"{cli} {subcommand} no help output"
except subprocess.TimeoutExpired:
pytest.fail(f"{cli} {subcommand} --help timed out")
def test_cli_error_handling(self):
"""Test that CLIs handle invalid commands gracefully."""
test_cases = [
("tddai", "invalid-command"),
("issue", "invalid-command"),
("markitect", "invalid-command"),
]
for cli, invalid_cmd in test_cases:
result = subprocess.run(
[cli, invalid_cmd],
capture_output=True,
text=True
)
# Should fail gracefully, not crash
assert result.returncode != 0, f"{cli} should reject invalid command {invalid_cmd}"
# Should have error output
assert len(result.stderr) > 0 or "error" in result.stdout.lower(), f"{cli} should show error for {invalid_cmd}"
def test_cli_list_commands_functional(self):
"""Test that list commands actually work."""
# Test that list commands don't crash
test_cases = [
("tddai", "list-issues"),
("issue", "list"),
("markitect", "md-list"),
]
for cli, list_cmd in test_cases:
try:
result = subprocess.run(
[cli, list_cmd],
capture_output=True,
text=True,
timeout=30
)
# Should not crash (may return empty list)
assert result.returncode == 0, f"{cli} {list_cmd} failed with exit code {result.returncode}"
except subprocess.TimeoutExpired:
pytest.fail(f"{cli} {list_cmd} timed out - may be hanging")
def test_cli_configuration_access(self):
"""Test that CLIs can access configuration."""
# Test config-related commands
result = subprocess.run(
["tddai", "config-show"],
capture_output=True,
text=True,
timeout=15
)
# Should not crash (may show config or error message)
assert result.returncode in [0, 1], "tddai config-show should handle config access"
class TestCLIRegression:
"""Tests to prevent regression of CLI functionality."""
def test_prevent_cli_loss(self):
"""Prevent loss of CLI commands (primary regression test)."""
# This is the main test that should have prevented the original issue
required_clis = ["markitect", "tddai", "issue"]
for cli in required_clis:
# Test that command exists
assert shutil.which(cli) is not None, f"REGRESSION: {cli} CLI lost - not installed"
# Test that command responds
result = subprocess.run([cli, "--help"], capture_output=True, text=True)
assert result.returncode == 0, f"REGRESSION: {cli} CLI broken - help fails"
def test_core_issue_operations_accessible(self):
"""Ensure core issue operations remain accessible through some CLI."""
# Test that basic issue operations are available
core_operations = [
("list issues", ["tddai", "list-issues"]),
("show issue", ["tddai", "show-issue", "42"]), # Will fail but should parse
("close issue", ["tddai", "close-issue", "42"]) # Will fail but should parse
]
for operation_name, cmd in core_operations:
try:
# We expect these to fail (no real issue 42), but the CLI should parse the command
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10
)
# Command should be recognized (not return "unknown command" error)
assert "unknown" not in result.stderr.lower(), f"{operation_name} not accessible via CLI"
except subprocess.TimeoutExpired:
# Timeout is okay - means command is running
pass
if __name__ == '__main__':
pytest.main([__file__, "-v"])

View File

@@ -1,627 +0,0 @@
"""
Tests for Issue #113 - Issue Activity Tracking Implementation
This module contains comprehensive tests for the issue activity tracking
service and CLI commands that log, retrieve, and manage issue activities
for cost allocation and project management.
"""
import pytest
import sqlite3
from datetime import datetime, date
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
import tempfile
import json
import csv
import io
from contextlib import redirect_stdout
from markitect.issues.activity_tracker import IssueActivityTracker, ActivityType, IssueActivity
from markitect.issues.activity_commands import activity
class TestActivityType:
"""Test suite for ActivityType enumeration."""
def test_activity_type_values(self):
"""Test that all expected activity types are available."""
expected_types = {
"created", "modified", "closed", "reopened", "commented", "status_changed"
}
actual_types = {at.value for at in ActivityType}
assert actual_types == expected_types
def test_activity_type_enumeration(self):
"""Test that ActivityType can be constructed from string values."""
assert ActivityType("created") == ActivityType.CREATED
assert ActivityType("modified") == ActivityType.MODIFIED
assert ActivityType("closed") == ActivityType.CLOSED
class TestIssueActivity:
"""Test suite for IssueActivity dataclass."""
def test_issue_activity_creation(self):
"""Test that IssueActivity objects can be created properly."""
activity = IssueActivity(
id=1,
issue_id=59,
activity_type=ActivityType.CREATED,
activity_date=date.today(),
activity_details="Issue created"
)
assert activity.id == 1
assert activity.issue_id == 59
assert activity.activity_type == ActivityType.CREATED
assert activity.activity_date == date.today()
assert activity.activity_details == "Issue created"
def test_issue_activity_defaults(self):
"""Test that IssueActivity has proper default values."""
activity = IssueActivity()
assert activity.id is None
assert activity.issue_id is None
assert activity.activity_type is None
assert activity.activity_date is None
assert activity.period_id is None
assert activity.activity_details is None
assert activity.created_at is None
class TestIssueActivityTracker:
"""Test suite for IssueActivityTracker service."""
def setup_method(self):
"""Set up test fixtures with temporary database."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
self.tracker = IssueActivityTracker(self.db_path)
def teardown_method(self):
"""Clean up test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
def test_tracker_initialization(self):
"""Test that tracker initializes properly with database."""
assert self.tracker.db_path == self.db_path
assert self.tracker.finance_models is not None
# Verify database schema was created
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='issue_activity_log'")
assert cursor.fetchone() is not None
def test_log_activity_basic(self):
"""Test logging a basic activity."""
activity_id = self.tracker.log_activity(
issue_id=59,
activity_type=ActivityType.CREATED,
activity_details="Test issue created"
)
assert activity_id is not None
# Verify activity was stored
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM issue_activity_log WHERE id = ?", (activity_id,))
row = cursor.fetchone()
assert row is not None
assert row[1] == 59 # issue_id
assert row[2] == "created" # activity_type
assert row[5] == "Test issue created" # activity_details
def test_log_activity_with_custom_date(self):
"""Test logging activity with custom date."""
custom_date = date(2025, 10, 1)
activity_id = self.tracker.log_activity(
issue_id=59,
activity_type=ActivityType.MODIFIED,
activity_date=custom_date
)
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT activity_date FROM issue_activity_log WHERE id = ?", (activity_id,))
stored_date = cursor.fetchone()[0]
assert stored_date == "2025-10-01"
def test_log_activity_with_period_id(self):
"""Test logging activity with specific period ID."""
# First create a cost period
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-10-01', '2025-10-31', 1000.00)
""")
period_id = cursor.lastrowid
activity_id = self.tracker.log_activity(
issue_id=59,
activity_type=ActivityType.CREATED,
period_id=period_id
)
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT period_id FROM issue_activity_log WHERE id = ?", (activity_id,))
stored_period_id = cursor.fetchone()[0]
assert stored_period_id == period_id
def test_get_issue_activities(self):
"""Test retrieving activities for a specific issue."""
# Log multiple activities
self.tracker.log_activity(59, ActivityType.CREATED, activity_details="Created")
self.tracker.log_activity(59, ActivityType.MODIFIED, activity_details="Modified")
self.tracker.log_activity(60, ActivityType.CREATED, activity_details="Different issue")
activities = self.tracker.get_issue_activities(59)
assert len(activities) == 2
assert all(a.issue_id == 59 for a in activities)
assert activities[0].activity_type in [ActivityType.CREATED, ActivityType.MODIFIED]
def test_get_issue_activities_with_limit(self):
"""Test retrieving activities with limit and offset."""
# Log multiple activities
for i in range(5):
self.tracker.log_activity(59, ActivityType.MODIFIED, activity_details=f"Update {i}")
activities = self.tracker.get_issue_activities(59, limit=2, offset=1)
assert len(activities) == 2
def test_get_activities_by_period(self):
"""Test retrieving activities by cost period."""
# Create a cost period
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-10-01', '2025-10-31', 1000.00)
""")
period_id = cursor.lastrowid
# Log activities in different periods
self.tracker.log_activity(59, ActivityType.CREATED, period_id=period_id)
self.tracker.log_activity(60, ActivityType.MODIFIED, period_id=period_id)
# Log activity outside the period date range
self.tracker.log_activity(61, ActivityType.CLOSED, activity_date=date(2025, 11, 1))
activities = self.tracker.get_activities_by_period(period_id)
assert len(activities) == 2
assert all(a.period_id == period_id for a in activities)
def test_get_activities_by_period_with_type_filter(self):
"""Test retrieving activities by period with type filtering."""
# Create period
with self.tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-10-01', '2025-10-31', 1000.00)
""")
period_id = cursor.lastrowid
# Log various activities
self.tracker.log_activity(59, ActivityType.CREATED, period_id=period_id)
self.tracker.log_activity(60, ActivityType.MODIFIED, period_id=period_id)
self.tracker.log_activity(61, ActivityType.CLOSED, period_id=period_id)
activities = self.tracker.get_activities_by_period(
period_id,
activity_types=[ActivityType.CREATED, ActivityType.CLOSED]
)
assert len(activities) == 2
assert all(a.activity_type in [ActivityType.CREATED, ActivityType.CLOSED] for a in activities)
def test_get_activity_summary_basic(self):
"""Test basic activity summary generation."""
# Log some test activities
self.tracker.log_activity(59, ActivityType.CREATED)
self.tracker.log_activity(59, ActivityType.MODIFIED)
self.tracker.log_activity(60, ActivityType.CREATED)
summary = self.tracker.get_activity_summary()
assert summary['total_activities'] == 3
assert summary['unique_issues'] == 2
assert 'created' in summary['activities_by_type']
assert 'modified' in summary['activities_by_type']
assert summary['activities_by_type']['created'] == 2
assert summary['activities_by_type']['modified'] == 1
def test_get_activity_summary_with_filters(self):
"""Test activity summary with date and issue filters."""
today = date.today()
yesterday = date(today.year, today.month, today.day - 1) if today.day > 1 else date(today.year, today.month - 1, 28)
# Log activities on different dates
self.tracker.log_activity(59, ActivityType.CREATED, activity_date=yesterday)
self.tracker.log_activity(59, ActivityType.MODIFIED, activity_date=today)
self.tracker.log_activity(60, ActivityType.CREATED, activity_date=today)
# Test issue filter
summary = self.tracker.get_activity_summary(issue_id=59)
assert summary['total_activities'] == 2
assert summary['unique_issues'] == 1
# Test date filter
summary = self.tracker.get_activity_summary(start_date=today)
assert summary['total_activities'] == 2
def test_delete_activity(self):
"""Test deleting an activity record."""
activity_id = self.tracker.log_activity(59, ActivityType.CREATED)
# Verify activity exists
activities = self.tracker.get_issue_activities(59)
assert len(activities) == 1
# Delete activity
result = self.tracker.delete_activity(activity_id)
assert result is True
# Verify activity is gone
activities = self.tracker.get_issue_activities(59)
assert len(activities) == 0
def test_delete_nonexistent_activity(self):
"""Test deleting non-existent activity returns False."""
result = self.tracker.delete_activity(99999)
assert result is False
def test_bulk_log_activities(self):
"""Test logging multiple activities in one transaction."""
activities_data = [
{
'issue_id': 59,
'activity_type': 'created',
'activity_details': 'Bulk created'
},
{
'issue_id': 60,
'activity_type': 'modified',
'activity_details': 'Bulk modified'
}
]
activity_ids = self.tracker.bulk_log_activities(activities_data)
assert len(activity_ids) == 2
assert all(isinstance(aid, int) for aid in activity_ids)
# Verify activities were created
activities_59 = self.tracker.get_issue_activities(59)
activities_60 = self.tracker.get_issue_activities(60)
assert len(activities_59) == 1
assert len(activities_60) == 1
assert activities_59[0].activity_details == 'Bulk created'
assert activities_60[0].activity_details == 'Bulk modified'
def test_bulk_log_activities_validation(self):
"""Test bulk logging validates required fields."""
invalid_data = [
{'issue_id': 59}, # Missing activity_type
{'activity_type': 'created'} # Missing issue_id
]
with pytest.raises(ValueError, match="must have 'issue_id' and 'activity_type'"):
self.tracker.bulk_log_activities(invalid_data)
class TestActivityCommands:
"""Test suite for activity CLI commands."""
def setup_method(self):
"""Set up test fixtures."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
# Initialize database with test data
tracker = IssueActivityTracker(self.db_path)
tracker.log_activity(59, ActivityType.CREATED, activity_details="Test issue created")
tracker.log_activity(59, ActivityType.MODIFIED, activity_details="Test issue modified")
def teardown_method(self):
"""Clean up test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_log_command_basic(self, mock_tracker_class):
"""Test the log command with basic parameters."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_tracker.log_activity.return_value = 123
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['log', '59', 'created'])
assert result.exit_code == 0
assert "✅ Logged created activity for issue #59" in result.output
mock_tracker.log_activity.assert_called_once()
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_log_command_with_details(self, mock_tracker_class):
"""Test the log command with activity details."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_tracker.log_activity.return_value = 123
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['log', '59', 'created', '--details', 'Test details'])
assert result.exit_code == 0
assert "Test details" in result.output
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_show_command(self, mock_tracker_class):
"""Test the show command for displaying issue activities."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_activities = [
IssueActivity(
id=1,
issue_id=59,
activity_type=ActivityType.CREATED,
activity_date=date.today(),
activity_details="Test activity",
created_at=datetime.now()
)
]
mock_tracker.get_issue_activities.return_value = mock_activities
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['show', '59'])
assert result.exit_code == 0
assert "📋 Activities for Issue #59" in result.output
assert "Test activity" in result.output
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_show_command_json_format(self, mock_tracker_class):
"""Test the show command with JSON output format."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_activities = [
IssueActivity(
id=1,
issue_id=59,
activity_type=ActivityType.CREATED,
activity_date=date.today(),
activity_details="Test activity",
created_at=datetime.now()
)
]
mock_tracker.get_issue_activities.return_value = mock_activities
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['show', '59', '--format', 'json'])
assert result.exit_code == 0
# Should be valid JSON
output_data = json.loads(result.output.strip())
assert len(output_data) == 1
assert output_data[0]['issue_id'] == 59
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_summary_command(self, mock_tracker_class):
"""Test the summary command."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_summary = {
'total_activities': 5,
'unique_issues': 3,
'activities_by_type': {'created': 3, 'modified': 2},
'date_range': {'start': '2025-10-01', 'end': '2025-10-04'},
'filters': {'issue_id': None, 'start_date': None, 'end_date': None}
}
mock_tracker.get_activity_summary.return_value = mock_summary
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['summary'])
assert result.exit_code == 0
assert "📊 Issue Activity Summary" in result.output
assert "Total Activities: 5" in result.output
assert "Unique Issues: 3" in result.output
@patch('markitect.issues.activity_commands.IssueActivityTracker')
def test_delete_command(self, mock_tracker_class):
"""Test the delete command."""
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_tracker.delete_activity.return_value = True
from click.testing import CliRunner
runner = CliRunner()
# Auto-confirm the deletion
result = runner.invoke(activity, ['delete', '123'], input='y\n')
assert result.exit_code == 0
assert "✅ Deleted activity #123" in result.output
mock_tracker.delete_activity.assert_called_once_with(123)
def test_import_activities_json(self):
"""Test importing activities from JSON file."""
# Create test JSON file
test_data = [
{
'issue_id': 59,
'activity_type': 'created',
'activity_details': 'Imported activity'
}
]
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
json.dump(test_data, f)
json_file_path = f.name
try:
with patch('markitect.issues.activity_commands.IssueActivityTracker') as mock_tracker_class:
mock_tracker = Mock()
mock_tracker_class.return_value = mock_tracker
mock_tracker.bulk_log_activities.return_value = [1]
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(activity, ['import-activities', json_file_path])
assert result.exit_code == 0
assert "✅ Successfully imported 1 activities" in result.output
finally:
Path(json_file_path).unlink(missing_ok=True)
class TestActivityIntegration:
"""Integration tests for the complete activity tracking system."""
def setup_method(self):
"""Set up integration test fixtures."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
def teardown_method(self):
"""Clean up integration test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
def test_full_activity_lifecycle(self):
"""Test the complete lifecycle of activity tracking."""
tracker = IssueActivityTracker(self.db_path)
# 1. Log initial activity
activity_id = tracker.log_activity(
issue_id=59,
activity_type=ActivityType.CREATED,
activity_details="Issue created for testing"
)
assert activity_id is not None
# 2. Log follow-up activities (with slight time differences to ensure ordering)
import time
time.sleep(0.1)
tracker.log_activity(59, ActivityType.MODIFIED, activity_details="Updated description")
time.sleep(0.1)
tracker.log_activity(59, ActivityType.COMMENTED, activity_details="Added comment")
time.sleep(0.1)
tracker.log_activity(59, ActivityType.CLOSED, activity_details="Resolved issue")
# 3. Retrieve issue history
activities = tracker.get_issue_activities(59)
assert len(activities) == 4
# Verify all expected activity types are present
activity_types = [a.activity_type.value for a in activities]
expected_types = {'closed', 'commented', 'modified', 'created'}
assert set(activity_types) == expected_types
# 4. Generate summary
summary = tracker.get_activity_summary(issue_id=59)
assert summary['total_activities'] == 4
assert summary['unique_issues'] == 1
assert len(summary['activities_by_type']) == 4
# 5. Clean up - delete an activity
deleted = tracker.delete_activity(activity_id)
assert deleted is True
# Verify deletion
remaining_activities = tracker.get_issue_activities(59)
assert len(remaining_activities) == 3
def test_multi_issue_activity_tracking(self):
"""Test activity tracking across multiple issues."""
tracker = IssueActivityTracker(self.db_path)
# Log activities for multiple issues
issues = [59, 60, 61, 62]
for issue_id in issues:
tracker.log_activity(issue_id, ActivityType.CREATED)
if issue_id % 2 == 0: # Even issues get modified
tracker.log_activity(issue_id, ActivityType.MODIFIED)
# Test overall summary
summary = tracker.get_activity_summary()
assert summary['total_activities'] == 6 # 4 created + 2 modified
assert summary['unique_issues'] == 4
assert summary['activities_by_type']['created'] == 4
assert summary['activities_by_type']['modified'] == 2
# Test individual issue tracking
for issue_id in issues:
activities = tracker.get_issue_activities(issue_id)
expected_count = 2 if issue_id % 2 == 0 else 1
assert len(activities) == expected_count
def test_cost_period_integration(self):
"""Test integration with cost period functionality."""
tracker = IssueActivityTracker(self.db_path)
# Create cost periods
with tracker.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-01-01', '2025-03-31', 5000.00)
""")
q1_period_id = cursor.lastrowid
cursor.execute("""
INSERT INTO cost_periods (period_start, period_end, total_costs)
VALUES ('2025-04-01', '2025-06-30', 6000.00)
""")
q2_period_id = cursor.lastrowid
periods = {'Q1 2025': q1_period_id, 'Q2 2025': q2_period_id}
# Log activities in different periods
tracker.log_activity(59, ActivityType.CREATED, period_id=periods['Q1 2025'])
tracker.log_activity(60, ActivityType.MODIFIED, period_id=periods['Q1 2025'])
tracker.log_activity(61, ActivityType.CLOSED, period_id=periods['Q2 2025'])
# Test period-based retrieval
q1_activities = tracker.get_activities_by_period(periods['Q1 2025'])
q2_activities = tracker.get_activities_by_period(periods['Q2 2025'])
assert len(q1_activities) == 2
assert len(q2_activities) == 1
assert all(a.period_id == periods['Q1 2025'] for a in q1_activities)
assert all(a.period_id == periods['Q2 2025'] for a in q2_activities)
# Test filtering by activity type within period
q1_created = tracker.get_activities_by_period(
periods['Q1 2025'],
activity_types=[ActivityType.CREATED]
)
assert len(q1_created) == 1
assert q1_created[0].activity_type == ActivityType.CREATED

View File

@@ -1,809 +0,0 @@
"""
Tests for Issue #114 - Cost Allocation Engine Implementation
This module contains comprehensive tests for the cost allocation engine
that distributes operational costs across active issues according to the
algorithm defined in Issue #88.
Tests cover:
- Core allocation algorithm with equal distribution
- Edge cases (no active issues, no costs, closed periods)
- Transaction audit trail creation
- Loss carried forward handling
- Integration with existing cost and activity tracking
- CLI command functionality
- Allocation reversal capabilities
"""
import pytest
import sqlite3
import tempfile
from datetime import datetime, date, timedelta
from decimal import Decimal
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
import json
from contextlib import redirect_stdout
import io
from markitect.finance.allocation_engine import (
AllocationEngine, TransactionManager, AllocationStatus,
AllocationResult, IssueAllocation
)
from markitect.finance.models import FinanceModels
from markitect.finance.cost_manager import CostItemManager, CostItem
from markitect.finance.period_manager import PeriodManager, PeriodStatus
from markitect.issues.activity_tracker import IssueActivityTracker, ActivityType
def create_test_cost_item(cost_manager, name, category_id, cost_type, amount_eur, starting_from_date):
"""Helper function to create cost items with proper interface."""
cost_item = CostItem(
name=name,
category_id=category_id,
cost_type=cost_type,
amount_eur=amount_eur,
starting_from_date=starting_from_date
)
return cost_manager.create_cost_item(cost_item)
def create_unique_category(cost_manager, base_name, description="Test category"):
"""Helper function to create categories with unique names."""
import time
unique_name = f"{base_name}-{int(time.time()*1000000)}"
return cost_manager.create_category(unique_name, description)
class TestTransactionManager:
"""Test suite for TransactionManager class."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def transaction_manager(self, temp_db):
"""Create TransactionManager instance for testing."""
return TransactionManager(temp_db)
@pytest.fixture
def sample_period(self, temp_db):
"""Create a sample period for testing."""
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
return period_id
def test_transaction_manager_initialization(self, temp_db):
"""Test that TransactionManager initializes properly."""
manager = TransactionManager(temp_db)
assert manager.db_path == temp_db
assert isinstance(manager.finance_models, FinanceModels)
def test_create_allocation_transaction(self, transaction_manager, sample_period):
"""Test creating allocation transaction records."""
transaction_id = transaction_manager.create_allocation_transaction(
period_id=sample_period,
amount=Decimal('15.50'),
issue_id=123,
transaction_date=date(2025, 10, 15),
description="Test allocation transaction"
)
assert transaction_id is not None
assert isinstance(transaction_id, int)
# Verify transaction was created
with transaction_manager.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT * FROM cost_transactions WHERE id = ?',
(transaction_id,)
)
row = cursor.fetchone()
assert row is not None
assert row[1] == sample_period # period_id
assert row[3] == 'cost_allocated' # transaction_type
assert float(row[4]) == 15.50 # amount_eur
assert row[5] == 123 # issue_id
def test_create_loss_forward_transaction(self, transaction_manager, sample_period):
"""Test creating loss carried forward transactions."""
# Create next period
period_manager = PeriodManager(transaction_manager.db_path)
next_period_id = period_manager.create_period(
period_start=date(2025, 11, 1),
period_end=date(2025, 11, 30)
)
transaction_id = transaction_manager.create_loss_forward_transaction(
from_period_id=sample_period,
to_period_id=next_period_id,
amount=Decimal('25.75'),
transaction_date=date(2025, 11, 1),
description="Loss carried forward"
)
assert transaction_id is not None
# Verify transaction was created
with transaction_manager.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT * FROM cost_transactions WHERE id = ?',
(transaction_id,)
)
row = cursor.fetchone()
assert row is not None
assert row[1] == next_period_id # period_id
assert row[3] == 'loss_forward' # transaction_type
assert float(row[4]) == 25.75 # amount_eur
assert row[5] is None # issue_id (null for loss forward)
class TestAllocationEngine:
"""Test suite for AllocationEngine class."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def allocation_engine(self, temp_db):
"""Create AllocationEngine instance for testing."""
return AllocationEngine(temp_db)
@pytest.fixture
def sample_costs(self, temp_db):
"""Create sample cost items for testing."""
cost_manager = CostItemManager(temp_db)
# Create cost category
category_id = create_unique_category(cost_manager, "Test Services", "Test cost category")
# Create cost items
cost_ids = []
cost_ids.append(create_test_cost_item(
cost_manager, "Monthly Service", category_id, "monthly",
Decimal('20.00'), date(2025, 10, 1)
))
cost_ids.append(create_test_cost_item(
cost_manager, "One-time Setup", category_id, "one_time",
Decimal('30.00'), date(2025, 10, 15)
))
return cost_ids
@pytest.fixture
def sample_period_with_costs(self, temp_db, sample_costs):
"""Create a period with associated costs."""
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
return period_id
@pytest.fixture
def sample_issue_activities(self, temp_db, sample_period_with_costs):
"""Create sample issue activities for testing."""
activity_tracker = IssueActivityTracker(temp_db)
# Log activities for different issues
activity_ids = []
activity_ids.append(activity_tracker.log_activity(
issue_id=101,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
period_id=sample_period_with_costs
))
activity_ids.append(activity_tracker.log_activity(
issue_id=102,
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 10),
period_id=sample_period_with_costs
))
activity_ids.append(activity_tracker.log_activity(
issue_id=103,
activity_type=ActivityType.COMMENTED,
activity_date=date(2025, 10, 20),
period_id=sample_period_with_costs
))
return activity_ids
def test_allocation_engine_initialization(self, temp_db):
"""Test that AllocationEngine initializes with all required components."""
engine = AllocationEngine(temp_db)
assert engine.db_path == temp_db
assert isinstance(engine.finance_models, FinanceModels)
assert isinstance(engine.cost_manager, CostItemManager)
assert isinstance(engine.period_manager, PeriodManager)
assert isinstance(engine.activity_tracker, IssueActivityTracker)
assert isinstance(engine.transaction_manager, TransactionManager)
def test_successful_allocation(self, allocation_engine, sample_period_with_costs, sample_issue_activities):
"""Test successful cost allocation with active issues."""
result = allocation_engine.allocate_period_costs(sample_period_with_costs)
assert result.status == AllocationStatus.SUCCESS
assert result.period_id == sample_period_with_costs
assert result.total_costs == Decimal('50.00') # 20.00 + 30.00
assert len(result.active_issues) == 3 # Issues 101, 102, 103
assert result.cost_per_issue == Decimal('50.00') / 3
assert result.allocations_created == 3
assert result.transactions_created == 3
assert result.loss_carried_forward == Decimal('0.00')
def test_allocation_no_active_issues(self, allocation_engine, sample_period_with_costs):
"""Test allocation when no active issues exist (should carry forward loss)."""
result = allocation_engine.allocate_period_costs(sample_period_with_costs)
assert result.status == AllocationStatus.NO_ACTIVE_ISSUES
assert result.period_id == sample_period_with_costs
assert result.total_costs == Decimal('50.00')
assert len(result.active_issues) == 0
assert result.cost_per_issue == Decimal('0.00')
assert result.allocations_created == 0
assert result.transactions_created == 0
assert result.loss_carried_forward == Decimal('50.00')
def test_allocation_no_costs(self, allocation_engine):
"""Test allocation when no costs exist for period."""
# Create empty period
period_manager = PeriodManager(allocation_engine.db_path)
period_id = period_manager.create_period(
period_start=date(2025, 11, 1),
period_end=date(2025, 11, 30)
)
result = allocation_engine.allocate_period_costs(period_id)
assert result.status == AllocationStatus.NO_COSTS_TO_ALLOCATE
assert result.total_costs == Decimal('0.00')
def test_allocation_period_closed(self, allocation_engine, sample_period_with_costs):
"""Test allocation on already closed period."""
# First allocation (should succeed)
result1 = allocation_engine.allocate_period_costs(sample_period_with_costs)
assert result1.status in [AllocationStatus.SUCCESS, AllocationStatus.NO_ACTIVE_ISSUES]
# Second allocation (should fail - period closed)
result2 = allocation_engine.allocate_period_costs(sample_period_with_costs)
assert result2.status == AllocationStatus.PERIOD_CLOSED
def test_allocation_period_not_found(self, allocation_engine):
"""Test allocation with non-existent period ID."""
result = allocation_engine.allocate_period_costs(99999)
assert result.status == AllocationStatus.ERROR
assert "not found" in result.message
def test_get_issue_allocations(self, allocation_engine, sample_period_with_costs, sample_issue_activities):
"""Test retrieving allocations for a specific issue."""
# Perform allocation first
allocation_engine.allocate_period_costs(sample_period_with_costs)
# Get allocations for issue 101
allocations = allocation_engine.get_issue_allocations(101)
assert len(allocations) == 1
allocation = allocations[0]
assert allocation['issue_id'] == 101
assert allocation['period_id'] == sample_period_with_costs
assert allocation['allocated_amount'] > 0
assert allocation['transaction_id'] is not None
def test_get_period_allocations(self, allocation_engine, sample_period_with_costs, sample_issue_activities):
"""Test retrieving all allocations for a specific period."""
# Perform allocation first
allocation_engine.allocate_period_costs(sample_period_with_costs)
# Get all allocations for the period
allocations = allocation_engine.get_period_allocations(sample_period_with_costs)
assert len(allocations) == 3
issue_ids = [alloc['issue_id'] for alloc in allocations]
assert 101 in issue_ids
assert 102 in issue_ids
assert 103 in issue_ids
def test_reverse_allocation(self, allocation_engine, sample_period_with_costs, sample_issue_activities):
"""Test reversing a cost allocation."""
# Perform allocation first
allocation_engine.allocate_period_costs(sample_period_with_costs)
# Get allocation to reverse
allocations = allocation_engine.get_issue_allocations(101)
assert len(allocations) == 1
allocation_id = allocations[0]['id']
# Reverse the allocation
success = allocation_engine.reverse_allocation(allocation_id)
assert success is True
# Verify allocation is removed
allocations_after = allocation_engine.get_issue_allocations(101)
assert len(allocations_after) == 0
def test_reverse_nonexistent_allocation(self, allocation_engine):
"""Test reversing non-existent allocation."""
success = allocation_engine.reverse_allocation(99999)
assert success is False
def test_allocation_with_carried_forward_loss(self, allocation_engine, temp_db):
"""Test allocation including loss carried forward from previous period."""
# Create period with carried forward loss
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31),
loss_carried_forward=Decimal('15.00')
)
# Create some costs
cost_manager = CostItemManager(temp_db)
category_id = create_unique_category(cost_manager, "Test", "Test category")
create_test_cost_item(
cost_manager, "Test Cost", category_id, "one_time",
Decimal('10.00'), date(2025, 10, 15)
)
# Create issue activity
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=201,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 10),
period_id=period_id
)
# Perform allocation
result = allocation_engine.allocate_period_costs(period_id)
assert result.status == AllocationStatus.SUCCESS
assert result.total_costs == Decimal('25.00') # 10.00 + 15.00 carried forward
assert len(result.active_issues) == 1
assert result.cost_per_issue == Decimal('25.00')
class TestAllocationIntegration:
"""Integration tests for allocation engine with other components."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
def test_complete_allocation_workflow(self, temp_db):
"""Test complete workflow from cost creation to allocation."""
# Step 1: Create cost categories and items
cost_manager = CostItemManager(temp_db)
category_id = create_unique_category(cost_manager, "Infrastructure", "Server and hosting costs")
monthly_cost_id = create_test_cost_item(
cost_manager, "Server Hosting", category_id, "monthly",
Decimal('25.00'), date(2025, 10, 1)
)
oneoff_cost_id = create_test_cost_item(
cost_manager, "SSL Certificate", category_id, "one_time",
Decimal('15.00'), date(2025, 10, 10)
)
# Step 2: Create period
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
# Step 3: Log issue activities
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=301,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=302,
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 12),
period_id=period_id
)
# Step 4: Perform allocation
allocation_engine = AllocationEngine(temp_db)
result = allocation_engine.allocate_period_costs(period_id)
# Verify allocation success
assert result.status == AllocationStatus.SUCCESS
assert result.total_costs == Decimal('40.00')
assert len(result.active_issues) == 2
assert result.cost_per_issue == Decimal('20.00')
# Step 5: Verify database state
# Check allocations exist
allocations_301 = allocation_engine.get_issue_allocations(301)
allocations_302 = allocation_engine.get_issue_allocations(302)
assert len(allocations_301) == 1
assert len(allocations_302) == 1
assert allocations_301[0]['allocated_amount'] == 20.00
assert allocations_302[0]['allocated_amount'] == 20.00
# Check transactions exist
with allocation_engine.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT COUNT(*) FROM cost_transactions WHERE transaction_type = "cost_allocated"'
)
transaction_count = cursor.fetchone()[0]
assert transaction_count == 2
# Check period is closed
period_data = period_manager.get_period_by_id(period_id)
periods = [period_data] if period_data else []
assert len(periods) == 1
assert periods[0]['status'] == PeriodStatus.CLOSED.value
assert float(periods[0]['total_costs']) == 40.00
assert periods[0]['active_issues_count'] == 2
assert float(periods[0]['cost_per_issue']) == 20.00
def test_multi_period_allocation_workflow(self, temp_db):
"""Test allocation across multiple periods with loss carry forward."""
# Create cost items
cost_manager = CostItemManager(temp_db)
category_id = create_unique_category(cost_manager, "Services", "Monthly services")
create_test_cost_item(
cost_manager, "Monthly Service", category_id, "monthly",
Decimal('30.00'), date(2025, 10, 1)
)
# Create periods
period_manager = PeriodManager(temp_db)
period1_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
period2_id = period_manager.create_period(
period_start=date(2025, 11, 1),
period_end=date(2025, 11, 30)
)
# Period 1: No activities (should carry forward loss)
allocation_engine = AllocationEngine(temp_db)
result1 = allocation_engine.allocate_period_costs(period1_id)
assert result1.status == AllocationStatus.NO_ACTIVE_ISSUES
assert result1.loss_carried_forward == Decimal('30.00')
# Period 2: Add activity and allocate
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=401,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 11, 15),
period_id=period2_id
)
# Manually set carried forward for period 2 (simulating automatic carry forward)
with allocation_engine.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
'UPDATE cost_periods SET loss_carried_forward = ? WHERE id = ?',
(float(Decimal('30.00')), period2_id)
)
result2 = allocation_engine.allocate_period_costs(period2_id)
assert result2.status == AllocationStatus.SUCCESS
assert result2.total_costs == Decimal('60.00') # 30.00 current + 30.00 carried forward
assert len(result2.active_issues) == 1
assert result2.cost_per_issue == Decimal('60.00')
class TestAllocationCLI:
"""Test suite for allocation CLI commands."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def setup_test_data(self, temp_db):
"""Set up test data for CLI testing."""
# Create costs and period
cost_manager = CostItemManager(temp_db)
category_id = create_unique_category(cost_manager, "Test", "Test category")
create_test_cost_item(
cost_manager, "Test Cost", category_id, "one_time",
Decimal('45.00'), date(2025, 10, 15)
)
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
# Create issue activities
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=501,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=502,
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 15),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=503,
activity_type=ActivityType.COMMENTED,
activity_date=date(2025, 10, 25),
period_id=period_id
)
return period_id
def test_cli_allocation_period_command(self, temp_db, setup_test_data):
"""Test CLI allocation period command."""
from markitect.finance.cli import allocate_period
from click.testing import CliRunner
runner = CliRunner()
# Mock configuration manager
with patch('markitect.finance.cli.ConfigurationManager') as mock_config_manager:
mock_config = Mock()
mock_config.get_current_config.return_value = {'database_path': temp_db}
mock_config_manager.return_value = mock_config
result = runner.invoke(allocate_period, [str(setup_test_data)])
assert result.exit_code == 0
assert "Cost Allocation Complete" in result.output
assert "Total Costs Allocated: €45.00" in result.output
assert "Active Issues: 3" in result.output
assert "Cost Per Issue: €15.00" in result.output
def test_cli_show_allocations_command(self, temp_db, setup_test_data):
"""Test CLI show allocations command."""
from markitect.finance.cli import show_allocations
from click.testing import CliRunner
# First perform allocation
allocation_engine = AllocationEngine(temp_db)
allocation_engine.allocate_period_costs(setup_test_data)
runner = CliRunner()
# Mock configuration manager
with patch('markitect.finance.cli.ConfigurationManager') as mock_config_manager:
mock_config = Mock()
mock_config.get_current_config.return_value = {'database_path': temp_db}
mock_config_manager.return_value = mock_config
# Test issue allocations
result = runner.invoke(show_allocations, ['issue:501'])
assert result.exit_code == 0
assert "Cost Allocations for Issue #501" in result.output
assert "€15.00" in result.output
# Test period allocations
result = runner.invoke(show_allocations, [f'period:{setup_test_data}'])
assert result.exit_code == 0
assert f"Cost Allocations for Period {setup_test_data}" in result.output
assert "Total: 3 allocations" in result.output
class TestAllocationEdgeCases:
"""Test edge cases and error conditions."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
def test_allocation_with_very_small_amounts(self, temp_db):
"""Test allocation with very small cost amounts."""
# Create tiny cost
cost_manager = CostItemManager(temp_db)
category_id = cost_manager.create_category("Test", "Test")
create_test_cost_item(
cost_manager, "Tiny Cost", category_id, "one_time",
Decimal('0.01'), date(2025, 10, 15) # 1 cent
)
# Create period and activities
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=601,
activity_type=ActivityType.CREATED,
period_id=period_id
)
activity_tracker.log_activity(
issue_id=602,
activity_type=ActivityType.MODIFIED,
period_id=period_id
)
activity_tracker.log_activity(
issue_id=603,
activity_type=ActivityType.COMMENTED,
period_id=period_id
)
# Perform allocation
allocation_engine = AllocationEngine(temp_db)
result = allocation_engine.allocate_period_costs(period_id)
assert result.status == AllocationStatus.SUCCESS
assert result.total_costs == Decimal('0.01')
# With 3 issues, each gets 0.01/3 = 0.0033... which should be handled properly
expected_per_issue = Decimal('0.01') / 3
assert abs(result.cost_per_issue - expected_per_issue) < Decimal('0.0001')
def test_allocation_with_duplicate_activities_same_issue(self, temp_db):
"""Test that duplicate activities for same issue don't create multiple allocations."""
# Create cost and period
cost_manager = CostItemManager(temp_db)
category_id = cost_manager.create_category("Test", "Test")
create_test_cost_item(
cost_manager, "Test Cost", category_id, "one_time",
Decimal('30.00'), date(2025, 10, 15)
)
period_manager = PeriodManager(temp_db)
period_id = period_manager.create_period(
period_start=date(2025, 10, 1),
period_end=date(2025, 10, 31)
)
# Create multiple activities for same issue
activity_tracker = IssueActivityTracker(temp_db)
activity_tracker.log_activity(
issue_id=701,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=701, # Same issue
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 10),
period_id=period_id
)
activity_tracker.log_activity(
issue_id=701, # Same issue again
activity_type=ActivityType.COMMENTED,
activity_date=date(2025, 10, 15),
period_id=period_id
)
# Perform allocation
allocation_engine = AllocationEngine(temp_db)
result = allocation_engine.allocate_period_costs(period_id)
assert result.status == AllocationStatus.SUCCESS
assert len(result.active_issues) == 1 # Only one unique issue
assert result.active_issues[0] == 701
assert result.cost_per_issue == Decimal('30.00') # Full amount to single issue
assert result.allocations_created == 1 # Only one allocation
def test_database_constraint_violations(self, temp_db):
"""Test handling of database constraint violations."""
allocation_engine = AllocationEngine(temp_db)
# Try to create duplicate allocation manually
with allocation_engine.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Create period first
cursor.execute('''
INSERT INTO cost_periods (period_start, period_end)
VALUES ('2025-10-01', '2025-10-31')
''')
period_id = cursor.lastrowid
# Create first allocation
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (801, ?, 10.00, '2025-10-15')
''', (period_id,))
# Try to create duplicate (should fail due to unique constraint)
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO issue_cost_allocations
(issue_id, period_id, allocated_amount, allocation_date)
VALUES (801, ?, 20.00, '2025-10-16')
''', (period_id,))
if __name__ == '__main__':
pytest.main([__file__])

View File

@@ -1,562 +0,0 @@
"""
Comprehensive test suite for Issue #123 - Single command issue wrap-up.
Tests the IssueWrapUpService and CLI commands that provide comprehensive
issue completion automation including requirement validation, test execution,
cost tracking, git operations, and issue closure.
"""
import pytest
import tempfile
import json
import subprocess
from datetime import date, datetime
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
from click.testing import CliRunner
from markitect.issues.issue_wrapup_commands import IssueWrapUpService, issue_wrapup
class TestIssueWrapUpService:
"""Test cases for the IssueWrapUpService class."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize database with required tables
try:
from markitect.finance.models import FinanceModels
from markitect.issues.activity_tracker import IssueActivityTracker
# Initialize models to create tables
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
activity_tracker = IssueActivityTracker(db_path)
yield db_path
finally:
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def service(self, temp_db):
"""Create IssueWrapUpService instance with temp database."""
return IssueWrapUpService(db_path=temp_db)
def test_service_initialization(self, service):
"""Test service initializes correctly with all required components."""
assert service.db_path is not None
assert service.worktime_tracker is not None
assert service.activity_tracker is not None
assert service.session_tracker is not None
assert service.cost_manager is not None
assert service.issue_manager is not None
@patch('markitect.issues.issue_wrapup_commands.IssuePluginManager')
def test_get_issue_details_success(self, mock_manager, service):
"""Test successful issue details retrieval."""
# Mock the backend response
mock_backend = Mock()
mock_manager.return_value.get_backend.return_value = mock_backend
result = service._get_issue_details(123)
assert result is not None
assert result['number'] == 123
assert 'title' in result
assert 'status' in result
def test_get_issue_details_failure(self, service):
"""Test issue details retrieval failure."""
with patch.object(service.issue_manager, 'get_backend') as mock_get_backend:
mock_get_backend.side_effect = Exception("Backend error")
result = service._get_issue_details(123)
assert result is None
def test_review_requirements_with_activities(self, service):
"""Test requirement review when issue has activities."""
# Mock activity tracker to return some activities
from markitect.issues.activity_tracker import IssueActivity, ActivityType
with patch.object(service.activity_tracker, 'get_issue_activities') as mock_activities:
mock_activities.return_value = [
IssueActivity(
id=1,
issue_id=123,
activity_type=ActivityType.CREATED,
activity_details='Implemented feature'
),
IssueActivity(
id=2,
issue_id=123,
activity_type=ActivityType.MODIFIED,
activity_details='Added tests'
)
]
result = service._review_requirements(123, {'title': 'Test Issue'}, False)
assert result['success'] is True
assert result['activities_count'] == 2
assert result['has_implementation_activity'] is True
def test_review_requirements_forced(self, service):
"""Test requirement review with force flag."""
result = service._review_requirements(123, {'title': 'Test Issue'}, True)
assert result['success'] is True
assert result['forced'] is True
def test_review_requirements_no_activities(self, service):
"""Test requirement review when issue has no activities."""
with patch.object(service.activity_tracker, 'get_issue_activities') as mock_activities:
mock_activities.return_value = []
result = service._review_requirements(123, {'title': 'Test Issue'}, False)
assert result['success'] is False
assert result['activities_count'] == 0
@patch('subprocess.run')
@patch('pathlib.Path.glob')
def test_run_issue_tests_success(self, mock_glob, mock_run, service):
"""Test successful issue-specific test execution."""
# Mock test files found - only one pattern should match
mock_test_file = Mock()
mock_test_file.__str__ = Mock(return_value='tests/test_issue_123.py')
mock_glob.side_effect = [[mock_test_file], []] # First pattern matches, second doesn't
# Mock successful subprocess run
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = "All tests passed"
mock_result.stderr = ""
mock_run.return_value = mock_result
result = service._run_issue_tests(123, False)
assert result['success'] is True
assert len(result['test_files']) == 1
assert result['test_files'][0] == 'tests/test_issue_123.py'
@patch('pathlib.Path.glob')
def test_run_issue_tests_no_files_found(self, mock_glob, service):
"""Test issue test execution when no test files exist."""
mock_glob.return_value = []
result = service._run_issue_tests(123, False)
assert result['success'] is True # No tests is not a failure
assert len(result['test_files']) == 0
def test_run_issue_tests_forced(self, service):
"""Test issue test execution with force flag."""
with patch('pathlib.Path.glob') as mock_glob:
mock_test_file = Mock()
mock_test_file.__str__ = Mock(return_value='tests/test_issue_123.py')
mock_glob.return_value = [mock_test_file]
result = service._run_issue_tests(123, True)
assert result['success'] is True
assert 'FORCED' in result['output'][0]
@patch('subprocess.run')
def test_run_full_tests_success(self, mock_run, service):
"""Test successful full test suite execution."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = "All tests passed"
mock_result.stderr = ""
mock_run.return_value = mock_result
result = service._run_full_tests(False)
assert result['success'] is True
assert 'command' in result
assert result['returncode'] == 0
def test_run_full_tests_forced(self, service):
"""Test full test suite execution with force flag."""
result = service._run_full_tests(True)
assert result['success'] is True
assert result['forced'] is True
def test_update_cost_tracking(self, service):
"""Test cost tracking data calculation."""
# Mock the various trackers using available methods
with patch.object(service.activity_tracker, 'get_issue_activities') as mock_activities:
mock_activities.return_value = [{'id': 1}, {'id': 2}]
# Mock session_tracker if the method doesn't exist
if not hasattr(service.session_tracker, 'get_issue_costs'):
with patch.object(service.session_tracker, 'get_issue_costs', create=True) as mock_costs:
mock_costs.return_value = [{'cost_eur': 10.50}, {'cost_eur': 5.25}]
result = service._update_cost_tracking(123, {'title': 'Test Issue'})
else:
with patch.object(service.session_tracker, 'get_issue_costs') as mock_costs:
mock_costs.return_value = [{'cost_eur': 10.50}, {'cost_eur': 5.25}]
result = service._update_cost_tracking(123, {'title': 'Test Issue'})
assert result['success'] is True
cost_data = result['cost_data']
assert cost_data['issue_number'] == 123
# Don't test specific values since methods may not exist - just test structure
assert cost_data['activity_count'] == 2
def test_create_cost_note(self, service):
"""Test cost note creation."""
with tempfile.TemporaryDirectory() as temp_dir:
# Change to temp directory for testing
original_cwd = Path.cwd()
try:
import os
os.chdir(temp_dir)
cost_results = {
'cost_data': {
'total_cost_eur': 15.75,
'total_minutes': 120,
'total_hours': 2.0,
'activity_count': 3,
'session_count': 2
}
}
result = service._create_cost_note(123, {'title': 'Test Issue'}, cost_results)
assert result['success'] is True
assert 'cost_note_path' in result
# Verify file was created
cost_note_path = Path(result['cost_note_path'])
assert cost_note_path.exists()
# Verify content
content = cost_note_path.read_text()
assert 'Issue #123' in content
assert 'Test Issue' in content
assert '15.7500' in content
finally:
os.chdir(original_cwd)
def test_generate_cost_note_content(self, service):
"""Test cost note content generation."""
cost_data = {
'total_cost_eur': 25.50,
'total_minutes': 180,
'total_hours': 3.0,
'activity_count': 4,
'session_count': 3
}
content = service._generate_cost_note_content(
456,
{'title': 'Sample Issue'},
cost_data
)
assert 'issue_id: 456' in content
assert 'Sample Issue' in content
assert '25.5000' in content
assert 'Implementation Time**: 3.0 hours' in content
assert 'Activities Tracked**: 4 activities' in content
@patch('subprocess.run')
def test_git_operations_success(self, mock_run, service):
"""Test successful git operations."""
# Mock successful git add
mock_add_result = Mock()
mock_add_result.returncode = 0
mock_add_result.stdout = "Files added"
# Mock successful git commit
mock_commit_result = Mock()
mock_commit_result.returncode = 0
mock_commit_result.stdout = "Commit created"
mock_commit_result.stderr = ""
mock_run.side_effect = [mock_add_result, mock_commit_result]
result = service._git_operations(123, {'title': 'Test Issue'})
assert result['success'] is True
assert 'add_output' in result
assert 'commit_output' in result
@patch('subprocess.run')
def test_git_operations_add_failure(self, mock_run, service):
"""Test git operations when git add fails."""
mock_add_result = Mock()
mock_add_result.returncode = 1
mock_add_result.stderr = "Git add failed"
mock_run.return_value = mock_add_result
result = service._git_operations(123, {'title': 'Test Issue'})
assert result['success'] is False
assert 'Git add failed' in result['error']
@patch('subprocess.run')
def test_close_issue_via_make(self, mock_run, service):
"""Test issue closure via make command."""
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = "Issue closed successfully"
mock_result.stderr = ""
mock_run.return_value = mock_result
with patch.object(service.activity_tracker, 'log_activity') as mock_log:
result = service._close_issue(123)
assert result['success'] is True
assert result['method'] == 'make'
mock_log.assert_called_once()
def test_format_summary(self, service):
"""Test wrap-up results summary formatting."""
results = {
'issue_number': 123,
'timestamp': datetime(2025, 1, 15, 10, 30, 0),
'steps': {
'issue_retrieval': {'success': True},
'requirement_review': {'success': True},
'test_execution': {'success': True},
'full_test_execution': {'success': True},
'cost_tracking': {
'success': True,
'cost_data': {
'total_hours': 2.5,
'total_cost_eur': 18.75,
'activity_count': 5
}
},
'cost_note': {'success': True},
'git_operations': {'success': True},
'issue_closure': {'success': True}
}
}
summary = service.format_summary(results)
assert 'Issue #123 Wrap-Up Complete' in summary
assert '2025-01-15 10:30:00' in summary
assert '✅ SUCCESS' in summary
assert 'Time: 2.5 hours' in summary
assert 'Cost: €18.7500' in summary
assert 'Activities: 5' in summary
@patch.multiple(IssueWrapUpService,
_get_issue_details=Mock(return_value={'title': 'Test Issue'}),
_review_requirements=Mock(return_value={'success': True}),
_run_issue_tests=Mock(return_value={'success': True, 'test_files': []}),
_run_full_tests=Mock(return_value={'success': True}),
_update_cost_tracking=Mock(return_value={'success': True, 'cost_data': {}}),
_create_cost_note=Mock(return_value={'success': True}),
_git_operations=Mock(return_value={'success': True}),
_close_issue=Mock(return_value={'success': True}))
def test_wrap_up_issue_complete_success(self, service):
"""Test complete successful issue wrap-up workflow."""
result = service.wrap_up_issue(123, force=False)
assert result['issue_number'] == 123
assert 'timestamp' in result
assert len(result['steps']) == 8
# Verify all steps are present
expected_steps = [
'issue_retrieval', 'requirement_review', 'test_execution',
'full_test_execution', 'cost_tracking', 'cost_note',
'git_operations', 'issue_closure'
]
for step in expected_steps:
assert step in result['steps']
assert result['steps'][step]['success'] is True
class TestIssueWrapUpCLI:
"""Test cases for the issue wrap-up CLI commands."""
@pytest.fixture
def runner(self):
"""Create CLI test runner."""
return CliRunner()
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_summary_format(self, mock_service_class, runner):
"""Test issue wrap-up complete command with summary format."""
# Mock service instance and results
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_results = {
'issue_number': 123,
'timestamp': datetime.now(),
'steps': {
'issue_retrieval': {'success': True},
'test_execution': {'success': True}
}
}
mock_service.wrap_up_issue.return_value = mock_results
mock_service.format_summary.return_value = "Summary output"
result = runner.invoke(issue_wrapup, ['complete', '123'])
assert result.exit_code == 0
assert "Summary output" in result.output
mock_service.wrap_up_issue.assert_called_once_with(123, force=False)
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_json_format(self, mock_service_class, runner):
"""Test issue wrap-up complete command with JSON format."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_results = {
'issue_number': 123,
'timestamp': datetime(2025, 1, 15, 10, 30, 0),
'steps': {'test_step': {'success': True}}
}
mock_service.wrap_up_issue.return_value = mock_results
result = runner.invoke(issue_wrapup, ['complete', '123', '--format', 'json'])
assert result.exit_code == 0
# Parse JSON output
output_data = json.loads(result.output)
assert output_data['issue_number'] == 123
assert 'timestamp' in output_data
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_with_force(self, mock_service_class, runner):
"""Test issue wrap-up complete command with force flag."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_service.wrap_up_issue.return_value = {'issue_number': 123, 'timestamp': datetime.now(), 'steps': {}}
mock_service.format_summary.return_value = "Forced completion"
result = runner.invoke(issue_wrapup, ['complete', '123', '--force'])
assert result.exit_code == 0
mock_service.wrap_up_issue.assert_called_once_with(123, force=True)
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_detailed_format(self, mock_service_class, runner):
"""Test issue wrap-up complete command with detailed format."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_results = {
'issue_number': 123,
'timestamp': datetime.now(),
'steps': {
'test_step': {
'success': True,
'output': 'Detailed test output'
}
}
}
mock_service.wrap_up_issue.return_value = mock_results
mock_service.format_summary.return_value = "Summary"
result = runner.invoke(issue_wrapup, ['complete', '123', '--format', 'detailed'])
assert result.exit_code == 0
assert "Summary" in result.output
assert "Test_Step Details" in result.output
@patch('markitect.issues.issue_wrapup_commands.IssueWrapUpService')
def test_complete_command_error_handling(self, mock_service_class, runner):
"""Test issue wrap-up complete command error handling."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_service.wrap_up_issue.side_effect = Exception("Service error")
result = runner.invoke(issue_wrapup, ['complete', '123'])
assert result.exit_code != 0
assert "Error during issue wrap-up" in result.output
class TestIssueWrapUpIntegration:
"""Integration test cases for issue wrap-up functionality."""
def test_cli_command_group_registration(self):
"""Test that issue wrap-up commands are properly registered."""
from markitect.issues.issue_wrapup_commands import issue_wrapup
# Verify the command group exists and has expected commands
assert issue_wrapup.name == 'issue-wrapup'
assert 'complete' in [cmd.name for cmd in issue_wrapup.commands.values()]
def test_service_component_integration(self):
"""Test that service integrates properly with all required components."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
try:
service = IssueWrapUpService(db_path=db_path)
# Verify all components are initialized
assert service.worktime_tracker is not None
assert service.activity_tracker is not None
assert service.session_tracker is not None
assert service.cost_manager is not None
assert service.issue_manager is not None
finally:
Path(db_path).unlink(missing_ok=True)
@patch('subprocess.run')
def test_git_commit_message_format(self, mock_run, service=None):
"""Test that git commit messages follow the expected format."""
if service is None:
with tempfile.NamedTemporaryFile(suffix='.db') as f:
service = IssueWrapUpService(f.name)
# Mock successful git add
mock_add = Mock()
mock_add.returncode = 0
mock_add.stdout = "Files added"
# Mock successful git commit
mock_commit = Mock()
mock_commit.returncode = 0
mock_commit.stdout = "Commit created"
mock_commit.stderr = ""
mock_run.side_effect = [mock_add, mock_commit]
result = service._git_operations(123, {'title': 'Test Feature'})
assert result['success'] is True
# Verify commit command was called with proper message format
commit_call = mock_run.call_args_list[1]
commit_args = commit_call[0][0]
assert 'git' in commit_args
assert 'commit' in commit_args
assert '-m' in commit_args
# Check commit message contains expected elements
commit_message_arg = next(arg for arg in commit_args if 'feat: complete issue #123' in arg)
assert 'Test Feature' in commit_message_arg
assert 'Claude Code' in commit_message_arg
assert 'Co-Authored-By: Claude' in commit_message_arg
if __name__ == '__main__':
pytest.main([__file__])

View File

@@ -1,621 +0,0 @@
"""
Tests for Issue #124 - Single command Day-Wrap-Up
This module contains comprehensive tests for the day wrap-up functionality
that consolidates daily work summaries, activity tracking, cost distribution,
and reporting into a single convenient command.
"""
import pytest
import tempfile
from datetime import datetime, date, timedelta
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
from decimal import Decimal
import json
from markitect.finance.day_wrapup_commands import DayWrapUpService, wrapup, _display_daily_summary, _display_period_summary
class TestDayWrapUpService:
"""Test suite for DayWrapUpService."""
def setup_method(self):
"""Set up test fixtures with temporary database."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
self.service = DayWrapUpService(self.db_path)
def teardown_method(self):
"""Clean up test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
def test_service_initialization(self):
"""Test that service initializes properly with all trackers."""
assert self.service.db_path == self.db_path
assert self.service.worktime_tracker is not None
assert self.service.activity_tracker is not None
assert self.service.session_tracker is not None
def test_get_worktime_summary_no_data(self):
"""Test worktime summary when no data exists."""
today = date.today()
summary = self.service._get_worktime_summary(today)
assert summary['total_minutes'] == 0
assert summary['total_hours'] == 0.0
assert summary['issues_worked'] == 0
assert summary['entries'] == []
assert summary['cost_allocated'] is None
assert summary['cost_per_minute'] is None
def test_get_worktime_summary_with_data(self):
"""Test worktime summary with logged data."""
today = date.today()
# Log some worktime
self.service.worktime_tracker.log_worktime(124, 90, work_date=today, description="Main work")
self.service.worktime_tracker.log_worktime(125, 60, work_date=today, description="Side work")
summary = self.service._get_worktime_summary(today)
assert summary['total_minutes'] == 150 # 90 + 60
assert summary['total_hours'] == 2.5
assert summary['issues_worked'] == 2
assert summary['entries'] == 2
assert len(summary['issue_breakdown']) == 2
assert 124 in summary['issue_breakdown']
assert 125 in summary['issue_breakdown']
assert summary['issue_breakdown'][124]['minutes'] == 90
assert summary['issue_breakdown'][125]['minutes'] == 60
def test_get_activity_summary_no_data(self):
"""Test activity summary when no data exists."""
today = date.today()
summary = self.service._get_activity_summary(today)
assert summary['total_activities'] == 0
assert summary['unique_issues'] == 0
assert summary['activities_by_type'] == {}
assert summary['activities'] == []
def test_get_activity_summary_with_data(self):
"""Test activity summary with logged data."""
today = date.today()
# Log some activities
from markitect.issues.activity_tracker import ActivityType
self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today, activity_details="Created issue")
self.service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today, activity_details="Updated issue")
self.service.activity_tracker.log_activity(125, ActivityType.CREATED, activity_date=today, activity_details="Created another")
summary = self.service._get_activity_summary(today)
assert summary['total_activities'] == 3
assert summary['unique_issues'] == 2
assert 'created' in summary['activities_by_type']
assert 'modified' in summary['activities_by_type']
assert summary['activities_by_type']['created'] == 2
assert summary['activities_by_type']['modified'] == 1
assert len(summary['activities']) == 3
def test_get_cost_summary_no_distribution(self):
"""Test cost summary when no cost distribution exists."""
today = date.today()
summary = self.service._get_cost_summary(today)
assert summary['daily_total'] == 0.0
assert summary['issue_costs'] == {}
assert summary['has_cost_allocation'] is False
def test_get_cost_summary_with_distribution(self):
"""Test cost summary with cost distribution data."""
today = date.today()
# Log worktime and distribute costs
self.service.worktime_tracker.log_worktime(124, 120, work_date=today) # 2 hours
self.service.worktime_tracker.log_worktime(125, 60, work_date=today) # 1 hour
distribution = self.service.worktime_tracker.distribute_daily_costs(
work_date=today,
total_daily_cost=Decimal('90.00') # €90 total
)
summary = self.service._get_cost_summary(today)
assert summary['daily_total'] == 90.0
assert summary['has_cost_allocation'] is True
assert len(summary['issue_costs']) == 2
assert summary['issue_costs'][124] == 60.0 # 2/3 of €90
assert summary['issue_costs'][125] == 30.0 # 1/3 of €90
def test_generate_recommendations_no_data(self):
"""Test recommendation generation with no data."""
summary = {
'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0},
'activities': {'total_activities': 0, 'unique_issues': 0},
'costs': {'has_cost_allocation': False}
}
recommendations = self.service._generate_recommendations(summary)
assert len(recommendations) >= 2
assert any("No worktime logged" in rec for rec in recommendations)
assert any("No issue activities logged" in rec for rec in recommendations)
def test_generate_recommendations_with_data(self):
"""Test recommendation generation with various data conditions."""
# Test low worktime
summary = {
'worktime': {'total_minutes': 120, 'total_hours': 2.0, 'issues_worked': 1},
'activities': {'total_activities': 5, 'unique_issues': 1},
'costs': {'has_cost_allocation': False}
}
recommendations = self.service._generate_recommendations(summary)
assert any("Low worktime logged" in rec for rec in recommendations)
assert any("no costs distributed" in rec for rec in recommendations)
# Test high worktime
summary['worktime'] = {'total_minutes': 660, 'total_hours': 11.0, 'issues_worked': 1}
recommendations = self.service._generate_recommendations(summary)
assert any("High worktime logged" in rec for rec in recommendations)
# Test many issues
summary['activities'] = {'total_activities': 10, 'unique_issues': 6}
recommendations = self.service._generate_recommendations(summary)
assert any("Many issues worked on" in rec for rec in recommendations)
def test_perform_auto_estimation_no_activities(self):
"""Test auto estimation when no activities exist."""
today = date.today()
result = self.service.perform_auto_estimation(today, 8.0)
assert result['estimated'] is False
assert "No active issues found" in result['reason']
assert result['active_issues'] == []
def test_perform_auto_estimation_with_existing_time(self):
"""Test auto estimation when time is already logged."""
today = date.today()
# Log some worktime first
self.service.worktime_tracker.log_worktime(124, 60, work_date=today)
result = self.service.perform_auto_estimation(today, 8.0)
assert result['estimated'] is False
assert "Time already logged" in result['reason']
assert result['existing_minutes'] == 60
def test_perform_auto_estimation_success(self):
"""Test successful auto estimation."""
today = date.today()
# Create activities for issues
from markitect.issues.activity_tracker import ActivityType
self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today)
self.service.activity_tracker.log_activity(125, ActivityType.MODIFIED, activity_date=today)
result = self.service.perform_auto_estimation(today, 6.0)
assert result['estimated'] is True
assert 'estimation_result' in result
estimation = result['estimation_result']
assert estimation['total_minutes'] == 360 # 6 hours
assert estimation['issues_count'] == 2
assert len(estimation['issue_estimates']) == 2
# Verify worktime entries were created
entries = self.service.worktime_tracker.get_worktime_entries(work_date=today)
assert len(entries) == 2
assert all(e.entry_type == "estimated" for e in entries)
def test_distribute_daily_costs(self):
"""Test daily cost distribution functionality."""
today = date.today()
# Log worktime first
self.service.worktime_tracker.log_worktime(124, 180, work_date=today) # 3 hours
self.service.worktime_tracker.log_worktime(125, 120, work_date=today) # 2 hours
# Total: 5 hours (300 minutes)
result = self.service.distribute_daily_costs(today, Decimal('150.00'))
assert result['total_cost'] == 150.0
assert result['total_minutes'] == 300
assert result['cost_per_minute'] == 0.5
assert result['distributions'][124]['cost_allocated'] == 90.0 # 3/5 * €150
assert result['distributions'][125]['cost_allocated'] == 60.0 # 2/5 * €150
def test_generate_daily_summary_integration(self):
"""Test complete daily summary generation."""
today = date.today()
# Create comprehensive test data
from markitect.issues.activity_tracker import ActivityType
# Log worktime
self.service.worktime_tracker.log_worktime(124, 120, work_date=today, description="Main feature")
self.service.worktime_tracker.log_worktime(125, 60, work_date=today, description="Bug fix")
# Log activities
self.service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today)
self.service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today)
self.service.activity_tracker.log_activity(125, ActivityType.CLOSED, activity_date=today)
# Distribute costs
self.service.distribute_daily_costs(today, Decimal('90.00'))
# Generate summary
summary = self.service.generate_daily_summary(today)
# Verify summary structure
assert summary['date'] == today
assert 'worktime' in summary
assert 'activities' in summary
assert 'costs' in summary
assert 'recommendations' in summary
# Verify worktime data
worktime = summary['worktime']
assert worktime['total_minutes'] == 180
assert worktime['total_hours'] == 3.0
assert worktime['issues_worked'] == 2
assert worktime['cost_allocated'] == 90.0
# Verify activity data
activities = summary['activities']
assert activities['total_activities'] == 3
assert activities['unique_issues'] == 2
# Verify cost data
costs = summary['costs']
assert costs['daily_total'] == 90.0
assert costs['has_cost_allocation'] is True
# Verify recommendations exist
assert isinstance(summary['recommendations'], list)
class TestDayWrapUpCommands:
"""Test suite for day wrap-up CLI commands."""
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_daily_command_basic(self, mock_service_class):
"""Test the daily wrap-up command with basic functionality."""
mock_service = Mock()
mock_service_class.return_value = mock_service
# Mock the summary data
mock_summary = {
'date': date.today(),
'worktime': {
'total_minutes': 180,
'total_hours': 3.0,
'issues_worked': 2,
'entries': 2,
'issue_breakdown': {124: {'minutes': 120, 'entries': 1}, 125: {'minutes': 60, 'entries': 1}},
'cost_allocated': 90.0,
'cost_per_minute': 0.5
},
'activities': {
'total_activities': 3,
'unique_issues': 2,
'activities_by_type': {'created': 2, 'modified': 1},
'activities': []
},
'costs': {
'daily_total': 90.0,
'issue_costs': {124: 60.0, 125: 30.0},
'has_cost_allocation': True
},
'recommendations': ["💰 Costs distributed successfully"]
}
mock_service.generate_daily_summary.return_value = mock_summary
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['daily'])
assert result.exit_code == 0
assert "📊 Daily Wrap-Up" in result.output
assert "⏰ WORKTIME SUMMARY" in result.output
assert "📝 ACTIVITIES SUMMARY" in result.output
assert "💰 COST SUMMARY" in result.output
assert "💡 RECOMMENDATIONS" in result.output
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_daily_command_with_auto_estimate(self, mock_service_class):
"""Test daily command with auto-estimation enabled."""
mock_service = Mock()
mock_service_class.return_value = mock_service
# Mock estimation result
mock_estimation = {
'estimated': True,
'estimation_result': {
'total_minutes': 480,
'issues_count': 2,
'issue_estimates': {124: 240, 125: 240}
}
}
mock_service.perform_auto_estimation.return_value = mock_estimation
# Mock summary
mock_service.generate_daily_summary.return_value = {
'date': date.today(),
'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0, 'entries': []},
'activities': {'total_activities': 0, 'unique_issues': 0, 'activities_by_type': {}, 'activities': []},
'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False},
'recommendations': []
}
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['daily', '--auto-estimate', '--estimate-hours', '8'])
assert result.exit_code == 0
assert "🤖 Auto-estimating worktime" in result.output
assert "✅ Estimated 8.0h across 2 issues" in result.output
mock_service.perform_auto_estimation.assert_called_once_with(date.today(), 8.0)
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_daily_command_with_cost_distribution(self, mock_service_class):
"""Test daily command with cost distribution."""
mock_service = Mock()
mock_service_class.return_value = mock_service
# Mock distribution result
mock_distribution = {
'total_cost': 120.0,
'total_minutes': 240,
'issues_count': 2,
'distributions': {124: {'cost_allocated': 80.0}, 125: {'cost_allocated': 40.0}}
}
mock_service.distribute_daily_costs.return_value = mock_distribution
# Mock summary
mock_service.generate_daily_summary.return_value = {
'date': date.today(),
'worktime': {'total_minutes': 0, 'total_hours': 0.0, 'issues_worked': 0, 'entries': []},
'activities': {'total_activities': 0, 'unique_issues': 0, 'activities_by_type': {}, 'activities': []},
'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False},
'recommendations': []
}
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['daily', '--distribute-cost', '120'])
assert result.exit_code == 0
assert "💰 Distributing €120.00" in result.output
assert "✅ Distributed €120.00 across 2 issues" in result.output
mock_service.distribute_daily_costs.assert_called_once()
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_daily_command_json_format(self, mock_service_class):
"""Test daily command with JSON output format."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_summary = {
'date': date.today(),
'worktime': {'total_minutes': 120, 'total_hours': 2.0, 'issues_worked': 1, 'entries': 1},
'activities': {'total_activities': 2, 'unique_issues': 1, 'activities_by_type': {}, 'activities': []},
'costs': {'daily_total': 0.0, 'issue_costs': {}, 'has_cost_allocation': False},
'recommendations': []
}
mock_service.generate_daily_summary.return_value = mock_summary
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['daily', '--format', 'json'])
assert result.exit_code == 0
# Should be valid JSON
output_data = json.loads(result.output.strip())
assert 'date' in output_data
assert 'worktime' in output_data
assert 'activities' in output_data
assert 'costs' in output_data
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_estimate_command(self, mock_service_class):
"""Test the estimate command."""
mock_service = Mock()
mock_service_class.return_value = mock_service
mock_estimation = {
'estimated': True,
'estimation_result': {
'work_date': date.today(),
'total_minutes': 480, # 8 hours
'distribution_method': 'activity_based',
'issue_estimates': {124: 300, 125: 180}, # 5h and 3h
'issues_count': 2
}
}
mock_service.perform_auto_estimation.return_value = mock_estimation
from click.testing import CliRunner
runner = CliRunner()
today = date.today().strftime('%Y-%m-%d')
result = runner.invoke(wrapup, ['estimate', today, '--hours', '8'])
assert result.exit_code == 0
assert "✅ Estimated worktime" in result.output
assert "Total Hours: 8.0h" in result.output
assert "Issues: 2" in result.output
assert "Estimated Time Distribution:" in result.output
@patch('markitect.finance.day_wrapup_commands.DayWrapUpService')
def test_period_command(self, mock_service_class):
"""Test the period wrap-up command."""
mock_service = Mock()
mock_service_class.return_value = mock_service
# Mock worktime report
mock_worktime_report = {
'period': '2025-10-01 to 2025-10-04',
'total_entries': 8,
'total_time': {'hours': 20, 'minutes': 30, 'total_minutes': 1230},
'unique_issues': 3,
'unique_dates': 4,
'average_minutes_per_day': 307.5
}
mock_service.worktime_tracker.get_worktime_report.return_value = mock_worktime_report
# Mock activity summary
mock_activity_summary = {
'total_activities': 15,
'unique_issues': 4,
'activities_by_type': {'created': 8, 'modified': 5, 'closed': 2}
}
mock_service.activity_tracker.get_activity_summary.return_value = mock_activity_summary
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(wrapup, ['period', '2025-10-01', '2025-10-04'])
assert result.exit_code == 0
assert "📈 Period Wrap-Up" in result.output
assert "⏰ WORKTIME OVERVIEW" in result.output
assert "📝 ACTIVITIES OVERVIEW" in result.output
assert "Total Time: 20h 30m" in result.output
assert "Total Activities: 15" in result.output
class TestDayWrapUpIntegration:
"""Integration tests for the complete day wrap-up system."""
def setup_method(self):
"""Set up integration test fixtures."""
self.temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
self.temp_db.close()
self.db_path = self.temp_db.name
def teardown_method(self):
"""Clean up integration test fixtures."""
Path(self.db_path).unlink(missing_ok=True)
def test_complete_day_workflow(self):
"""Test a complete daily workflow from start to finish."""
service = DayWrapUpService(self.db_path)
today = date.today()
# 1. Start with empty day
initial_summary = service.generate_daily_summary(today)
assert initial_summary['worktime']['total_minutes'] == 0
assert initial_summary['activities']['total_activities'] == 0
# 2. Log some activities
from markitect.issues.activity_tracker import ActivityType
service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today, activity_details="Started new feature")
service.activity_tracker.log_activity(124, ActivityType.MODIFIED, activity_date=today, activity_details="Made progress")
service.activity_tracker.log_activity(125, ActivityType.CLOSED, activity_date=today, activity_details="Fixed bug")
# 3. Perform auto-estimation
estimation = service.perform_auto_estimation(today, 7.5)
assert estimation['estimated'] is True
assert estimation['estimation_result']['total_minutes'] == 450 # 7.5 hours
# 4. Distribute costs
distribution = service.distribute_daily_costs(today, Decimal('112.50')) # €15 per hour
assert distribution['total_cost'] == 112.5
assert distribution['cost_per_minute'] == 0.25 # €0.25 per minute
# 5. Generate final summary
final_summary = service.generate_daily_summary(today)
# Verify complete summary
assert final_summary['worktime']['total_hours'] == 7.5
assert final_summary['worktime']['issues_worked'] == 2
assert final_summary['worktime']['cost_allocated'] == 112.5
assert final_summary['activities']['total_activities'] == 3
assert final_summary['activities']['unique_issues'] == 2
assert final_summary['costs']['daily_total'] == 112.5
assert final_summary['costs']['has_cost_allocation'] is True
assert len(final_summary['costs']['issue_costs']) == 2
# Verify recommendations are helpful
recommendations = final_summary['recommendations']
assert len(recommendations) >= 0 # Should have reasonable recommendations
def test_multi_day_period_summary(self):
"""Test period summary across multiple days."""
service = DayWrapUpService(self.db_path)
# Create data across multiple days
dates = [date.today() - timedelta(days=i) for i in range(3)] # Last 3 days
for i, test_date in enumerate(dates):
# Log different amounts of work each day
hours = 6 + i * 2 # 6, 8, 10 hours
minutes = hours * 60
service.worktime_tracker.log_worktime(124, minutes // 2, work_date=test_date, description=f"Day {i+1} main work")
service.worktime_tracker.log_worktime(125 + i, minutes // 2, work_date=test_date, description=f"Day {i+1} side work")
# Log activities
from markitect.issues.activity_tracker import ActivityType
service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=test_date)
service.activity_tracker.log_activity(125 + i, ActivityType.MODIFIED, activity_date=test_date)
# Generate period report
start_date = dates[-1] # Oldest date
end_date = dates[0] # Most recent date
worktime_report = service.worktime_tracker.get_worktime_report(
start_date=start_date,
end_date=end_date
)
# Verify period data
assert worktime_report['total_entries'] == 6 # 2 entries per day * 3 days
assert worktime_report['total_time']['total_minutes'] == 1440 # 6+8+10 = 24 hours
assert worktime_report['unique_issues'] == 4 # Issues 124, 125, 126, 127
assert worktime_report['unique_dates'] == 3
# Verify daily averages
expected_avg = 1440 / 3 # 480 minutes per day on average
assert abs(worktime_report['average_minutes_per_day'] - expected_avg) < 1
def test_error_handling_and_edge_cases(self):
"""Test error handling and edge cases."""
service = DayWrapUpService(self.db_path)
today = date.today()
# Test estimation with no activities
estimation = service.perform_auto_estimation(today, 8.0)
assert estimation['estimated'] is False
assert "No active issues found" in estimation['reason']
# Test cost distribution with no worktime
distribution = service.distribute_daily_costs(today, Decimal('100.00'))
assert 'message' in distribution
assert "No worktime entries found" in distribution['message']
# Test summary generation with partial data
from markitect.issues.activity_tracker import ActivityType
service.activity_tracker.log_activity(124, ActivityType.CREATED, activity_date=today)
summary = service.generate_daily_summary(today)
assert summary['worktime']['total_minutes'] == 0 # No worktime logged
assert summary['activities']['total_activities'] == 1 # But activity exists
assert "No worktime logged" in ' '.join(summary['recommendations'])
# Test recommendations for edge cases
service.worktime_tracker.log_worktime(124, 720, work_date=today) # 12 hours - excessive
summary = service.generate_daily_summary(today)
assert any("High worktime logged" in rec for rec in summary['recommendations'])

View File

@@ -1,485 +0,0 @@
"""
Tests for Issue #59 - CLI Interface
This module contains tests for the unified CLI interface that provides
consistent commands for issue management across different backends.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from click.testing import CliRunner
from typing import List
# Import CLI commands we'll implement
# Note: These imports will fail initially (RED phase)
from markitect.cli import cli
from markitect.issues.commands import issues_group
from markitect.issues.manager import IssuePluginManager
from domain.issues.models import Issue
class TestIssuesCLIGroup:
"""Test suite for the main issues CLI group."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
def test_issues_group_exists_in_main_cli(self):
"""Test that issues group is properly registered in main CLI."""
result = self.runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert 'issues' in result.output
def test_issues_group_shows_help(self):
"""Test that issues group displays help information."""
result = self.runner.invoke(cli, ['issues', '--help'])
assert result.exit_code == 0
assert 'Issue management' in result.output
assert 'list' in result.output
assert 'show' in result.output
assert 'create' in result.output
def test_issues_group_description(self):
"""Test that issues group has appropriate description."""
result = self.runner.invoke(cli, ['issues', '--help'])
assert 'multiple backend support' in result.output.lower()
class TestIssuesListCommand:
"""Test suite for the issues list command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
def test_list_all_issues_default(self):
"""Test listing all issues with default parameters."""
with patch('markitect.issues.commands.IssuePluginManager') as mock_manager_class:
# Mock the plugin manager and backend
mock_manager = Mock()
mock_backend = Mock()
# Create more realistic mock issues with proper attributes
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01"
mock_issue1 = Mock(spec=Issue)
mock_issue1.number = 1
mock_issue1.title = "Test Issue 1"
mock_issue1.state = "open"
mock_issue1.labels = []
mock_issue1.body = "Test body 1"
mock_issue1.created_at = mock_datetime
mock_issue1.updated_at = mock_datetime
mock_issue2 = Mock(spec=Issue)
mock_issue2.number = 2
mock_issue2.title = "Test Issue 2"
mock_issue2.state = "closed"
mock_issue2.labels = []
mock_issue2.body = "Test body 2"
mock_issue2.created_at = mock_datetime
mock_issue2.updated_at = mock_datetime
mock_issues = [mock_issue1, mock_issue2]
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = mock_issues
result = self.runner.invoke(cli, ['issues', 'list'])
assert result.exit_code == 0
mock_manager.get_backend.assert_called_once_with(None)
mock_backend.list_issues.assert_called_once_with(state='all')
@patch('markitect.issues.commands.IssuePluginManager')
def test_list_open_issues_only(self, mock_manager_class):
"""Test listing only open issues."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = []
result = self.runner.invoke(cli, ['issues', 'list', '--state', 'open'])
assert result.exit_code == 0
mock_backend.list_issues.assert_called_once_with(state='open')
@patch('markitect.issues.commands.IssuePluginManager')
def test_list_closed_issues_only(self, mock_manager_class):
"""Test listing only closed issues."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = []
result = self.runner.invoke(cli, ['issues', 'list', '--state', 'closed'])
assert result.exit_code == 0
mock_backend.list_issues.assert_called_once_with(state='closed')
@patch('markitect.issues.commands.IssuePluginManager')
def test_list_with_backend_override(self, mock_manager_class):
"""Test listing issues with backend override."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = []
result = self.runner.invoke(cli, ['issues', 'list', '--backend', 'local'])
assert result.exit_code == 0
mock_manager.get_backend.assert_called_once_with('local')
@patch('markitect.issues.commands.IssuePluginManager')
def test_list_displays_issues_in_table_format(self, mock_manager_class):
"""Test that list command displays issues in readable table format."""
mock_manager = Mock()
mock_backend = Mock()
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01"
mock_issue = Mock()
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue.state = "open"
mock_issue.labels = []
mock_issue.body = "Test issue body"
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.list_issues.return_value = [mock_issue]
result = self.runner.invoke(cli, ['issues', 'list'])
assert result.exit_code == 0
assert '59' in result.output
assert 'Test Issue' in result.output
class TestIssuesShowCommand:
"""Test suite for the issues show command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_show_specific_issue(self, mock_manager_class):
"""Test showing a specific issue by ID."""
mock_manager = Mock()
mock_backend = Mock()
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01 00:00"
mock_issue = Mock()
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue._body = "Test issue body"
mock_issue.state = Mock()
mock_issue.state.value = "open"
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
mock_issue.labels = []
mock_issue.assignee = None
mock_issue.milestone = None
mock_issue.state_label = "OPEN"
mock_issue.priority_label = "Normal"
mock_issue.type_labels = []
mock_issue.other_labels = []
mock_issue.html_url = ""
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.get_issue.return_value = mock_issue
result = self.runner.invoke(cli, ['issues', 'show', '59'])
assert result.exit_code == 0
mock_backend.get_issue.assert_called_once_with('59')
@patch('markitect.issues.commands.IssuePluginManager')
def test_show_displays_issue_details(self, mock_manager_class):
"""Test that show command displays comprehensive issue details."""
mock_manager = Mock()
mock_backend = Mock()
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01 00:00"
mock_issue = Mock()
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue._body = "Detailed issue description"
mock_issue.state = Mock()
mock_issue.state.value = "open"
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
mock_issue.labels = []
mock_issue.assignee = None
mock_issue.milestone = None
mock_issue.state_label = "OPEN"
mock_issue.priority_label = "Normal"
mock_issue.type_labels = []
mock_issue.other_labels = []
mock_issue.html_url = ""
mock_issue.kanban_column = "To Do"
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.get_issue.return_value = mock_issue
result = self.runner.invoke(cli, ['issues', 'show', '59'])
assert result.exit_code == 0
assert 'Test Issue' in result.output
assert 'Detailed issue description' in result.output
@patch('markitect.issues.commands.IssuePluginManager')
def test_show_with_backend_override(self, mock_manager_class):
"""Test showing issue with specific backend override."""
mock_manager = Mock()
mock_backend = Mock()
from datetime import datetime
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01 00:00"
mock_issue = Mock()
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue._body = "Test issue body"
mock_issue.state = Mock()
mock_issue.state.value = "open"
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
mock_issue.labels = []
mock_issue.assignee = None
mock_issue.milestone = None
mock_issue.state_label = "OPEN"
mock_issue.priority_label = "Normal"
mock_issue.type_labels = []
mock_issue.other_labels = []
mock_issue.html_url = ""
mock_issue.kanban_column = "To Do"
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.get_issue.return_value = mock_issue
result = self.runner.invoke(cli, ['issues', 'show', '59', '--backend', 'gitea'])
assert result.exit_code == 0
mock_manager.get_backend.assert_called_once_with('gitea')
class TestIssuesCreateCommand:
"""Test suite for the issues create command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_create_issue_with_title_and_body(self, mock_manager_class):
"""Test creating an issue with title and body."""
mock_manager = Mock()
mock_backend = Mock()
mock_created_issue = Mock()
mock_created_issue.number = 60
mock_created_issue.title = "New Issue"
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.create_issue.return_value = mock_created_issue
result = self.runner.invoke(cli, ['issues', 'create', 'New Issue', 'Issue body content'])
assert result.exit_code == 0
mock_backend.create_issue.assert_called_once_with('New Issue', 'Issue body content')
@patch('markitect.issues.commands.IssuePluginManager')
def test_create_displays_success_message(self, mock_manager_class):
"""Test that create command displays success message with issue number."""
mock_manager = Mock()
mock_backend = Mock()
mock_created_issue = Mock()
mock_created_issue.number = 60
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.create_issue.return_value = mock_created_issue
result = self.runner.invoke(cli, ['issues', 'create', 'Test', 'Body'])
assert result.exit_code == 0
assert '60' in result.output
assert 'created' in result.output.lower()
class TestIssuesCommentCommand:
"""Test suite for the issues comment command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_add_comment_to_issue(self, mock_manager_class):
"""Test adding a comment to an existing issue."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.add_comment.return_value = {}
result = self.runner.invoke(cli, ['issues', 'comment', '59', 'This is a comment'])
assert result.exit_code == 0
mock_backend.add_comment.assert_called_once_with('59', 'This is a comment')
@patch('markitect.issues.commands.IssuePluginManager')
def test_comment_displays_success_message(self, mock_manager_class):
"""Test that comment command displays success message."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.add_comment.return_value = {}
result = self.runner.invoke(cli, ['issues', 'comment', '59', 'Test comment'])
assert result.exit_code == 0
assert 'comment added' in result.output.lower()
class TestIssuesCloseCommand:
"""Test suite for the issues close command."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_close_issue(self, mock_manager_class):
"""Test closing an issue."""
mock_manager = Mock()
mock_backend = Mock()
mock_closed_issue = Mock()
mock_closed_issue.state = "closed"
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.close_issue.return_value = mock_closed_issue
result = self.runner.invoke(cli, ['issues', 'close', '59'])
assert result.exit_code == 0
mock_backend.close_issue.assert_called_once_with('59')
@patch('markitect.issues.commands.IssuePluginManager')
def test_close_displays_success_message(self, mock_manager_class):
"""Test that close command displays success message."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.close_issue.return_value = Mock()
result = self.runner.invoke(cli, ['issues', 'close', '59'])
assert result.exit_code == 0
assert 'closed' in result.output.lower()
class TestErrorHandling:
"""Test suite for CLI error handling."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_backend_error_displays_user_friendly_message(self, mock_manager_class):
"""Test that backend errors are displayed in user-friendly format."""
mock_manager = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.side_effect = Exception("Backend connection failed")
result = self.runner.invoke(cli, ['issues', 'list'])
assert result.exit_code != 0
assert 'error' in result.output.lower()
@patch('markitect.issues.commands.IssuePluginManager')
def test_invalid_issue_id_displays_helpful_error(self, mock_manager_class):
"""Test that invalid issue IDs display helpful error messages."""
mock_manager = Mock()
mock_backend = Mock()
mock_manager_class.return_value = mock_manager
mock_manager.get_backend.return_value = mock_backend
mock_backend.get_issue.side_effect = Exception("Issue not found")
result = self.runner.invoke(cli, ['issues', 'show', '999999'])
assert result.exit_code != 0
assert 'not found' in result.output.lower()
class TestBackendIntegration:
"""Test suite for backend integration in CLI commands."""
def setup_method(self):
"""Set up test fixtures."""
self.runner = CliRunner()
@patch('markitect.issues.commands.IssuePluginManager')
def test_cli_respects_backend_configuration(self, mock_manager_class):
"""Test that CLI commands respect backend configuration."""
mock_manager = Mock()
mock_manager_class.return_value = mock_manager
# Test with different backends
for backend in ['gitea', 'local']:
mock_manager.get_backend.return_value = Mock()
mock_manager.get_backend.return_value.list_issues.return_value = []
result = self.runner.invoke(cli, ['issues', 'list', '--backend', backend])
assert result.exit_code == 0
mock_manager.get_backend.assert_called_with(backend)
@patch('markitect.issues.commands.IssuePluginManager')
def test_cli_handles_plugin_switching_gracefully(self, mock_manager_class):
"""Test that CLI handles switching between plugins gracefully."""
mock_manager = Mock()
mock_manager_class.return_value = mock_manager
# First call with gitea
mock_manager.get_backend.return_value = Mock()
mock_manager.get_backend.return_value.list_issues.return_value = []
result1 = self.runner.invoke(cli, ['issues', 'list', '--backend', 'gitea'])
# Second call with local
mock_manager.get_backend.return_value = Mock()
mock_manager.get_backend.return_value.list_issues.return_value = []
result2 = self.runner.invoke(cli, ['issues', 'list', '--backend', 'local'])
assert result1.exit_code == 0
assert result2.exit_code == 0

View File

@@ -1,446 +0,0 @@
"""
Tests for Issue #59 - Gitea Plugin Implementation
This module contains tests for the Gitea backend plugin that integrates
with the existing GiteaIssueRepository infrastructure.
"""
import pytest
from unittest.mock import Mock, patch, AsyncMock
from typing import List, Dict, Any
# Import async test utilities
from tests.utils.assertions import AsyncTestCase, create_async_mock_that_returns, create_async_mock_that_raises
# Import classes we'll implement
# Note: These imports will fail initially (RED phase)
from markitect.issues.plugins.gitea import GiteaPlugin
from markitect.issues.base import IssueBackend
from domain.issues.models import Issue
from infrastructure.repositories.gitea_repository import GiteaIssueRepository
class TestGiteaPluginInitialization:
"""Test suite for Gitea plugin initialization and configuration."""
def test_gitea_plugin_inherits_from_issue_backend(self):
"""Test that GiteaPlugin properly inherits from IssueBackend."""
config = {'url': 'http://test.com', 'repo': 'test/repo'}
plugin = GiteaPlugin(config)
assert isinstance(plugin, IssueBackend)
def test_gitea_plugin_accepts_configuration(self):
"""Test that GiteaPlugin accepts and stores configuration."""
config = {
'url': 'http://gitea.example.com',
'repo': 'owner/repository',
'token_env': 'GITEA_TOKEN'
}
plugin = GiteaPlugin(config)
assert plugin.config == config
def test_gitea_plugin_initializes_repository(self):
"""Test that GiteaPlugin properly initializes underlying repository."""
config = {'url': 'http://test.com', 'repo': 'test/repo'}
with patch('markitect.issues.plugins.gitea.GiteaIssueRepository') as mock_repo_class:
plugin = GiteaPlugin(config)
# Should initialize repository with config
mock_repo_class.assert_called_once()
def test_gitea_plugin_handles_missing_config_gracefully(self):
"""Test that GiteaPlugin handles missing configuration parameters."""
config = {} # Empty config
# Should not raise errors, but may use defaults
plugin = GiteaPlugin(config)
assert plugin is not None
def test_gitea_plugin_validates_required_config_parameters(self):
"""Test that GiteaPlugin validates required configuration parameters."""
# This will be implemented when we add config validation
pass
class TestGiteaPluginListIssues(AsyncTestCase):
"""Test suite for listing issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_list_all_issues(self, mock_repo_class):
"""Test listing all issues regardless of state."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
# Mock issues data
mock_issues = [Mock(spec=Issue), Mock(spec=Issue)]
plugin = GiteaPlugin(self.config)
# Mock the async method directly to avoid creating real coroutines
plugin._list_issues_async = self.create_async_mock(return_value=mock_issues)
issues = plugin.list_issues(state='all')
assert len(issues) == 2
assert all(isinstance(issue, Mock) for issue in issues)
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_list_open_issues_only(self, mock_repo_class):
"""Test listing only open issues."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
plugin = GiteaPlugin(self.config)
# Mock list_issues directly to avoid async complexity
with patch.object(plugin, 'list_issues', return_value=[]) as mock_list:
result = plugin.list_issues(state='open')
assert result == []
mock_list.assert_called_once_with(state='open')
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_list_closed_issues_only(self, mock_repo_class):
"""Test listing only closed issues."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
plugin = GiteaPlugin(self.config)
# Mock list_issues directly to avoid async complexity
with patch.object(plugin, 'list_issues', return_value=[]) as mock_list:
result = plugin.list_issues(state='closed')
assert result == []
mock_list.assert_called_once_with(state='closed')
def test_list_issues_error_handling_integration(self):
"""Test that list_issues properly handles and propagates errors from underlying components."""
# Test error handling at the integration level without creating real async methods
with patch('markitect.issues.plugins.gitea.GiteaPlugin') as MockPlugin:
mock_instance = Mock()
MockPlugin.return_value = mock_instance
mock_instance.list_issues.side_effect = ConnectionError("Network connection failed")
plugin = MockPlugin(self.config)
with pytest.raises(ConnectionError):
plugin.list_issues(state='all')
class TestGiteaPluginGetIssue(AsyncTestCase):
"""Test suite for getting individual issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_get_specific_issue_by_id(self, mock_repo_class):
"""Test getting a specific issue by ID."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_issue = Mock(spec=Issue)
mock_issue.number = 59
plugin = GiteaPlugin(self.config)
plugin._get_issue_async = self.create_async_mock(return_value=mock_issue)
issue = plugin.get_issue('59')
assert issue == mock_issue
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_get_issue_converts_string_id_to_int(self, mock_repo_class):
"""Test that get_issue properly converts string IDs to integers."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._get_issue_async = self.create_async_mock(return_value=mock_result)
result = plugin.get_issue('59')
# Verify the conversion worked and result is returned
assert result == mock_result
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_get_nonexistent_issue_raises_error(self, mock_repo_class):
"""Test that getting non-existent issue raises appropriate error."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
plugin = GiteaPlugin(self.config)
plugin._get_issue_async = self.create_async_mock(side_effect=Exception("Issue not found"))
with pytest.raises(Exception):
plugin.get_issue('999999')
class TestGiteaPluginCreateIssue(AsyncTestCase):
"""Test suite for creating issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_create_issue_with_title_and_body(self, mock_repo_class):
"""Test creating an issue with title and body."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_created_issue = Mock(spec=Issue)
mock_created_issue.number = 60
plugin = GiteaPlugin(self.config)
plugin._create_issue_async = self.create_async_mock(return_value=mock_created_issue)
issue = plugin.create_issue('Test Title', 'Test Body')
assert issue == mock_created_issue
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_create_issue_with_additional_kwargs(self, mock_repo_class):
"""Test creating an issue with additional keyword arguments."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._create_issue_async = self.create_async_mock(return_value=mock_result)
result = plugin.create_issue('Title', 'Body', labels=['bug', 'priority:high'])
# Verify the method was called and returned expected result
assert result == mock_result
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_create_issue_handles_validation_errors(self, mock_repo_class):
"""Test that create_issue handles validation errors appropriately."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
plugin = GiteaPlugin(self.config)
plugin._create_issue_async = self.create_async_mock(side_effect=ValueError("Invalid title"))
with pytest.raises(ValueError):
plugin.create_issue('', 'Body') # Empty title
class TestGiteaPluginUpdateIssue(AsyncTestCase):
"""Test suite for updating issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_update_issue_title(self, mock_repo_class):
"""Test updating an issue's title."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_updated_issue = Mock(spec=Issue)
plugin = GiteaPlugin(self.config)
plugin._update_issue_async = self.create_async_mock(return_value=mock_updated_issue)
issue = plugin.update_issue('59', title='New Title')
assert issue == mock_updated_issue
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_update_issue_body(self, mock_repo_class):
"""Test updating an issue's body."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._update_issue_async = self.create_async_mock(return_value=mock_result)
result = plugin.update_issue('59', body='New body content')
assert result == mock_result
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_update_issue_multiple_fields(self, mock_repo_class):
"""Test updating multiple issue fields simultaneously."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._update_issue_async = self.create_async_mock(return_value=mock_result)
result = plugin.update_issue('59', title='New Title', body='New Body', state='closed')
assert result == mock_result
class TestGiteaPluginCommentOperations(AsyncTestCase):
"""Test suite for comment operations through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
def test_add_comment_functionality_integration(self):
"""Test comment addition functionality at integration level."""
# Test comment functionality without creating real async methods
with patch('markitect.issues.plugins.gitea.GiteaPlugin') as MockPlugin:
mock_instance = Mock()
MockPlugin.return_value = mock_instance
mock_comment_result = {'id': 123, 'body': 'Test comment'}
mock_instance.add_comment.return_value = mock_comment_result
plugin = MockPlugin(self.config)
result = plugin.add_comment('59', 'Test comment')
assert result == mock_comment_result
mock_instance.add_comment.assert_called_once_with('59', 'Test comment')
def test_add_comment_validates_input_integration(self):
"""Test that add_comment validates input parameters at integration level."""
# Test input validation without creating real async methods
with patch('markitect.issues.plugins.gitea.GiteaPlugin') as MockPlugin:
mock_instance = Mock()
MockPlugin.return_value = mock_instance
mock_instance.add_comment.side_effect = [
ValueError("Comment cannot be empty"),
ValueError("Issue ID cannot be empty")
]
plugin = MockPlugin(self.config)
# Test empty comment
with pytest.raises(ValueError):
plugin.add_comment('59', '')
# Test invalid issue ID
with pytest.raises(ValueError):
plugin.add_comment('', 'Valid comment')
class TestGiteaPluginCloseIssue(AsyncTestCase):
"""Test suite for closing issues through Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_close_issue_updates_state(self, mock_repo_class):
"""Test that closing an issue updates its state to closed."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_closed_issue = Mock(spec=Issue)
mock_closed_issue.state = "closed"
plugin = GiteaPlugin(self.config)
plugin._close_issue_async = self.create_async_mock(return_value=mock_closed_issue)
issue = plugin.close_issue('59')
assert issue == mock_closed_issue
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_close_already_closed_issue_succeeds(self, mock_repo_class):
"""Test that closing an already closed issue succeeds gracefully."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
mock_result = Mock()
plugin = GiteaPlugin(self.config)
plugin._close_issue_async = self.create_async_mock(return_value=mock_result)
# Should not raise an error
result = plugin.close_issue('59')
assert result == mock_result
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_close_nonexistent_issue_raises_error(self, mock_repo_class):
"""Test that closing non-existent issue raises appropriate error."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
plugin = GiteaPlugin(self.config)
plugin._close_issue_async = self.create_async_mock(side_effect=Exception("Issue not found"))
with pytest.raises(Exception):
plugin.close_issue('999999')
class TestGiteaPluginErrorHandling(AsyncTestCase):
"""Test suite for error handling in Gitea plugin."""
def setup_method(self):
"""Set up test fixtures."""
super().setup_method()
self.config = {'url': 'http://test.com', 'repo': 'test/repo'}
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_network_errors_are_handled_gracefully(self, mock_repo_class):
"""Test that network errors are handled gracefully."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
plugin = GiteaPlugin(self.config)
plugin._list_issues_async = self.create_async_mock(side_effect=ConnectionError("Network error"))
with pytest.raises(ConnectionError):
plugin.list_issues()
@patch('markitect.issues.plugins.gitea.GiteaIssueRepository')
def test_authentication_errors_provide_helpful_messages(self, mock_repo_class):
"""Test that authentication errors provide helpful error messages."""
mock_repo = Mock()
mock_repo_class.return_value = mock_repo
plugin = GiteaPlugin(self.config)
plugin._list_issues_async = self.create_async_mock(side_effect=PermissionError("Authentication failed"))
with pytest.raises(PermissionError):
plugin.list_issues()
def test_invalid_configuration_raises_appropriate_error(self):
"""Test that invalid configuration raises appropriate errors."""
# Test will be implemented when we add configuration validation
pass
class TestGiteaPluginIntegration:
"""Test suite for Gitea plugin integration with existing infrastructure."""
def test_plugin_integrates_with_existing_gitea_repository(self):
"""Test that plugin properly integrates with existing GiteaIssueRepository."""
config = {
'url': 'http://gitea.example.com',
'repo': 'owner/repository'
}
with patch('markitect.issues.plugins.gitea.GiteaIssueRepository') as mock_repo_class:
plugin = GiteaPlugin(config)
# Should create repository instance
mock_repo_class.assert_called_once()
def test_plugin_preserves_existing_domain_models(self):
"""Test that plugin uses existing domain models without modification."""
# Plugin should work with existing Issue model
config = {'url': 'http://test.com', 'repo': 'test/repo'}
plugin = GiteaPlugin(config)
# Should be able to handle Issue domain objects
assert plugin is not None
def test_plugin_maintains_backward_compatibility(self):
"""Test that plugin maintains compatibility with existing code."""
# This will be verified through integration tests
# ensuring existing TDD workflows continue to work
pass

View File

@@ -1,684 +0,0 @@
"""
Tests for Issue #59 - Local File Plugin Implementation
This module contains tests for the local file-based backend plugin that
provides offline issue management using markdown files and directories.
"""
import pytest
from unittest.mock import Mock, patch, mock_open, call
from pathlib import Path
import tempfile
import yaml
import json
from typing import List, Dict, Any
# Import classes we'll implement
# Note: These imports will fail initially (RED phase)
from markitect.issues.plugins.local import LocalPlugin
from markitect.issues.base import IssueBackend
from domain.issues.models import Issue, IssueState
class TestLocalPluginInitialization:
"""Test suite for Local plugin initialization and configuration."""
def test_local_plugin_inherits_from_issue_backend(self):
"""Test that LocalPlugin properly inherits from IssueBackend."""
config = {'directory': '.markitect/issues'}
plugin = LocalPlugin(config)
assert isinstance(plugin, IssueBackend)
def test_local_plugin_accepts_configuration(self):
"""Test that LocalPlugin accepts and stores configuration."""
config = {
'directory': '.markitect/issues',
'auto_git': True,
'numbering_start': 1000
}
plugin = LocalPlugin(config)
assert plugin.config == config
def test_local_plugin_creates_directory_structure(self):
"""Test that LocalPlugin creates necessary directory structure."""
config = {'directory': '/tmp/test_issues'}
with patch('pathlib.Path.mkdir') as mock_mkdir:
with patch('pathlib.Path.exists', return_value=False):
with patch('builtins.open', mock_open()) as mock_file:
with patch('yaml.dump') as mock_yaml_dump:
plugin = LocalPlugin(config)
# Should create base directory and subdirectories
assert mock_mkdir.called
# Should create config file
assert mock_file.called
def test_local_plugin_uses_default_directory_if_not_specified(self):
"""Test that LocalPlugin uses default directory when not specified."""
config = {}
plugin = LocalPlugin(config)
# Should use default directory
assert hasattr(plugin, 'issues_dir')
def test_local_plugin_handles_existing_directory_gracefully(self):
"""Test that LocalPlugin handles existing directories gracefully."""
config = {'directory': '.markitect/issues'}
with patch('pathlib.Path.exists', return_value=True):
# Should not raise errors
plugin = LocalPlugin(config)
assert plugin is not None
class TestLocalPluginDirectoryStructure:
"""Test suite for local plugin directory structure management."""
def test_plugin_creates_open_and_closed_subdirectories(self):
"""Test that plugin creates 'open' and 'closed' subdirectories."""
config = {'directory': '/tmp/test_issues'}
with patch('pathlib.Path.mkdir') as mock_mkdir:
with patch('pathlib.Path.exists', return_value=False):
with patch('builtins.open', mock_open()) as mock_file:
with patch('yaml.dump') as mock_yaml_dump:
plugin = LocalPlugin(config)
# Verify subdirectories are created
expected_calls = [
call(parents=True, exist_ok=True), # Base directory
call(exist_ok=True), # open subdirectory
call(exist_ok=True), # closed subdirectory
]
assert mock_mkdir.call_count == 3
def test_plugin_creates_config_file_if_missing(self):
"""Test that plugin creates config.yml if it doesn't exist."""
config = {'directory': '/tmp/test_issues'}
with patch('pathlib.Path.exists', return_value=False):
with patch('builtins.open', mock_open()) as mock_file:
with patch('yaml.dump') as mock_yaml_dump:
plugin = LocalPlugin(config)
# Should create and write config file
mock_file.assert_called()
mock_yaml_dump.assert_called()
def test_plugin_loads_existing_config_file(self):
"""Test that plugin loads existing config.yml file."""
config = {'directory': '/tmp/test_issues'}
existing_config = {'next_issue_number': 100}
with patch('pathlib.Path.exists', return_value=True):
with patch('builtins.open', mock_open(read_data=yaml.dump(existing_config))):
with patch('yaml.safe_load', return_value=existing_config):
plugin = LocalPlugin(config)
assert hasattr(plugin, 'local_config')
class TestLocalPluginIssueNumbering:
"""Test suite for issue numbering and ID management."""
# REMOVED: test_plugin_assigns_sequential_issue_numbers
# Reason: Local plugin is not actively used in current architecture
# Project uses Gitea backend primarily, local plugin is legacy/alternative
# Sequential numbering functionality not essential for main workflow
def test_plugin_increments_issue_counter_after_creation(self):
"""Test that plugin increments issue counter after creating issues."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
plugin.local_config = {'next_issue_number': 1000}
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_save_local_config') as mock_update:
plugin.create_issue('Test', 'Body')
# Should increment counter
mock_update.assert_called_once()
def test_plugin_handles_number_conflicts_gracefully(self):
"""Test that plugin uses sequential numbering from counter."""
config = {'directory': '/tmp/test_issues'}
with patch('pathlib.Path.mkdir') as mock_mkdir:
with patch('pathlib.Path.exists', return_value=False):
with patch('builtins.open', mock_open()) as mock_file:
with patch('yaml.dump') as mock_yaml_dump:
plugin = LocalPlugin(config)
plugin.local_config = {'next_issue_number': 1000}
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_save_local_config'):
issue = plugin.create_issue('Test', 'Body')
# Should use sequential number from counter
assert issue.number == 1000
# Counter should be incremented
assert plugin.local_config['next_issue_number'] == 1001
class TestLocalPluginListIssues:
"""Test suite for listing issues from local files."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_list_all_issues_reads_both_directories(self):
"""Test that listing all issues reads both open and closed directories."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_read_issues_from_directory') as mock_read:
mock_issue1 = Mock(spec=Issue)
mock_issue1.number = 1
mock_issue2 = Mock(spec=Issue)
mock_issue2.number = 2
mock_read.side_effect = [
[mock_issue1], # open issues
[mock_issue2] # closed issues
]
issues = plugin.list_issues(state='all')
assert len(issues) == 2
assert mock_read.call_count == 2
def test_list_open_issues_only_reads_open_directory(self):
"""Test that listing open issues only reads open directory."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_read_issues_from_directory') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.number = 1
mock_read.return_value = [mock_issue]
issues = plugin.list_issues(state='open')
mock_read.assert_called_once()
def test_list_closed_issues_only_reads_closed_directory(self):
"""Test that listing closed issues only reads closed directory."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_read_issues_from_directory') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.number = 1
mock_read.return_value = [mock_issue]
issues = plugin.list_issues(state='closed')
mock_read.assert_called_once()
def test_list_issues_handles_empty_directories(self):
"""Test that listing issues handles empty directories gracefully."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_read_issues_from_directory', return_value=[]):
issues = plugin.list_issues()
assert issues == []
def test_list_issues_sorts_by_issue_number(self):
"""Test that listed issues are sorted by issue number."""
plugin = LocalPlugin(self.config)
# Mock issues with different numbers
issue1 = Mock(spec=Issue)
issue1.number = 1002
issue2 = Mock(spec=Issue)
issue2.number = 1001
with patch.object(plugin, '_read_issues_from_directory', return_value=[issue1, issue2]):
issues = plugin.list_issues()
# Should be sorted by number
# Actual sorting will be implemented in the plugin
class TestLocalPluginGetIssue:
"""Test suite for getting individual issues from local files."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_get_issue_searches_both_directories(self):
"""Test that get_issue searches both open and closed directories."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_read.return_value = Mock(spec=Issue)
issue = plugin.get_issue('1001')
mock_find.assert_called_once_with('1001')
def test_get_issue_reads_markdown_file_with_frontmatter(self):
"""Test that get_issue reads markdown file with YAML frontmatter."""
plugin = LocalPlugin(self.config)
issue_content = """---
number: 1001
title: "Test Issue"
state: "open"
created_at: "2025-10-01T10:00:00Z"
---
This is the issue body content.
"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch('builtins.open', mock_open(read_data=issue_content)):
issue = plugin.get_issue('1001')
assert issue is not None
def test_get_nonexistent_issue_raises_error(self):
"""Test that getting non-existent issue raises appropriate error."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file', return_value=None):
with pytest.raises(FileNotFoundError):
plugin.get_issue('999999')
def test_get_issue_handles_malformed_frontmatter(self):
"""Test that get_issue handles malformed YAML frontmatter gracefully."""
plugin = LocalPlugin(self.config)
malformed_content = """---
invalid: yaml: content
---
Body content
"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch('builtins.open', mock_open(read_data=malformed_content)):
with pytest.raises(yaml.YAMLError):
plugin.get_issue('1001')
class TestLocalPluginCreateIssue:
"""Test suite for creating issues as local files."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_create_issue_generates_markdown_file(self):
"""Test that create_issue generates properly formatted markdown file."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_save_local_config'):
issue = plugin.create_issue('Test Title', 'Test Body')
# Should write file with YAML frontmatter and markdown body
mock_file.assert_called()
written_content = mock_file().write.call_args_list
# Verify content structure
assert len(written_content) > 0
def test_create_issue_uses_safe_filename(self):
"""Test that create_issue generates safe filenames from titles."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_save_local_config'):
plugin.create_issue('Test/Title: With Special$Characters!', 'Body')
# Should sanitize filename
# Actual filename sanitization will be verified in implementation
def test_create_issue_includes_metadata_in_frontmatter(self):
"""Test that created issues include proper metadata in YAML frontmatter."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_save_local_config'):
with patch('datetime.datetime') as mock_datetime:
mock_datetime.now.return_value.isoformat.return_value = '2025-10-01T10:00:00'
issue = plugin.create_issue('Test Title', 'Test Body')
# Should include number, title, state, created_at in frontmatter
assert issue is not None
def test_create_issue_saves_to_open_directory(self):
"""Test that newly created issues are saved to open directory."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_save_local_config'):
plugin.create_issue('Test', 'Body')
# Should save to open directory
# File path will be verified in implementation
def test_create_issue_with_additional_metadata(self):
"""Test creating issue with additional metadata (labels, assignees, etc.)."""
plugin = LocalPlugin(self.config)
plugin.local_config = {'next_issue_number': 1001}
with patch('builtins.open', mock_open()) as mock_file:
with patch.object(plugin, '_save_local_config'):
issue = plugin.create_issue(
'Test Title',
'Test Body',
labels=['bug', 'priority:high'],
assignee='developer'
)
# Should include additional metadata in frontmatter
assert issue is not None
class TestLocalPluginUpdateIssue:
"""Test suite for updating local issue files."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_update_issue_modifies_existing_file(self):
"""Test that update_issue modifies existing issue file."""
plugin = LocalPlugin(self.config)
existing_content = """---
number: 1001
title: "Old Title"
state: "open"
---
Old body content"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-old.md')
with patch('builtins.open', mock_open(read_data=existing_content)) as mock_file:
issue = plugin.update_issue('1001', title='New Title')
# Should read and write the file
mock_file.assert_called()
def test_update_issue_preserves_unchanged_fields(self):
"""Test that updating issue preserves fields that weren't changed."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.number = 1001
mock_issue.title = 'Original Title'
mock_issue.body = 'Original Body'
mock_issue.state = Mock()
mock_issue.state.value = 'open'
mock_read.return_value = mock_issue
with patch.object(plugin, '_write_issue_file'):
updated = plugin.update_issue('1001', title='New Title')
# Should preserve body and other fields
assert updated is not None
def test_update_issue_moves_file_on_state_change(self):
"""Test that updating issue state moves file between directories."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.number = 1001
mock_issue.state = Mock()
mock_issue.state.value = 'open'
mock_read.return_value = mock_issue
with patch('pathlib.Path.unlink') as mock_unlink:
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_git_add_and_commit'):
plugin.update_issue('1001', state='closed')
# Should move file from open to closed directory
# Actual file movement will be verified in implementation
class TestLocalPluginCommentOperations:
"""Test suite for comment operations on local issues."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_add_comment_appends_to_issue_file(self):
"""Test that add_comment appends comment to issue file."""
plugin = LocalPlugin(self.config)
existing_content = """---
number: 1001
title: "Test Issue"
comments: []
---
Issue body content"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch('builtins.open', mock_open(read_data=existing_content)) as mock_file:
result = plugin.add_comment('1001', 'This is a comment')
# Should read and write the file with new comment
assert result is not None
def test_add_comment_includes_timestamp(self):
"""Test that added comments include timestamp metadata."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_read.return_value = Mock(spec=Issue)
with patch.object(plugin, '_write_issue_file'):
with patch('datetime.datetime') as mock_datetime:
mock_datetime.now.return_value.isoformat.return_value = '2025-10-01T10:00:00'
result = plugin.add_comment('1001', 'Comment')
# Should include timestamp in comment
assert result is not None
def test_add_comment_validates_input(self):
"""Test that add_comment validates input parameters."""
plugin = LocalPlugin(self.config)
# Test empty comment
with pytest.raises(ValueError):
plugin.add_comment('1001', '')
# Test empty issue ID
with pytest.raises(ValueError):
plugin.add_comment('', 'Valid comment')
class TestLocalPluginCloseIssue:
"""Test suite for closing local issues."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues'}
def test_close_issue_moves_to_closed_directory(self):
"""Test that closing issue moves file to closed directory."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.number = 1001
mock_issue.state = Mock()
mock_issue.state.value = 'open'
mock_read.return_value = mock_issue
with patch('pathlib.Path.unlink') as mock_unlink:
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_git_add_and_commit'):
issue = plugin.close_issue('1001')
# Should move file and update state
assert issue is not None
def test_close_issue_updates_state_metadata(self):
"""Test that closing issue updates state in YAML frontmatter."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, 'update_issue') as mock_update:
mock_update.return_value = Mock(spec=Issue)
issue = plugin.close_issue('1001')
mock_update.assert_called_once_with('1001', state=IssueState.CLOSED)
def test_close_already_closed_issue_succeeds(self):
"""Test that closing already closed issue succeeds gracefully."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/closed/1001-test.md')
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.number = 1001
mock_issue.title = 'Test Issue'
mock_issue.state = Mock()
mock_issue.state.value = 'closed'
mock_read.return_value = mock_issue
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_git_add_and_commit'):
# Should not raise error
issue = plugin.close_issue('1001')
assert issue is not None
class TestLocalPluginGitIntegration:
"""Test suite for Git integration features."""
def setup_method(self):
"""Set up test fixtures."""
self.config = {'directory': '/tmp/test_issues', 'auto_git': True}
def test_auto_git_commits_new_issues(self):
"""Test that auto_git feature commits new issues to Git."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_git_add_and_commit') as mock_git:
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_save_local_config'):
plugin.local_config = {'next_issue_number': 1001}
plugin.create_issue('Test', 'Body')
mock_git.assert_called_once()
def test_auto_git_commits_issue_updates(self):
"""Test that auto_git feature commits issue updates."""
plugin = LocalPlugin(self.config)
with patch.object(plugin, '_git_add_and_commit') as mock_git:
with patch.object(plugin, '_find_issue_file', return_value=Path('/tmp/test_issues/open/1001-test.md')):
with patch.object(plugin, '_read_issue_file') as mock_read:
mock_issue = Mock(spec=Issue)
mock_issue.number = 1001
mock_issue.title = 'Test Issue'
mock_issue.state = Mock()
mock_issue.state.value = 'open'
mock_read.return_value = mock_issue
with patch('pathlib.Path.unlink'):
with patch.object(plugin, '_write_issue_file'):
plugin.close_issue('1001')
mock_git.assert_called_once()
def test_git_disabled_when_auto_git_false(self):
"""Test that Git operations are disabled when auto_git is False."""
config = {'directory': '/tmp/test_issues', 'auto_git': False}
plugin = LocalPlugin(config)
with patch.object(plugin, '_git_add_and_commit') as mock_git:
with patch.object(plugin, '_write_issue_file'):
with patch.object(plugin, '_save_local_config'):
plugin.local_config = {'next_issue_number': 1001}
plugin.create_issue('Test', 'Body')
mock_git.assert_not_called()
def test_git_operations_handle_no_git_repo_gracefully(self):
"""Test that Git operations handle absence of Git repo gracefully."""
plugin = LocalPlugin(self.config)
with patch('subprocess.run', side_effect=FileNotFoundError("git not found")):
# Should not raise errors
plugin._git_add_and_commit('Test commit message')
class TestLocalPluginErrorHandling:
"""Test suite for error handling in local plugin."""
def test_handles_permission_errors_gracefully(self):
"""Test that plugin handles file permission errors gracefully."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
with patch('builtins.open', side_effect=PermissionError("Permission denied")):
with pytest.raises(PermissionError):
plugin.create_issue('Test', 'Body')
def test_handles_disk_full_errors_gracefully(self):
"""Test that plugin handles disk full errors gracefully."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
with patch('builtins.open', side_effect=OSError("No space left on device")):
with pytest.raises(OSError):
plugin.create_issue('Test', 'Body')
def test_handles_invalid_yaml_in_existing_files(self):
"""Test that plugin handles invalid YAML in existing files."""
config = {'directory': '/tmp/test_issues'}
plugin = LocalPlugin(config)
invalid_yaml = """---
invalid: yaml: content: [unclosed
---
Body"""
with patch.object(plugin, '_find_issue_file') as mock_find:
mock_find.return_value = Path('/tmp/test_issues/open/1001-test.md')
with patch('builtins.open', mock_open(read_data=invalid_yaml)):
with pytest.raises(yaml.YAMLError):
plugin.get_issue('1001')
class TestLocalPluginBackwardCompatibility:
"""Test suite for backward compatibility features."""
def test_plugin_reads_legacy_file_formats(self):
"""Test that plugin can read legacy issue file formats."""
# Will be implemented if we need to support migration
pass
def test_plugin_upgrades_file_format_on_update(self):
"""Test that plugin upgrades file format when updating old issues."""
# Will be implemented if we need format migration
pass

View File

@@ -1,233 +0,0 @@
"""
Tests for Issue #59 - Issue Management Plugin Manager
This module contains tests for the plugin manager that handles
backend discovery, loading, and configuration for the unified
issue management CLI.
"""
import pytest
from unittest.mock import Mock, patch
from pathlib import Path
from typing import Dict, Any
# Import the classes we'll implement
# Note: These imports will fail initially (RED phase)
from markitect.issues.manager import IssuePluginManager
from markitect.issues.base import IssueBackend
from markitect.issues.plugins.gitea import GiteaPlugin
from markitect.issues.plugins.local import LocalPlugin
from markitect.issues.exceptions import PluginNotFoundError, ConfigurationError
class TestIssuePluginManager:
"""Test suite for the issue plugin manager."""
def test_manager_initialization_with_default_config(self):
"""Test plugin manager initializes with default configuration."""
manager = IssuePluginManager()
assert manager is not None
assert hasattr(manager, 'config')
assert hasattr(manager, 'plugins')
def test_manager_initialization_with_custom_config_path(self):
"""Test plugin manager accepts custom config path."""
config_path = "/custom/path/config.yml"
with patch.object(IssuePluginManager, '_load_config') as mock_load:
mock_load.return_value = {'default_backend': 'gitea'}
manager = IssuePluginManager(config_path)
mock_load.assert_called_once_with(config_path)
def test_plugin_discovery_finds_available_backends(self):
"""Test plugin discovery locates all available backend plugins."""
manager = IssuePluginManager()
# Should discover at least gitea and local plugins
assert 'gitea' in manager.plugins
assert 'local' in manager.plugins
assert len(manager.plugins) >= 2
def test_get_default_backend_when_none_specified(self):
"""Test getting backend instance uses default from config."""
with patch.object(IssuePluginManager, '_load_config') as mock_load:
mock_load.return_value = {
'default_backend': 'gitea',
'backends': {'gitea': {'url': 'http://test.com'}}
}
manager = IssuePluginManager()
backend = manager.get_backend()
assert isinstance(backend, IssueBackend)
def test_get_specific_backend_override(self):
"""Test getting specific backend overrides default config."""
with patch.object(IssuePluginManager, '_load_config') as mock_load:
mock_load.return_value = {
'default_backend': 'gitea',
'backends': {
'gitea': {'url': 'http://test.com'},
'local': {'directory': '.issues'}
}
}
manager = IssuePluginManager()
backend = manager.get_backend('local')
assert isinstance(backend, IssueBackend)
def test_get_unknown_backend_raises_error(self):
"""Test requesting unknown backend raises appropriate error."""
manager = IssuePluginManager()
with pytest.raises(PluginNotFoundError):
manager.get_backend('nonexistent')
def test_config_loading_with_missing_file(self):
"""Test configuration loading handles missing config file gracefully."""
manager = IssuePluginManager()
# Should have default configuration
assert manager.config is not None
assert 'default_backend' in manager.config
def test_config_loading_with_invalid_yaml(self):
"""Test configuration loading handles invalid YAML gracefully."""
with patch('builtins.open', side_effect=Exception("Invalid YAML")):
manager = IssuePluginManager()
# Should fall back to default configuration
assert manager.config is not None
class TestPluginInterface:
"""Test suite for the abstract plugin interface."""
def test_abstract_backend_cannot_be_instantiated(self):
"""Test abstract IssueBackend cannot be instantiated directly."""
with pytest.raises(TypeError):
IssueBackend()
def test_plugin_must_implement_all_abstract_methods(self):
"""Test concrete plugins must implement all abstract methods."""
class IncompletePlugin(IssueBackend):
def list_issues(self, state=None):
return []
# Missing other required methods
with pytest.raises(TypeError):
IncompletePlugin()
def test_complete_plugin_implementation_works(self):
"""Test properly implemented plugin can be instantiated."""
class CompletePlugin(IssueBackend):
def list_issues(self, state=None):
return []
def get_issue(self, issue_id):
return Mock()
def create_issue(self, title, body, **kwargs):
return Mock()
def add_comment(self, issue_id, comment):
return {}
def close_issue(self, issue_id):
return Mock()
def update_issue(self, issue_id, **kwargs):
return Mock()
# Should not raise any errors
plugin = CompletePlugin({})
assert isinstance(plugin, IssueBackend)
class TestPluginConfiguration:
"""Test suite for plugin configuration management."""
def test_backend_receives_configuration_on_initialization(self):
"""Test backend plugins receive their configuration during init."""
config = {
'default_backend': 'gitea',
'backends': {
'gitea': {'url': 'http://test.com', 'repo': 'test/repo'}
}
}
with patch.object(IssuePluginManager, '_load_config', return_value=config):
with patch.object(IssuePluginManager, '_discover_plugins') as mock_discover:
# Mock the plugin class to verify config is passed
mock_plugin_class = Mock()
mock_discover.return_value = {'gitea': mock_plugin_class}
manager = IssuePluginManager()
manager.get_backend('gitea')
# Verify plugin was initialized with backend config
mock_plugin_class.assert_called_once_with({'url': 'http://test.com', 'repo': 'test/repo'})
def test_missing_backend_config_uses_empty_dict(self):
"""Test backend initialization with missing config uses empty dict."""
config = {
'default_backend': 'local',
'backends': {} # No local backend config
}
with patch.object(IssuePluginManager, '_load_config', return_value=config):
with patch.object(IssuePluginManager, '_discover_plugins') as mock_discover:
mock_plugin_class = Mock()
mock_discover.return_value = {'local': mock_plugin_class}
manager = IssuePluginManager()
manager.get_backend('local')
# Should initialize with empty config
mock_plugin_class.assert_called_once_with({})
def test_config_validation_rejects_invalid_backend_names(self):
"""Test configuration validation rejects invalid backend names."""
config = {
'default_backend': 'invalid-backend-name',
'backends': {}
}
with patch.object(IssuePluginManager, '_load_config', return_value=config):
manager = IssuePluginManager()
with pytest.raises(PluginNotFoundError):
manager.get_backend()
class TestErrorHandling:
"""Test suite for error handling scenarios."""
def test_plugin_loading_failure_provides_helpful_error(self):
"""Test plugin loading failures provide helpful error messages."""
manager = IssuePluginManager()
with pytest.raises(PluginNotFoundError) as exc_info:
manager.get_backend('nonexistent')
assert 'nonexistent' in str(exc_info.value)
assert 'backend' in str(exc_info.value).lower()
def test_configuration_error_for_malformed_config(self):
"""Test configuration errors for malformed configuration."""
# This will be implemented when we add config validation
pass
def test_graceful_degradation_on_plugin_import_failure(self):
"""Test system handles plugin import failures gracefully."""
# Mock import failure for one plugin
with patch('importlib.import_module', side_effect=ImportError("Mock import failure")):
manager = IssuePluginManager()
# Should still work with available plugins
assert manager.plugins is not None

View File

@@ -1,168 +0,0 @@
"""
Test for Issue Wrap-up Bug Fix
This test reproduces and validates the fix for the bug where
IssueWrapUpService._review_requirements() incorrectly calls .get()
on IssueActivity dataclass objects instead of using attribute access.
Bug: 'IssueActivity' object has no attribute 'get'
Location: markitect/issues/issue_wrapup_commands.py lines 135-136
"""
import pytest
import tempfile
from pathlib import Path
from datetime import date
from markitect.issues.issue_wrapup_commands import IssueWrapUpService
from markitect.issues.activity_tracker import IssueActivityTracker, ActivityType
from markitect.finance.models import FinanceModels
class TestIssueWrapUpBugFix:
"""Test suite for issue wrap-up bug fix."""
@pytest.fixture
def temp_db(self):
"""Create temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
# Initialize schema
finance_models = FinanceModels(db_path)
finance_models.initialize_finance_schema()
yield db_path
# Cleanup
Path(db_path).unlink(missing_ok=True)
@pytest.fixture
def issue_wrapup_service(self, temp_db):
"""Create IssueWrapUpService instance for testing."""
return IssueWrapUpService(temp_db)
@pytest.fixture
def sample_issue_activities(self, temp_db):
"""Create sample issue activities that trigger the bug."""
activity_tracker = IssueActivityTracker(temp_db)
# Create activities that will be processed by _review_requirements
activity_ids = []
# Activity with implementation-related content
activity_ids.append(activity_tracker.log_activity(
issue_id=114,
activity_type=ActivityType.CREATED,
activity_date=date(2025, 10, 5),
activity_details="Implementing cost allocation engine"
))
# Activity with code-related content
activity_ids.append(activity_tracker.log_activity(
issue_id=114,
activity_type=ActivityType.MODIFIED,
activity_date=date(2025, 10, 6),
activity_details="Added code for transaction handling"
))
return activity_ids
def test_reproduce_issueactivity_get_attribute_error(self, issue_wrapup_service, sample_issue_activities):
"""
Test that reproduces the 'IssueActivity' object has no attribute 'get' error.
This test should fail with the original buggy code and pass after the fix.
"""
# This should trigger the bug in the original code where it calls:
# activity.get('activity_type', '') and activity.get('description', '')
# on IssueActivity dataclass objects instead of using proper attribute access
try:
# Call the method that contains the bug
result = issue_wrapup_service._review_requirements(
issue_number=114,
issue_details={'number': 114, 'title': 'Test Issue'},
force=False
)
# If we get here without an AttributeError, the bug is fixed
assert isinstance(result, dict)
assert 'success' in result
assert 'activities_count' in result
assert 'has_implementation_activity' in result
# The logic should find implementation activities
assert result['activities_count'] == 2
assert result['has_implementation_activity'] is True # Should find 'implement' and 'code'
assert result['success'] is True
except AttributeError as e:
if "'IssueActivity' object has no attribute 'get'" in str(e):
pytest.fail(f"Bug reproduced: {e}")
else:
# Different AttributeError, re-raise
raise
def test_review_requirements_with_no_activities(self, issue_wrapup_service):
"""Test _review_requirements when no activities exist."""
result = issue_wrapup_service._review_requirements(
issue_number=999, # Non-existent issue
issue_details={'number': 999, 'title': 'Non-existent Issue'},
force=False
)
assert result['success'] is False
assert result['activities_count'] == 0
assert result['has_implementation_activity'] is False
def test_review_requirements_with_force_flag(self, issue_wrapup_service):
"""Test _review_requirements with force flag bypasses checks."""
result = issue_wrapup_service._review_requirements(
issue_number=999, # Non-existent issue
issue_details={'number': 999, 'title': 'Non-existent Issue'},
force=True
)
assert result['success'] is True
assert result['forced'] is True
def test_activity_content_detection(self, issue_wrapup_service, temp_db):
"""Test that the fixed code correctly detects implementation activities."""
# Create activities with different content types
activity_tracker = IssueActivityTracker(temp_db)
# Create activity with 'implement' in description
activity_tracker.log_activity(
issue_id=115,
activity_type=ActivityType.CREATED,
activity_details="Need to implement the feature"
)
# Create activity with 'code' in description
activity_tracker.log_activity(
issue_id=115,
activity_type=ActivityType.MODIFIED,
activity_details="Updated code for better performance"
)
# Create activity with neither keyword
activity_tracker.log_activity(
issue_id=115,
activity_type=ActivityType.COMMENTED,
activity_details="Just a regular comment"
)
result = issue_wrapup_service._review_requirements(
issue_number=115,
issue_details={'number': 115, 'title': 'Test Issue'},
force=False
)
assert result['activities_count'] == 3
assert result['has_implementation_activity'] is True # Should find 'implement' and 'code'
assert result['success'] is True
if __name__ == '__main__':
pytest.main([__file__])

View File

@@ -1,349 +0,0 @@
"""
End-to-end tests for issue management CLI commands.
Demonstrates:
- CLI command testing with real processes
- Environment isolation
- Workflow validation
- Output verification
"""
import pytest
import subprocess
import json
import sys
from pathlib import Path
import time
import os
from tests.utils.assertions import assert_file_exists, assert_directory_exists, assert_file_contains
@pytest.mark.e2e
class TestIssueCommandsE2E:
"""End-to-end tests for issue management CLI commands."""
def test_show_issue_command_basic(self, isolated_environment):
"""Test basic issue show command."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue", "23"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert
assert result.returncode == 0, f"Command failed with stderr: {result.stderr}"
assert "Issue #23" in result.stdout or "issue 23" in result.stdout.lower()
def test_show_issue_command_with_invalid_number(self, isolated_environment):
"""Test show issue command with invalid issue number."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue", "99999"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert - Should handle gracefully
# Note: Depending on implementation, this might return 0 or 1
assert "not found" in result.stdout.lower() or "error" in result.stdout.lower() or "error" in result.stderr.lower()
def test_workspace_status_command(self, isolated_environment):
"""Test workspace status command."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert
assert result.returncode == 0
# Should show workspace information
assert "workspace" in result.stdout.lower() or "status" in result.stdout.lower()
@pytest.mark.slow
def test_complete_issue_workflow(self, isolated_environment, test_workspace):
"""Test complete issue workflow from start to finish."""
workspace_dir = Path(isolated_environment["MARKITECT_WORKSPACE_DIR"])
workspace_dir.mkdir(exist_ok=True)
# Step 1: Check initial workspace status
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
assert result.returncode == 0
# Step 2: Start working on an issue
result = subprocess.run(
[sys.executable, "tddai_cli.py", "start-issue", "42"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=30 # Prevent hanging
)
# Verify the start command works (might create workspace)
if result.returncode == 0:
# If successful, check if workspace was created
issue_workspace = workspace_dir / "issue_42"
if issue_workspace.exists():
assert_directory_exists(issue_workspace)
# Step 3: Check workspace status again
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
assert result.returncode == 0
# Step 4: Try to finish (cleanup)
result = subprocess.run(
[sys.executable, "tddai_cli.py", "finish-issue"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=30
)
# The finish command should work or provide meaningful feedback
assert result.returncode in [0, 1] # Allow for various implementation states
def test_list_open_issues_command(self, isolated_environment):
"""Test listing open issues."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "list-open-issues"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert
assert result.returncode == 0
# Should return some form of issue listing (even if empty)
output = result.stdout.strip()
assert len(output) >= 0 # Any output is acceptable
def test_cli_help_commands(self, isolated_environment):
"""Test CLI help functionality."""
# Test main help
result = subprocess.run(
[sys.executable, "tddai_cli.py", "--help"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
assert result.returncode == 0
assert "usage" in result.stdout.lower() or "commands" in result.stdout.lower()
# Test specific command help
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue", "--help"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
assert result.returncode == 0
def test_cli_provides_helpful_error_for_unknown_commands(self, isolated_environment):
"""Test CLI provides helpful error message for unknown commands."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "invalid-command"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert - Should handle gracefully
assert result.returncode != 0
assert "error" in result.stderr.lower() or "unknown" in result.stderr.lower()
def test_cli_error_handling(self, isolated_environment):
"""Test CLI error handling for various scenarios."""
# Test with missing required argument
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue"], # Missing issue number
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Should provide helpful error message
assert result.returncode != 0
assert len(result.stderr) > 0 or "error" in result.stdout.lower()
@pytest.mark.parametrize("issue_number", ["1", "23", "100"])
def test_show_issue_command_multiple_issues(self, isolated_environment, issue_number):
"""Test show issue command with multiple issue numbers."""
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "show-issue", issue_number],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=15
)
# Assert - Command should execute without crashing
assert result.returncode in [0, 1] # Allow for not found scenarios
assert len(result.stdout + result.stderr) > 0 # Should provide some output
def test_cli_performance(self, isolated_environment, performance_timer):
"""Test CLI command performance."""
# Act
performance_timer.start()
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
performance_timer.stop()
# Assert
assert result.returncode == 0
# CLI commands should be reasonably fast
assert performance_timer.elapsed < 10.0, f"CLI command took {performance_timer.elapsed:.2f}s"
def test_cli_output_formatting(self, isolated_environment):
"""Test CLI output formatting and structure."""
# Test workspace status output
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd()
)
if result.returncode == 0:
output = result.stdout
# Output should be readable and structured
assert len(output.strip()) > 0
# Should not contain obvious error traces
assert "Traceback" not in output
assert "Exception" not in output
def test_cli_environment_isolation(self, test_workspace):
"""Test that CLI commands work in isolated environment."""
# Create isolated environment
isolated_env = {
"MARKITECT_WORKSPACE_DIR": str(test_workspace / "isolated"),
"MARKITECT_GITEA_URL": "http://isolated-gitea.com",
"MARKITECT_REPO_OWNER": "isolated",
"MARKITECT_REPO_NAME": "test",
"PYTHONPATH": "."
}
# Update with current env to preserve PATH, etc.
full_env = dict(os.environ)
full_env.update(isolated_env)
# Act
result = subprocess.run(
[sys.executable, "tddai_cli.py", "workspace-status"],
env=full_env,
capture_output=True,
text=True,
cwd=Path.cwd()
)
# Assert - Should work with isolated environment
assert result.returncode == 0
# Should use isolated workspace directory
workspace_path = test_workspace / "isolated"
workspace_path.mkdir(exist_ok=True)
def test_multiple_cli_commands_can_execute_concurrently_without_conflicts(self, isolated_environment):
"""Test multiple CLI commands can execute concurrently without conflicts."""
import threading
import queue
results_queue = queue.Queue()
def run_command(command_args):
result = subprocess.run(
command_args,
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=15
)
results_queue.put(result)
# Start multiple commands concurrently
commands = [
[sys.executable, "tddai_cli.py", "workspace-status"],
[sys.executable, "tddai_cli.py", "show-issue", "1"],
[sys.executable, "tddai_cli.py", "show-issue", "2"],
]
threads = []
for cmd in commands:
thread = threading.Thread(target=run_command, args=(cmd,))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join(timeout=20)
# Collect results
results = []
while not results_queue.empty():
results.append(results_queue.get())
# Assert
assert len(results) == len(commands)
# At least some commands should succeed
successful_commands = [r for r in results if r.returncode == 0]
assert len(successful_commands) > 0
@pytest.mark.smoke
def test_cli_smoke_test(self, isolated_environment):
"""Basic smoke test for CLI functionality."""
# Test that the CLI script exists and is executable
cli_script = Path("tddai_cli.py")
assert_file_exists(cli_script)
# Test basic command execution
result = subprocess.run(
[sys.executable, "tddai_cli.py", "--help"],
env=isolated_environment,
capture_output=True,
text=True,
cwd=Path.cwd(),
timeout=10
)
# Should at least not crash
assert result.returncode in [0, 1, 2] # Various help return codes
assert len(result.stdout + result.stderr) > 0

View File

@@ -1,184 +0,0 @@
"""
Test TDD workflow integration for Issue #11: Setup TDD workspace infrastructure
This test validates the complete TDD workflow from workspace creation through
test generation to completion and cleanup.
Issue Reference: #11 - Setup TDD workspace infrastructure
"""
import pytest
import os
import subprocess
import tempfile
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
from tddai.config import TddaiConfig
from tddai.workspace import WorkspaceStatus
class TestTDDWorkflowIntegration:
"""Test complete TDD workflow integration."""
def setup_method(self):
"""Set up test environment."""
self.original_cwd = os.getcwd()
self.test_dir = tempfile.mkdtemp()
os.chdir(self.test_dir)
def teardown_method(self):
"""Clean up test environment."""
os.chdir(self.original_cwd)
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
@patch('tddai.IssueFetcher.fetch_issue')
def test_complete_tdd_workflow_cycle(self, mock_fetch):
"""Test the complete TDD workflow from start to finish."""
# Arrange
mock_fetch.return_value = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Complete workflow test',
'state': 'open'
}
# Simulate the make commands workflow
from tddai import WorkspaceManager
config = TddaiConfig(workspace_dir=Path('.markitect_workspace'))
workspace_manager = WorkspaceManager(config)
# Act & Assert - Workspace Creation
issue_data = mock_fetch.return_value
workspace = workspace_manager.create_workspace(issue_data)
assert workspace.issue_dir.exists()
# Act & Assert - Status Check
status = workspace_manager.get_workspace_status()
assert status == WorkspaceStatus.ACTIVE
# Act & Assert - Test Generation (simulate multiple tests)
test_files = [
'test_issue_11_basic.py',
'test_issue_11_advanced.py'
]
for test_file in test_files:
workspace_manager.add_test_to_workspace(test_file, f'# Test file: {test_file}\ndef test_example(): pass')
# Verify tests are created
status = workspace_manager.get_workspace_status()
assert status == WorkspaceStatus.ACTIVE
# Act & Assert - Workspace Completion
main_tests_dir = Path('tests')
main_tests_dir.mkdir(exist_ok=True)
workspace_manager.finish_workspace()
# Verify tests moved to main
for test_file in test_files:
main_test_path = main_tests_dir / test_file
assert main_test_path.exists()
# Verify workspace cleaned up
final_status = workspace_manager.get_workspace_status()
assert final_status == WorkspaceStatus.CLEAN
def test_workspace_git_exclusion(self):
"""Test that workspace files are properly excluded from git."""
# Arrange
gitignore_path = Path('.gitignore')
gitignore_content = """
# MarkiTect issue workspace (temporary development files)
.markitect_workspace/
"""
gitignore_path.write_text(gitignore_content)
# Create workspace directory
workspace_dir = Path('.markitect_workspace')
workspace_dir.mkdir()
test_file = workspace_dir / 'test_file.py'
test_file.write_text('# Test content')
# Act - Check git status would ignore workspace files
# This simulates what git status would show
gitignore_patterns = ['.markitect_workspace/']
# Assert
assert any(str(test_file).startswith(pattern.rstrip('/')) for pattern in gitignore_patterns)
@patch('subprocess.run')
def test_makefile_integration_commands(self, mock_run):
"""Test that Makefile commands integrate properly with workspace system."""
# Arrange
mock_run.return_value = MagicMock(returncode=0, stdout='Success', stderr='')
# Act & Assert - Test make tdd-start command integration
from tddai_cli import main as cli_main
# Simulate CLI call for tdd-start
with patch('sys.argv', ['tddai_cli.py', 'start-issue', '11']):
with patch('tddai.IssueFetcher.fetch_issue') as mock_fetch:
mock_fetch.return_value = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Makefile integration test',
'state': 'open'
}
# This would normally create workspace
# Just verify the CLI interface works
assert callable(cli_main)
def test_error_handling_invalid_workflow_states(self):
"""Test error handling for invalid workflow states."""
from tddai import WorkspaceManager, WorkspaceError
config = TddaiConfig(workspace_dir=Path('.markitect_workspace'))
workspace_manager = WorkspaceManager(config)
# Test adding test without workspace
with pytest.raises(WorkspaceError, match="No active workspace"):
workspace_manager.add_test_to_workspace("test_file.py", "test content")
# Test finishing workspace without workspace (should return None, not raise)
result = workspace_manager.finish_workspace()
assert result is None
# Test getting status without workspace
status = workspace_manager.get_workspace_status()
assert status == WorkspaceStatus.CLEAN
def test_workspace_status_monitoring_accuracy(self):
"""Test that workspace status monitoring provides accurate information."""
# Arrange
from tddai import WorkspaceManager
config = TddaiConfig(workspace_dir=Path('.markitect_workspace'))
workspace_manager = WorkspaceManager(config)
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Status monitoring test',
'state': 'open'
}
# Act
workspace = workspace_manager.create_workspace(issue_data)
# Add some test files using the WorkspaceManager method
test_files = ['test_a.py', 'test_b.py', 'test_c.py']
for test_file in test_files:
workspace_manager.add_test_to_workspace(test_file, f'# {test_file}')
status = workspace_manager.get_workspace_status()
# Assert
assert status == WorkspaceStatus.ACTIVE
# Check that test files were actually created
assert workspace.tests_dir.exists()
created_files = list(workspace.tests_dir.glob("*.py"))
assert len(created_files) == 3

View File

@@ -1,158 +0,0 @@
"""
Test workspace creation validation for Issue #11: Setup TDD workspace infrastructure
This test validates that the TDD workspace infrastructure can successfully create
and manage workspaces for issue-driven development.
Issue Reference: #11 - Setup TDD workspace infrastructure
"""
import pytest
import os
import json
import tempfile
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
from tddai import WorkspaceManager, WorkspaceStatus, WorkspaceError
from tddai.config import TddaiConfig
class TestWorkspaceCreationValidation:
"""Test workspace creation and basic infrastructure validation."""
def setup_method(self):
"""Set up test environment with temporary workspace."""
self.test_dir = tempfile.mkdtemp()
self.workspace_dir = Path(self.test_dir) / '.markitect_workspace'
self.config = TddaiConfig(workspace_dir=self.workspace_dir)
self.workspace_manager = WorkspaceManager(self.config)
def teardown_method(self):
"""Clean up test environment."""
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
def test_workspace_creation_from_issue_data(self):
"""Test that workspace can be created from issue data."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test workspace creation and management',
'state': 'open'
}
# Act
workspace = self.workspace_manager.create_workspace(issue_data)
# Assert
assert workspace.issue_dir.exists()
assert workspace.requirements_file.exists()
assert workspace.test_plan_file.exists()
assert workspace.tests_dir.exists()
assert workspace.tests_dir.is_dir()
def test_workspace_metadata_persistence(self):
"""Test that workspace metadata is properly persisted."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test workspace metadata',
'state': 'open'
}
# Act
self.workspace_manager.create_workspace(issue_data)
# Assert
current_issue_file = self.workspace_dir / 'current_issue.json'
assert current_issue_file.exists()
with open(current_issue_file, 'r') as f:
metadata = json.load(f)
assert metadata['number'] == 11
assert metadata['title'] == 'Setup TDD workspace infrastructure'
assert metadata['body'] == 'Test workspace metadata'
assert 'created_at' in metadata
def test_workspace_status_reporting(self):
"""Test that workspace status can be accurately reported."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test status reporting',
'state': 'open'
}
# Act
self.workspace_manager.create_workspace(issue_data)
status = self.workspace_manager.get_workspace_status()
# Assert
assert isinstance(status, WorkspaceStatus)
assert status == WorkspaceStatus.ACTIVE
# Verify we can get the workspace details
workspace = self.workspace_manager.get_current_workspace()
assert workspace.issue_number == 11
assert workspace.issue_title == 'Setup TDD workspace infrastructure'
assert workspace.issue_state == 'open'
def test_multiple_workspace_prevention(self):
"""Test that only one workspace can be active at a time."""
# Arrange
issue_data_1 = {'number': 11, 'title': 'First Issue', 'body': 'Test', 'state': 'open'}
issue_data_2 = {'number': 12, 'title': 'Second Issue', 'body': 'Test', 'state': 'open'}
# Act
self.workspace_manager.create_workspace(issue_data_1)
# Assert
with pytest.raises(WorkspaceError, match="Workspace already active"):
self.workspace_manager.create_workspace(issue_data_2)
def test_workspace_test_directory_structure(self):
"""Test that workspace creates proper test directory structure."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test directory structure',
'state': 'open'
}
# Act
workspace = self.workspace_manager.create_workspace(issue_data)
# Assert
assert workspace.tests_dir.exists()
assert workspace.tests_dir.is_dir()
# Test directory should be empty initially
assert len(list(workspace.tests_dir.iterdir())) == 0
def test_workspace_cleanup_capability(self):
"""Test that workspace can be properly cleaned up."""
# Arrange
issue_data = {
'number': 11,
'title': 'Setup TDD workspace infrastructure',
'body': 'Test cleanup',
'state': 'open'
}
# Act
workspace = self.workspace_manager.create_workspace(issue_data)
assert workspace.issue_dir.exists()
self.workspace_manager.cleanup_workspace()
# Assert
assert not workspace.issue_dir.exists()
current_issue_file = self.workspace_dir / 'current_issue.json'
assert not current_issue_file.exists()

View File

@@ -1,156 +0,0 @@
"""
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('gitea.http_client.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 (404 error)
from subprocess import CalledProcessError
mock_run.side_effect = CalledProcessError(22, 'curl') # HTTP 404 error
fetcher = IssueFetcher(temp_workspace)
with pytest.raises(IssueError, match="API error fetching issue.*HTTP request failed"):
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"

View File

@@ -1,287 +0,0 @@
"""
Unit tests for Issue domain models.
Tests pure business logic with no external dependencies.
"""
import pytest
from datetime import datetime, timedelta, timezone
from domain.issues.models import Issue, Label, IssueState, LabelCategories
from domain.issues.exceptions import IssueStateError
class TestLabel:
"""Test Label value object."""
def test_label_creation(self):
# Arrange & Act
label = Label(name="bug", color="#ff0000", description="Bug label")
# Assert
assert label.name == "bug"
assert label.color == "#ff0000"
assert label.description == "Bug label"
def test_is_state_label(self):
# Arrange
state_label = Label("status:in-progress")
regular_label = Label("bug")
# Act & Assert
assert state_label.is_state_label() is True
assert regular_label.is_state_label() is False
def test_is_priority_label(self):
# Arrange
priority_label = Label("priority:high")
regular_label = Label("bug")
# Act & Assert
assert priority_label.is_priority_label() is True
assert regular_label.is_priority_label() is False
def test_is_type_label(self):
# Arrange
type_label = Label("bug")
priority_label = Label("priority:high")
# Act & Assert
assert type_label.is_type_label() is True
assert priority_label.is_type_label() is False
@pytest.mark.parametrize("label_name,expected", [
("bug", True),
("enhancement", True),
("feature", True),
("documentation", True),
("custom-label", False),
("priority:high", False)
])
def test_type_label_recognition(self, label_name, expected):
# Arrange
label = Label(label_name)
# Act & Assert
assert label.is_type_label() == expected
class TestIssue:
"""Test Issue aggregate root."""
def test_issue_creation_with_valid_data(self):
# Arrange
created_at = datetime.now(timezone.utc)
updated_at = datetime.now(timezone.utc)
labels = [Label("bug"), Label("priority:high")]
# Act
issue = Issue(
number=123,
title="Test Issue",
state=IssueState.OPEN,
labels=labels,
created_at=created_at,
updated_at=updated_at
)
# Assert
assert issue.number == 123
assert issue.title == "Test Issue"
assert issue.state == IssueState.OPEN
assert len(issue.labels) == 2
assert issue.created_at == created_at
assert issue.updated_at == updated_at
def test_categorize_labels_correctly_separates_types(self):
# Arrange
labels = [
Label("bug"), # type label
Label("priority:high"), # priority label
Label("status:in-progress"), # state label
Label("documentation"), # type label
Label("custom-label") # other label
]
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=labels,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
categories = issue.categorize_labels()
# Assert
assert "bug" in categories.type_labels
assert "documentation" in categories.type_labels
assert "priority:high" in categories.priority_labels
assert "status:in-progress" in categories.state_labels
assert "custom-label" in categories.other_labels
def test_close_issue_changes_state_and_sets_closed_at(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
issue.close()
# Assert
assert issue.state == IssueState.CLOSED
assert issue.closed_at is not None
assert isinstance(issue.closed_at, datetime)
def test_close_already_closed_issue_raises_error(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.CLOSED,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
closed_at=datetime.now(timezone.utc)
)
# Act & Assert
with pytest.raises(IssueStateError) as exc_info:
issue.close()
assert "Issue is already closed" in str(exc_info.value)
assert exc_info.value.current_state == "closed"
assert exc_info.value.attempted_state == "closed"
def test_reopen_closed_issue_changes_state_and_clears_closed_at(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.CLOSED,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
closed_at=datetime.now(timezone.utc)
)
# Act
issue.reopen()
# Assert
assert issue.state == IssueState.OPEN
assert issue.closed_at is None
def test_reopen_open_issue_raises_error(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act & Assert
with pytest.raises(IssueStateError) as exc_info:
issue.reopen()
assert "Issue is not closed" in str(exc_info.value)
def test_add_label_to_issue(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
new_label = Label("priority:high")
# Act
issue.add_label(new_label)
# Assert
assert len(issue.labels) == 2
assert new_label in issue.labels
def test_add_duplicate_label_does_not_duplicate(self):
# Arrange
label = Label("bug")
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[label],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
issue.add_label(label)
# Assert
assert len(issue.labels) == 1
def test_remove_label_from_issue(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug"), Label("priority:high")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
issue.remove_label("bug")
# Assert
assert len(issue.labels) == 1
assert not any(label.name == "bug" for label in issue.labels)
def test_has_label_returns_correct_value(self):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug"), Label("priority:high")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act & Assert
assert issue.has_label("bug") is True
assert issue.has_label("priority:high") is True
assert issue.has_label("enhancement") is False
class TestLabelCategories:
"""Test LabelCategories value object."""
def test_label_categories_creation(self):
# Arrange & Act
categories = LabelCategories(
state_labels=["status:open"],
priority_labels=["priority:high"],
type_labels=["bug"],
other_labels=["custom"]
)
# Assert
assert categories.state_labels == ["status:open"]
assert categories.priority_labels == ["priority:high"]
assert categories.type_labels == ["bug"]
assert categories.other_labels == ["custom"]

View File

@@ -1,368 +0,0 @@
"""
Unit tests for Issue domain services.
Tests business logic in issue services with no external dependencies.
"""
import pytest
from datetime import datetime, timedelta, timezone
from domain.issues.models import Issue, Label, IssueState
from domain.issues.services import IssueStatusService, IssueValidationService
from domain.issues.exceptions import IssueValidationError
class TestIssueStatusService:
"""Test business logic in issue status service."""
@pytest.fixture
def service(self):
return IssueStatusService()
def test_determine_kanban_column_for_closed_issue(self, service):
# Arrange
issue = Issue(
number=1,
title="Closed Issue",
state=IssueState.CLOSED,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Done"]}
# Act
column = service.determine_kanban_column(issue, project_info)
# Assert
assert column == "Done"
@pytest.mark.parametrize("status_label,expected_column", [
("status:in-progress", "In Progress"),
("status:review", "Review"),
("status:blocked", "Blocked"),
("status:ready", "Ready"),
])
def test_determine_kanban_column_based_on_status_labels(self, service, status_label, expected_column):
# Arrange
issue = Issue(
number=1,
title="Test Issue",
state=IssueState.OPEN,
labels=[Label(status_label)],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
project_info = {"kanban_columns": ["Todo", "In Progress", "Review", "Blocked", "Ready", "Done"]}
# Act
column = service.determine_kanban_column(issue, project_info)
# Assert
assert column == expected_column
def test_determine_kanban_column_defaults_to_todo(self, service):
# Arrange
issue = Issue(
number=1,
title="New Issue",
state=IssueState.OPEN,
labels=[Label("bug")], # No status label
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
project_info = {"kanban_columns": ["Todo", "In Progress", "Done"]}
# Act
column = service.determine_kanban_column(issue, project_info)
# Assert
assert column == "Todo"
@pytest.mark.parametrize("priority_label,expected_level", [
("priority:low", "Low"),
("priority:medium", "Medium"),
("priority:high", "High"),
("priority:critical", "Critical"),
])
def test_extract_priority_info_with_priority_labels(self, service, priority_label, expected_level):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label(priority_label)],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
priority_info = service.extract_priority_info(issue)
# Assert
assert priority_info["level"] == expected_level
assert priority_info["label"] == priority_label
def test_extract_priority_info_defaults_to_medium(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug")], # No priority label
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
priority_info = service.extract_priority_info(issue)
# Assert
assert priority_info["level"] == "Medium"
assert priority_info["label"] is None
def test_extract_state_info_for_open_issue(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("status:in-progress")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Act
state_info = service.extract_state_info(issue)
# Assert
assert state_info["state"] == "open"
assert state_info["state_labels"] == ["status:in-progress"]
assert state_info["is_closed"] is False
assert state_info["closed_at"] is None
def test_extract_state_info_for_closed_issue(self, service):
# Arrange
closed_at = datetime.now(timezone.utc)
issue = Issue(
number=1,
title="Test",
state=IssueState.CLOSED,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
closed_at=closed_at
)
# Act
state_info = service.extract_state_info(issue)
# Assert
assert state_info["state"] == "closed"
assert state_info["is_closed"] is True
assert state_info["closed_at"] == closed_at.isoformat()
def test_calculate_issue_age_days(self, service):
# Arrange
created_at = datetime.now(timezone.utc) - timedelta(days=5)
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=created_at,
updated_at=datetime.now(timezone.utc)
)
# Act
age_days = service.calculate_issue_age_days(issue)
# Assert
assert age_days == 5
def test_is_stale_issue_with_old_open_issue(self, service):
# Arrange
created_at = datetime.now(timezone.utc) - timedelta(days=45)
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=created_at,
updated_at=datetime.now(timezone.utc)
)
# Act
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
# Assert
assert is_stale is True
def test_is_stale_issue_with_recent_open_issue(self, service):
# Arrange
created_at = datetime.now(timezone.utc) - timedelta(days=15)
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[],
created_at=created_at,
updated_at=datetime.now(timezone.utc)
)
# Act
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
# Assert
assert is_stale is False
def test_is_stale_issue_with_closed_issue_never_stale(self, service):
# Arrange
created_at = datetime.now(timezone.utc) - timedelta(days=100)
issue = Issue(
number=1,
title="Test",
state=IssueState.CLOSED,
labels=[],
created_at=created_at,
updated_at=datetime.now(timezone.utc),
closed_at=datetime.now(timezone.utc)
)
# Act
is_stale = service.is_stale_issue(issue, stale_threshold_days=30)
# Assert
assert is_stale is False
class TestIssueValidationService:
"""Test business logic in issue validation service."""
@pytest.fixture
def service(self):
return IssueValidationService()
def test_validate_issue_creation_with_valid_data(self, service):
# Arrange
title = "Valid Issue Title"
labels = ["bug", "priority:high"]
# Act & Assert - Should not raise exception
service.validate_issue_creation(title, labels)
def test_validate_issue_creation_with_empty_title_raises_error(self, service):
# Arrange
title = ""
labels = ["bug"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue title cannot be empty" in str(exc_info.value)
assert exc_info.value.field == "title"
def test_validate_issue_creation_with_whitespace_only_title_raises_error(self, service):
# Arrange
title = " "
labels = ["bug"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue title cannot be empty" in str(exc_info.value)
def test_validate_issue_creation_with_too_long_title_raises_error(self, service):
# Arrange
title = "x" * 256 # Too long
labels = ["bug"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue title cannot exceed 255 characters" in str(exc_info.value)
def test_validate_issue_creation_with_multiple_priority_labels_raises_error(self, service):
# Arrange
title = "Valid Title"
labels = ["bug", "priority:high", "priority:low"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue cannot have multiple priority labels" in str(exc_info.value)
assert exc_info.value.field == "labels"
def test_validate_issue_creation_with_multiple_state_labels_raises_error(self, service):
# Arrange
title = "Valid Title"
labels = ["bug", "status:open", "status:in-progress"]
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_issue_creation(title, labels)
assert "Issue cannot have multiple state labels" in str(exc_info.value)
def test_validate_title_update_with_valid_title(self, service):
# Arrange
new_title = "Updated Title"
# Act & Assert - Should not raise exception
service.validate_title_update(new_title)
def test_validate_label_addition_to_issue_without_conflicts(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
new_label = "enhancement"
# Act & Assert - Should not raise exception
service.validate_label_addition(issue, new_label)
def test_validate_label_addition_with_duplicate_label_raises_error(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("bug")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
new_label = "bug"
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_label_addition(issue, new_label)
assert "Issue already has label 'bug'" in str(exc_info.value)
def test_validate_label_addition_with_conflicting_priority_raises_error(self, service):
# Arrange
issue = Issue(
number=1,
title="Test",
state=IssueState.OPEN,
labels=[Label("priority:high")],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
new_label = "priority:low"
# Act & Assert
with pytest.raises(IssueValidationError) as exc_info:
service.validate_label_addition(issue, new_label)
assert "Issue already has priority label" in str(exc_info.value)
assert "Cannot add 'priority:low'" in str(exc_info.value)

View File

@@ -1,417 +0,0 @@
"""
Tests for IssueCreator functionality.
"""
import json
import subprocess
import pytest
from unittest.mock import patch, MagicMock, mock_open
from pathlib import Path
from tddai.issue_creator import IssueCreator
from tddai.exceptions import IssueError
from tddai.config import TddaiConfig
class TestIssueCreator:
"""Test suite for IssueCreator class."""
def _get_test_config(self):
"""Get a valid test configuration."""
return TddaiConfig(
workspace_dir=Path(".test_workspace"),
gitea_url="http://localhost:3000",
repo_owner="test_owner",
repo_name="test_repo"
)
def _get_complete_mock_response(self, number: int, title: str = "Test Issue", body: str = "Test description"):
"""Get a complete mock API response with all required fields."""
return {
"number": number,
"title": title,
"body": body,
"state": "open",
"created_at": "2025-09-26T10:00:00Z",
"updated_at": "2025-09-26T10:00:00Z",
"html_url": f"http://gitea.example.com/repo/issues/{number}"
}
def test_issue_creator_initializes_with_authentication_token(self):
"""Test IssueCreator can be initialized with authentication token."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
assert creator.config == config
assert creator.auth_token == "test-token"
def test_issue_creator_reads_authentication_from_environment_variable(self):
"""Test IssueCreator reads authentication token from environment variable."""
config = self._get_test_config()
with patch.dict('os.environ', {'GITEA_API_TOKEN': 'env-token'}):
creator = IssueCreator(config=config)
assert creator.auth_token == 'env-token'
def test_issue_creator_handles_missing_authentication_token_gracefully(self):
"""Test IssueCreator handles missing authentication token gracefully."""
config = self._get_test_config()
# Ensure no environment token interferes
with patch.dict('os.environ', {}, clear=True):
creator = IssueCreator(config=config)
assert creator.auth_token is None
@patch('subprocess.run')
def test_create_issue_success(self, mock_run):
"""Test successful issue creation."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock successful API response
mock_response = {
"number": 123,
"title": "Test Issue",
"body": "Test description",
"state": "open",
"created_at": "2025-09-26T10:00:00Z",
"updated_at": "2025-09-26T10:00:00Z",
"html_url": "http://gitea.example.com/repo/issues/123"
}
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps(mock_response),
stderr=""
)
result = creator.create_issue("Test Issue", "Test description")
# Verify the result has the expected structure (transformed by issue creator)
expected_result = {
'number': 123,
'title': "Test Issue",
'body': "Test description",
'state': "open",
'html_url': "http://gitea.example.com/repo/issues/123",
'created_at': "2025-09-26T10:00:00", # ISO format from datetime parsing
'updated_at': "2025-09-26T10:00:00",
'assignee': None,
'labels': []
}
assert result == expected_result
mock_run.assert_called_once()
# Check curl command structure
call_args = mock_run.call_args[0][0]
assert 'curl' in call_args
assert '-X' in call_args
assert 'POST' in call_args
assert 'Authorization: token test-token' in ' '.join(call_args)
def test_create_issue_without_auth_token(self):
"""Test issue creation without authentication token."""
config = self._get_test_config()
# Ensure no environment token interferes
with patch.dict('os.environ', {}, clear=True):
creator = IssueCreator(config=config)
with pytest.raises(IssueError, match="Authentication token required"):
creator.create_issue("Test Issue", "Test description")
def test_create_issue_empty_title(self):
"""Test issue creation with empty title."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
with pytest.raises(IssueError, match="Issue title cannot be empty"):
creator.create_issue("", "Test description")
with pytest.raises(IssueError, match="Issue title cannot be empty"):
creator.create_issue(" ", "Test description")
@patch('subprocess.run')
def test_create_issue_api_error(self, mock_run):
"""Test issue creation with API error response."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock API error response
mock_response = {"message": "Repository not found"}
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps(mock_response),
stderr=""
)
with pytest.raises(IssueError, match="Failed to create issue: Repository not found"):
creator.create_issue("Test Issue", "Test description")
@patch('subprocess.run')
def test_create_issue_subprocess_error(self, mock_run):
"""Test issue creation with subprocess error."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
mock_run.side_effect = subprocess.CalledProcessError(1, 'curl')
with pytest.raises(IssueError, match="Failed to create issue"):
creator.create_issue("Test Issue", "Test description")
@patch('subprocess.run')
def test_create_issue_json_error(self, mock_run):
"""Test issue creation with invalid JSON response."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
mock_run.return_value = MagicMock(
returncode=0,
stdout="invalid json",
stderr=""
)
with pytest.raises(IssueError, match="Failed to create issue.*parse.*response"):
creator.create_issue("Test Issue", "Test description")
@patch('gitea.http_client.subprocess.run')
def test_create_issue_with_optional_fields(self, mock_run):
"""Test issue creation with optional fields."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock labels API response for label resolution
labels_response = [
{"id": 1, "name": "bug", "color": "red"},
{"id": 2, "name": "high", "color": "orange"}
]
# Mock issue creation response
issue_response = self._get_complete_mock_response(124)
# Configure mock to return different responses based on URL
def side_effect(*args, **kwargs):
cmd = args[0]
url = cmd[-1] # URL is the last argument
result_mock = MagicMock()
result_mock.returncode = 0
result_mock.stderr = ""
if 'labels' in url:
result_mock.stdout = json.dumps(labels_response)
else:
result_mock.stdout = json.dumps(issue_response)
return result_mock
mock_run.side_effect = side_effect
result = creator.create_issue(
"Test Issue",
"Test description",
assignees=["user1"],
milestone=1,
labels=["bug", "high"]
)
# Verify issue was created successfully
assert result['number'] == 124
assert result['title'] == "Test Issue"
# Verify the API was called correctly
# Find the issue creation call (not the labels call)
create_call = None
for call in mock_run.call_args_list:
cmd = call[0][0]
url = cmd[-1]
if 'issues' in url and '/labels' not in url:
create_call = call
break
assert create_call is not None
cmd = create_call[0][0]
# Find the -d argument (data payload)
data_index = cmd.index('-d') + 1
payload = json.loads(cmd[data_index])
assert payload['assignees'] == ["user1"]
assert payload['milestone'] == 1
assert payload['labels'] == [1, 2] # Should be IDs now
@patch('gitea.http_client.subprocess.run')
def test_create_enhancement_issue(self, mock_run):
"""Test creating enhancement issue with structured format."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock labels API response for label resolution
labels_response = [
{"id": 1, "name": "enhancement", "color": "blue"},
{"id": 2, "name": "high", "color": "orange"}
]
# Mock issue creation response
issue_response = self._get_complete_mock_response(125)
# Configure mock to return different responses based on URL
def side_effect(*args, **kwargs):
cmd = args[0]
url = cmd[-1] # URL is the last argument
result_mock = MagicMock()
result_mock.returncode = 0
result_mock.stderr = ""
if 'labels' in url:
result_mock.stdout = json.dumps(labels_response)
else:
result_mock.stdout = json.dumps(issue_response)
return result_mock
mock_run.side_effect = side_effect
result = creator.create_enhancement_issue(
title="Add CLI Support",
use_case="User needs command-line interface",
technical_requirements="Implement Click framework",
acceptance_criteria=["CLI entry point works", "Commands have help text"],
dependencies=["Issue #1 - Database"],
priority="High"
)
# Verify structure of created issue
create_call = None
for call in mock_run.call_args_list:
cmd = call[0][0]
url = cmd[-1]
if 'issues' in url and '/labels' not in url:
create_call = call
break
assert create_call is not None
cmd = create_call[0][0]
# Find the -d argument (data payload)
data_index = cmd.index('-d') + 1
payload = json.loads(cmd[data_index])
assert "UseCase: User needs command-line interface" in payload['body']
assert "Technical Requirements:" in payload['body']
assert "- [ ] CLI entry point works" in payload['body']
assert "- [ ] Commands have help text" in payload['body']
assert "Dependencies:" in payload['body']
assert "- Issue #1 - Database" in payload['body']
assert payload['labels'] == [2, 1] # Should be IDs: [high, enhancement]
@patch('gitea.http_client.subprocess.run')
def test_create_bug_issue(self, mock_run):
"""Test creating bug issue with structured format."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
# Mock labels API response for label resolution
labels_response = [
{"id": 1, "name": "bug", "color": "red"}
]
# Mock issue creation response
issue_response = self._get_complete_mock_response(126)
# Configure mock to return different responses based on URL
def side_effect(*args, **kwargs):
cmd = args[0]
url = cmd[-1] # URL is the last argument
result_mock = MagicMock()
result_mock.returncode = 0
result_mock.stderr = ""
if 'labels' in url:
result_mock.stdout = json.dumps(labels_response)
else:
result_mock.stdout = json.dumps(issue_response)
return result_mock
mock_run.side_effect = side_effect
result = creator.create_bug_issue(
title="CLI crashes on empty input",
description="The CLI tool crashes when given empty input",
steps_to_reproduce=["Run CLI command", "Provide empty input", "Observe crash"],
expected_behavior="Should show help message",
actual_behavior="Application crashes",
environment="Python 3.8, Linux"
)
# Verify structure of created bug issue
create_call = None
for call in mock_run.call_args_list:
cmd = call[0][0]
url = cmd[-1]
if 'issues' in url and '/labels' not in url:
create_call = call
break
assert create_call is not None
cmd = create_call[0][0]
# Find the -d argument (data payload)
data_index = cmd.index('-d') + 1
payload = json.loads(cmd[data_index])
assert "The CLI tool crashes when given empty input" in payload['body']
assert "Steps to Reproduce:" in payload['body']
assert "1. Run CLI command" in payload['body']
assert "2. Provide empty input" in payload['body']
assert "Expected Behavior: Should show help message" in payload['body']
assert "Actual Behavior: Application crashes" in payload['body']
assert "Environment: Python 3.8, Linux" in payload['body']
assert payload['labels'] == [1] # Should be ID: [bug]
@patch('subprocess.run')
@patch('builtins.open', new_callable=mock_open, read_data="Title: Template Issue\nTemplate body content with {variable}")
def test_create_from_template(self, mock_file, mock_run):
"""Test creating issue from template file."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
mock_response = self._get_complete_mock_response(127)
mock_run.return_value = MagicMock(
returncode=0,
stdout=json.dumps(mock_response),
stderr=""
)
result = creator.create_from_template(
"template.md",
variable="test value"
)
# Verify template processing
call_args = mock_run.call_args[0][0]
json_data_index = call_args.index('-d') + 1
json_data = json.loads(call_args[json_data_index])
assert json_data['title'] == "Template Issue"
assert "Template body content with test value" in json_data['body']
def test_create_from_template_file_not_found(self):
"""Test creating issue from non-existent template."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
with pytest.raises(IssueError, match="Template file not found"):
creator.create_from_template("nonexistent.md")
@patch('builtins.open', new_callable=mock_open, read_data="")
def test_create_from_empty_template(self, mock_file):
"""Test creating issue from empty template."""
config = self._get_test_config()
creator = IssueCreator(config=config, auth_token="test-token")
with pytest.raises(IssueError, match="Template file is empty"):
creator.create_from_template("empty.md")

View File

@@ -1,223 +0,0 @@
"""
Mock Compatibility Validation Tests
Validates that test mocks match actual domain models and prevent
the interface compatibility issues encountered in Issue #59.
"""
import pytest
from unittest.mock import Mock
from datetime import datetime, timezone
from typing import List
# Import domain models for validation
from domain.issues.models import Issue, IssueState, Label
class TestMockCompatibility:
"""Validate that test mocks match actual domain models."""
def test_issue_mock_has_all_required_attributes(self):
"""Test that Issue mocks include all required attributes."""
# Create a real Issue to get expected attributes
real_issue = Issue(
number=1,
title="Real Issue",
state=IssueState.OPEN,
labels=[],
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
# Create mock with spec
mock_issue = Mock(spec=Issue)
mock_issue.number = 1
mock_issue.title = "Mock Issue"
mock_issue.state = IssueState.OPEN
mock_issue.labels = []
mock_issue.created_at = datetime.now(timezone.utc)
mock_issue.updated_at = datetime.now(timezone.utc)
mock_issue.milestone = None
mock_issue.assignee = None
# Verify critical attributes match
real_attrs = {attr for attr in dir(real_issue) if not attr.startswith('_')}
mock_attrs = {attr for attr in dir(mock_issue) if not attr.startswith('_')}
missing_attrs = real_attrs - mock_attrs
# Filter out methods - we only care about data attributes
critical_missing = [attr for attr in missing_attrs
if not callable(getattr(real_issue, attr, None))]
assert not critical_missing, f"Mock missing critical attributes: {critical_missing}"
def test_issue_mock_uses_correct_types(self):
"""Test that Issue mocks use correct types."""
mock_issue = Mock(spec=Issue)
mock_issue.state = IssueState.OPEN # Should be enum, not string
assert isinstance(mock_issue.state, IssueState), "State should be IssueState enum"
def test_issue_mock_correct_pattern_from_issue_59_fix(self):
"""Test the correct mock pattern that fixes Issue #59 problems."""
# ✅ CORRECT pattern - what we learned from Issue #59
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01 00:00"
mock_issue = Mock(spec=Issue) # Use spec parameter!
mock_issue.number = 59
mock_issue.title = "Test Issue"
mock_issue._body = "Test issue body" # CLI expects _body attribute
mock_issue.state = Mock()
mock_issue.state.value = "open" # CLI converts state.value
mock_issue.created_at = mock_datetime # Must support strftime()
mock_issue.updated_at = mock_datetime
mock_issue.labels = []
mock_issue.assignee = None
mock_issue.milestone = None
# Attributes that the view layer expects (learned from Issue #59)
mock_issue.state_label = "OPEN"
mock_issue.priority_label = "Normal"
mock_issue.type_labels = []
mock_issue.other_labels = []
mock_issue.html_url = ""
mock_issue.kanban_column = "To Do"
# Verify it behaves like Issue #59 tests expect
assert mock_issue.number == 59
assert mock_issue.state.value == "open"
assert mock_issue.created_at.strftime('%Y-%m-%d') == "2023-01-01 00:00"
assert isinstance(mock_issue.labels, list)
def test_label_mock_has_correct_attributes(self):
"""Test that Label mocks match domain model."""
real_label = Label(name="bug", color="#ff0000", description="Bug label")
mock_label = Mock(spec=Label)
mock_label.name = "bug"
mock_label.color = "#ff0000"
mock_label.description = "Bug label"
# Verify key attributes exist
assert hasattr(mock_label, 'name')
assert hasattr(mock_label, 'color')
assert hasattr(mock_label, 'description')
def test_enum_vs_string_validation(self):
"""Validate that enums are used instead of strings (Issue #59 lesson)."""
# ❌ WRONG - what caused Issue #59 problems
wrong_mock = Mock()
wrong_mock.state = "open" # String instead of enum!
# ✅ CORRECT - proper enum usage
correct_mock = Mock(spec=Issue)
correct_mock.state = IssueState.OPEN
# Verify correct usage
assert isinstance(correct_mock.state, IssueState)
assert not isinstance(wrong_mock.state, IssueState)
# This test serves as documentation of the correct pattern
class TestIssue59Prevention:
"""Specific tests to prevent Issue #59 problems from recurring."""
def test_plugin_manager_mock_discovery_pattern(self):
"""Test the correct plugin manager mocking pattern."""
from markitect.issues.manager import IssuePluginManager
# ✅ CORRECT: Mock at _discover_plugins level (learned from Issue #59)
from unittest.mock import patch
with patch.object(IssuePluginManager, '_discover_plugins') as mock_discover:
mock_plugin_class = Mock()
mock_discover.return_value = {'gitea': mock_plugin_class}
manager = IssuePluginManager()
# This pattern works because it mocks at the right level
assert 'gitea' in manager.plugins
mock_plugin_class.assert_not_called() # Only called when get_backend() is used
def test_cli_mock_realistic_attributes(self):
"""Test CLI layer mocks have all required attributes for views."""
# These are the attributes the CLI view layer expects (learned from Issue #59)
required_view_attributes = [
'number', 'title', 'state', 'created_at', 'updated_at',
'labels', 'assignee', 'milestone', '_body'
]
# Additional attributes the view expects (discovered during Issue #59 fixing)
view_layer_attributes = [
'state_label', 'priority_label', 'type_labels',
'other_labels', 'html_url', 'kanban_column'
]
mock_issue = Mock(spec=Issue)
# Set all required attributes
for attr in required_view_attributes:
setattr(mock_issue, attr, None) # Set to None or appropriate default
for attr in view_layer_attributes:
setattr(mock_issue, attr, None)
# Verify all critical attributes exist
for attr in required_view_attributes + view_layer_attributes:
assert hasattr(mock_issue, attr), f"Mock missing required attribute: {attr}"
def test_datetime_mock_strftime_support(self):
"""Test datetime mocks support strftime (Issue #59 requirement)."""
# The view layer calls created_at.strftime() - mocks must support this
mock_datetime = Mock()
mock_datetime.strftime.return_value = "2023-01-01"
mock_issue = Mock(spec=Issue)
mock_issue.created_at = mock_datetime
mock_issue.updated_at = mock_datetime
# Verify strftime works
assert mock_issue.created_at.strftime('%Y-%m-%d') == "2023-01-01"
assert mock_issue.updated_at.strftime('%Y-%m-%d') == "2023-01-01"
class TestMockGuidelines:
"""Tests that demonstrate correct mock patterns for future development."""
def test_correct_mock_creation_pattern(self):
"""Demonstrate the correct way to create mocks."""
# ✅ ALWAYS use spec= parameter
mock_issue = Mock(spec=Issue)
# ✅ Use actual enums, not strings
mock_issue.state = IssueState.OPEN
# ✅ Include all required attributes based on domain model analysis
mock_issue.number = 1
mock_issue.title = "Test"
mock_issue.labels = []
mock_issue.created_at = datetime.now(timezone.utc)
mock_issue.updated_at = datetime.now(timezone.utc)
# Verify this is the correct pattern
assert hasattr(mock_issue, '_spec_class') # Mock(spec=X) creates this
assert isinstance(mock_issue.state, IssueState)
def test_integration_test_mocking_strategy(self):
"""Demonstrate proper mocking for integration tests."""
# For integration tests, create mocks that match the actual interface contracts
from markitect.issues.base import IssueBackend
mock_backend = Mock(spec=IssueBackend)
mock_backend.list_issues.return_value = []
mock_backend.get_issue.return_value = Mock(spec=Issue)
# Verify the mock supports the interface
assert hasattr(mock_backend, 'list_issues')
assert hasattr(mock_backend, 'get_issue')
assert callable(mock_backend.list_issues)
if __name__ == '__main__':
pytest.main([__file__, '-v'])