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
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:
@@ -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
260
Makefile
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
79
cli/core.py
79
cli/core.py
@@ -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)
|
||||
|
||||
180
cli/issue_cli.py
180
cli/issue_cli.py
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -1,7 +0,0 @@
|
||||
"""
|
||||
Issue management module for MarkiTect.
|
||||
|
||||
Provides unified CLI interface for issue management with pluggable backend support.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
"""
|
||||
Issue management plugins.
|
||||
|
||||
This package contains backend implementations for different issue management systems.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
171
tddai/config.py
171
tddai/config.py
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
342
tddai_cli.py
342
tddai_cli.py
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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__])
|
||||
@@ -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__])
|
||||
@@ -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'])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__])
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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'])
|
||||
Reference in New Issue
Block a user