Compare commits
2 Commits
4ceb6cce42
...
a8e5b4b044
| Author | SHA1 | Date | |
|---|---|---|---|
| a8e5b4b044 | |||
| cb94c92fc0 |
@@ -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
|
||||
)
|
||||
340
issue-facade/README.md
Normal file
340
issue-facade/README.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Issue Facade - Universal CLI for Issue Tracking
|
||||
|
||||
A convenient command-line facade that provides a unified interface to the repository's main issue tracker, regardless of which backend system is actually being used.
|
||||
|
||||
## Purpose
|
||||
|
||||
The **Issue Facade** acts as a convenient CLI wrapper that automatically detects and interfaces with whatever issue tracking system is configured for the current repository. This means you get a consistent, intuitive command-line experience whether your project uses:
|
||||
|
||||
- GitHub Issues
|
||||
- GitLab Issues
|
||||
- Gitea Issues
|
||||
- JIRA
|
||||
- Local SQLite storage
|
||||
- Any other supported backend
|
||||
|
||||
## Philosophy
|
||||
|
||||
Rather than learning different commands and workflows for each issue tracking system, the Issue Facade provides:
|
||||
|
||||
- **One CLI to rule them all**: Same commands work across all backends
|
||||
- **Repository-aware**: Automatically detects the relevant issue tracker for your repo
|
||||
- **Offline capability**: Local SQLite fallback when remote systems are unavailable
|
||||
- **Seamless sync**: Keep local and remote issue trackers synchronized
|
||||
|
||||
## How It Works
|
||||
|
||||
```bash
|
||||
# The facade automatically detects your repository's issue tracker
|
||||
cd /path/to/my-project
|
||||
issue list # Lists issues from the repo's configured tracker
|
||||
|
||||
cd /path/to/another-project
|
||||
issue list # Lists issues from THIS repo's tracker
|
||||
|
||||
# Same commands, different backends - transparent to the user
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🎯 **Repository Context Awareness**
|
||||
The facade automatically detects:
|
||||
- Git remotes (GitHub, GitLab, Gitea URLs)
|
||||
- Configuration files (`.issue-config`, `pyproject.toml`, etc.)
|
||||
- Environment variables
|
||||
- Default fallbacks
|
||||
|
||||
### 🖥️ **Unified CLI Experience**
|
||||
```bash
|
||||
# Core issue operations (same across all backends)
|
||||
issue list # List issues
|
||||
issue show 42 # Show issue details
|
||||
issue create "Bug in parser" # Create new issue
|
||||
issue edit 42 --add-label bug # Edit existing issue
|
||||
issue close 42 --comment "Fixed" # Close with comment
|
||||
issue comment 42 "Still broken" # Add comment
|
||||
|
||||
# Advanced operations
|
||||
issue list --assignee=me --state=open
|
||||
issue search "memory leak"
|
||||
issue stats # Show issue statistics
|
||||
```
|
||||
|
||||
### 🔄 **Automatic Backend Detection**
|
||||
```bash
|
||||
# GitHub repository
|
||||
cd my-github-project
|
||||
issue list # → Automatically uses GitHub Issues API
|
||||
|
||||
# Gitea repository
|
||||
cd my-gitea-project
|
||||
issue list # → Automatically uses Gitea Issues API
|
||||
|
||||
# Offline/local work
|
||||
cd any-project
|
||||
issue backend set local
|
||||
issue list # → Uses local SQLite storage
|
||||
```
|
||||
|
||||
### 🌐 **Seamless Synchronization**
|
||||
```bash
|
||||
# Work offline, sync later
|
||||
issue create "Bug found offline" --local
|
||||
issue sync # Pushes to remote when online
|
||||
|
||||
# Keep backups
|
||||
issue sync pull # Download all remote issues locally
|
||||
issue export backup.json # Export for archival
|
||||
```
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### 1. Install the Facade
|
||||
```bash
|
||||
pip install issue-facade
|
||||
```
|
||||
|
||||
### 2. Automatic Configuration
|
||||
The facade auto-detects your repository's issue tracker:
|
||||
|
||||
```bash
|
||||
cd your-repository
|
||||
issue config detect # Auto-configure based on git remotes
|
||||
issue list # Ready to use!
|
||||
```
|
||||
|
||||
### 3. Manual Configuration (if needed)
|
||||
```bash
|
||||
# Configure specific backends
|
||||
issue backend add github my-repo
|
||||
issue backend add gitea company-repo
|
||||
issue backend add local offline
|
||||
|
||||
# Set repository-specific backend
|
||||
issue config set-backend github # For current repository
|
||||
```
|
||||
|
||||
## Repository Integration Examples
|
||||
|
||||
### GitHub Repository
|
||||
```bash
|
||||
cd my-github-project
|
||||
issue config detect
|
||||
# → Automatically configures GitHub Issues via API
|
||||
# → Uses .github/ISSUE_TEMPLATE/ for templates
|
||||
# → Respects repository labels and milestones
|
||||
|
||||
issue create "Security vulnerability" --template security
|
||||
```
|
||||
|
||||
### Corporate Gitea
|
||||
```bash
|
||||
cd company-project
|
||||
issue config detect
|
||||
# → Detects Gitea instance from git remote
|
||||
# → Prompts for access token (one-time setup)
|
||||
# → Uses corporate labels and workflows
|
||||
|
||||
issue list --milestone "Q4 Release"
|
||||
```
|
||||
|
||||
### Offline Development
|
||||
```bash
|
||||
cd any-project
|
||||
issue backend set local
|
||||
# → Creates local SQLite database in .issue-facade/
|
||||
# → Full offline functionality
|
||||
# → Sync to remote when connection available
|
||||
|
||||
issue create "Performance issue" --offline
|
||||
issue sync when-online
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Per-Repository Configuration
|
||||
Each repository can have its own configuration in `.issue-facade/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"backend": "github",
|
||||
"github": {
|
||||
"owner": "myorg",
|
||||
"repo": "myproject",
|
||||
"token_env": "GITHUB_TOKEN"
|
||||
},
|
||||
"local": {
|
||||
"db_path": ".issue-facade/issues.db",
|
||||
"sync_enabled": true
|
||||
},
|
||||
"templates": {
|
||||
"bug": ".github/ISSUE_TEMPLATE/bug_report.md",
|
||||
"feature": ".github/ISSUE_TEMPLATE/feature_request.md"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Global Configuration
|
||||
User-wide settings in `~/.config/issue-facade/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"default_backend": "local",
|
||||
"github_token": "env:GITHUB_TOKEN",
|
||||
"gitea_instances": {
|
||||
"company": {
|
||||
"url": "https://git.company.com",
|
||||
"token": "env:GITEA_TOKEN"
|
||||
}
|
||||
},
|
||||
"offline_mode": false,
|
||||
"sync_interval": "1h"
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Facade Pattern Implementation
|
||||
```
|
||||
Repository Working Directory
|
||||
├── .git/ # Git repository
|
||||
├── .issue-facade/ # Facade configuration
|
||||
│ ├── config.json # Repository-specific config
|
||||
│ ├── issues.db # Local SQLite cache/backup
|
||||
│ └── templates/ # Issue templates
|
||||
├── your-project-files...
|
||||
└── issue # CLI command (context-aware)
|
||||
```
|
||||
|
||||
### Backend Detection Logic
|
||||
1. **Check repository configuration**: `.issue-facade/config.json`
|
||||
2. **Analyze git remotes**: Detect GitHub/GitLab/Gitea URLs
|
||||
3. **Look for platform files**: `.github/`, `.gitlab/`, etc.
|
||||
4. **Check environment**: `GITHUB_TOKEN`, `GITLAB_TOKEN`, etc.
|
||||
5. **Fall back to local**: SQLite storage if no remote detected
|
||||
|
||||
### Multi-Repository Support
|
||||
```bash
|
||||
# Each repository maintains its own context
|
||||
/projects/web-app/ → GitHub Issues
|
||||
/projects/api-server/ → GitLab Issues
|
||||
/projects/cli-tool/ → Gitea Issues
|
||||
/projects/experiment/ → Local SQLite
|
||||
|
||||
# Same commands work in all contexts
|
||||
cd web-app && issue list # GitHub
|
||||
cd api-server && issue list # GitLab
|
||||
cd cli-tool && issue list # Gitea
|
||||
cd experiment && issue list # Local
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. **Multi-Platform Developer**
|
||||
You work with repositories across GitHub, GitLab, and company Gitea:
|
||||
```bash
|
||||
# Learn one CLI, use everywhere
|
||||
issue list --assignee=me # Works on all platforms
|
||||
issue create "Cross-platform bug" --label bug
|
||||
```
|
||||
|
||||
### 2. **Offline Developer**
|
||||
You need to track issues without constant internet:
|
||||
```bash
|
||||
issue create "Found while flying" --offline
|
||||
issue list --local # View offline issues
|
||||
issue sync # Upload when back online
|
||||
```
|
||||
|
||||
### 3. **Repository Migration**
|
||||
Moving from GitHub to GitLab:
|
||||
```bash
|
||||
issue export github-backup.json # Backup from GitHub
|
||||
issue backend set gitlab # Switch to GitLab
|
||||
issue import github-backup.json # Import to GitLab
|
||||
```
|
||||
|
||||
### 4. **Cross-Repository Analytics**
|
||||
Track issues across multiple repositories:
|
||||
```bash
|
||||
issue stats --all-repos # Statistics across all configured repos
|
||||
issue search "security" --global # Search across all issue trackers
|
||||
```
|
||||
|
||||
## Integration with Development Workflow
|
||||
|
||||
### Git Hooks Integration
|
||||
```bash
|
||||
# .git/hooks/pre-commit
|
||||
issue list --assignee=me --state=open > ISSUES.md
|
||||
git add ISSUES.md
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
```bash
|
||||
# In your CI pipeline
|
||||
issue create "Build failed on commit $SHA" --label ci-failure
|
||||
issue close-if-fixed $ISSUE_NUMBER
|
||||
```
|
||||
|
||||
### IDE Integration
|
||||
```bash
|
||||
# VS Code, Vim, Emacs plugins can use the CLI
|
||||
:IssueList # List issues in editor
|
||||
:IssueCreate "Typo in function" # Create issue from editor
|
||||
```
|
||||
|
||||
## Comparison with Native Tools
|
||||
|
||||
| Feature | issue-facade | gh (GitHub CLI) | glab (GitLab CLI) | Platform Web UI |
|
||||
|---------|--------------|-----------------|-------------------|-----------------|
|
||||
| **Multi-platform** | ✅ All backends | ❌ GitHub only | ❌ GitLab only | ❌ Single platform |
|
||||
| **Offline support** | ✅ Local SQLite | ❌ Online only | ❌ Online only | ❌ Online only |
|
||||
| **Consistent CLI** | ✅ Same commands | ❌ GitHub-specific | ❌ GitLab-specific | ❌ Web interface |
|
||||
| **Repository context** | ✅ Auto-detect | ✅ Git-aware | ✅ Git-aware | ❌ Manual navigation |
|
||||
| **Cross-repo search** | ✅ Global search | ❌ Single repo | ❌ Single repo | ❌ Single repo |
|
||||
| **Data portability** | ✅ Export/import | ❌ Platform-locked | ❌ Platform-locked | ❌ Platform-locked |
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
### Version 1.0 (Current)
|
||||
- [x] Core facade architecture
|
||||
- [x] GitHub/GitLab/Gitea backend support
|
||||
- [x] Local SQLite backend
|
||||
- [x] Automatic repository detection
|
||||
- [x] Basic synchronization
|
||||
|
||||
### Version 1.1
|
||||
- [ ] Advanced sync (conflict resolution)
|
||||
- [ ] Issue templates support
|
||||
- [ ] Workflow automation hooks
|
||||
- [ ] Plugin system for custom backends
|
||||
|
||||
### Version 2.0
|
||||
- [ ] Web dashboard for multi-repo overview
|
||||
- [ ] Advanced analytics and reporting
|
||||
- [ ] Team collaboration features
|
||||
- [ ] Integration with project management tools
|
||||
|
||||
## Contributing
|
||||
|
||||
The Issue Facade is designed to be:
|
||||
- **Backend-agnostic**: Easy to add new issue tracking systems
|
||||
- **Repository-aware**: Respects the conventions of each platform
|
||||
- **Developer-friendly**: Consistent CLI across all environments
|
||||
|
||||
To add a new backend:
|
||||
1. Implement the `IssueBackend` interface
|
||||
2. Add detection logic for the platform
|
||||
3. Register the backend in the factory
|
||||
4. Add CLI configuration support
|
||||
|
||||
## Why "Facade"?
|
||||
|
||||
The **Facade Pattern** perfectly describes this tool's purpose:
|
||||
|
||||
> *"Provide a unified interface to a set of interfaces in a subsystem. Facade defines a higher-level interface that makes the subsystem easier to use."*
|
||||
|
||||
Instead of learning different CLIs for GitHub (`gh`), GitLab (`glab`), JIRA, etc., the Issue Facade provides one consistent interface that works with all of them. It's the universal remote control for issue tracking systems.
|
||||
|
||||
The facade doesn't replace the underlying issue trackers - it makes them easier to use consistently across different platforms and repositories.
|
||||
24
issue-facade/__init__.py
Normal file
24
issue-facade/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Universal Issue Tracking System
|
||||
|
||||
A backend-agnostic issue tracking system that supports multiple backends
|
||||
through a plugin architecture. Designed to be extracted into a standalone
|
||||
repository for use across multiple projects.
|
||||
|
||||
Features:
|
||||
- Unified issue model across all backends
|
||||
- Plugin-based backend architecture
|
||||
- Local SQLite backend for offline work
|
||||
- Bidirectional synchronization
|
||||
- CLI-first interface
|
||||
- Support for GitHub-style and other issue tracking systems
|
||||
|
||||
Supported Backends:
|
||||
- Local SQLite (for offline/standalone use)
|
||||
- Gitea (GitHub-compatible API)
|
||||
- Future: GitHub, GitLab, JIRA, Redmine, etc.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "MarkiTect Project"
|
||||
__description__ = "Universal Issue Tracking System with Plugin Architecture"
|
||||
11
issue-facade/backends/__init__.py
Normal file
11
issue-facade/backends/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Issue Tracking Backend Plugins
|
||||
|
||||
This package contains implementations for various issue tracking backends.
|
||||
Each backend implements the IssueBackend interface to provide a consistent
|
||||
API regardless of the underlying issue tracking system.
|
||||
|
||||
Available Backends:
|
||||
- local: SQLite-based local backend for offline use
|
||||
- gitea: Gitea API backend for GitHub-compatible systems
|
||||
"""
|
||||
17
issue-facade/backends/gitea/__init__.py
Normal file
17
issue-facade/backends/gitea/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Gitea Backend
|
||||
|
||||
A backend implementation for Gitea issue tracking systems.
|
||||
This backend provides integration with Gitea API for remote issue management.
|
||||
|
||||
Features:
|
||||
- Full Gitea API integration
|
||||
- GitHub-compatible operations
|
||||
- Remote synchronization
|
||||
- Authentication support
|
||||
- Rate limiting compliance
|
||||
"""
|
||||
|
||||
from .backend import GiteaBackend
|
||||
|
||||
__all__ = ['GiteaBackend']
|
||||
591
issue-facade/backends/gitea/backend.py
Normal file
591
issue-facade/backends/gitea/backend.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""
|
||||
Gitea Backend Implementation
|
||||
|
||||
Provides integration with Gitea API for remote issue tracking.
|
||||
This backend adapts the Gitea API to our unified issue model.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from ...core.interfaces import RemoteBackend, BackendCapabilities, IssueFilter, SyncableBackend
|
||||
from ...core.models import Issue, Label, User, Milestone, Comment, IssueState, Priority, IssueType
|
||||
|
||||
|
||||
class GiteaAPIError(Exception):
|
||||
"""Gitea API specific errors."""
|
||||
pass
|
||||
|
||||
|
||||
class GiteaRateLimitError(GiteaAPIError):
|
||||
"""Rate limit exceeded."""
|
||||
pass
|
||||
|
||||
|
||||
class GiteaBackend(RemoteBackend, SyncableBackend):
|
||||
"""Gitea API backend for remote issue tracking."""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url: Optional[str] = None
|
||||
self.token: Optional[str] = None
|
||||
self.owner: Optional[str] = None
|
||||
self.repo: Optional[str] = None
|
||||
self.session = requests.Session()
|
||||
self._capabilities = BackendCapabilities(
|
||||
supports_milestones=True,
|
||||
supports_assignees=True,
|
||||
supports_comments=True,
|
||||
supports_labels=True,
|
||||
supports_search=True,
|
||||
supports_bulk_operations=False,
|
||||
supports_webhooks=True,
|
||||
supports_real_time=False,
|
||||
max_labels_per_issue=None,
|
||||
max_assignees_per_issue=10 # Gitea typical limit
|
||||
)
|
||||
|
||||
@property
|
||||
def backend_type(self) -> str:
|
||||
return "gitea"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> BackendCapabilities:
|
||||
return self._capabilities
|
||||
|
||||
def connect(self, config: Dict[str, Any]) -> None:
|
||||
"""Connect to Gitea API."""
|
||||
self.base_url = config['base_url'].rstrip('/')
|
||||
self.token = config['token']
|
||||
self.owner = config['owner']
|
||||
self.repo = config['repo']
|
||||
|
||||
# Setup session with authentication
|
||||
self.session.headers.update({
|
||||
'Authorization': f'token {self.token}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
})
|
||||
|
||||
# Test connection
|
||||
if not self.test_connection():
|
||||
raise GiteaAPIError("Failed to connect to Gitea API")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from Gitea API."""
|
||||
self.session.close()
|
||||
self.base_url = None
|
||||
self.token = None
|
||||
self.owner = None
|
||||
self.repo = None
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""Test Gitea API connection."""
|
||||
try:
|
||||
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}')
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _api_request(self, method: str, endpoint: str, data: Optional[Dict] = None, params: Optional[Dict] = None) -> requests.Response:
|
||||
"""Make API request with error handling and rate limiting."""
|
||||
url = urljoin(f"{self.base_url}/api/v1", endpoint)
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, json=data, params=params)
|
||||
|
||||
# Handle rate limiting
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get('Retry-After', 60))
|
||||
raise GiteaRateLimitError(f"Rate limit exceeded. Retry after {retry_after} seconds")
|
||||
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise GiteaAPIError(f"API request failed: {e}")
|
||||
|
||||
def _gitea_issue_to_unified(self, gitea_issue: Dict[str, Any]) -> Issue:
|
||||
"""Convert Gitea issue JSON to unified Issue model."""
|
||||
# Convert labels
|
||||
labels = []
|
||||
for label_data in gitea_issue.get('labels', []):
|
||||
labels.append(Label(
|
||||
name=label_data['name'],
|
||||
color=label_data['color'],
|
||||
description=label_data.get('description', ''),
|
||||
backend_id=str(label_data['id'])
|
||||
))
|
||||
|
||||
# Convert assignees
|
||||
assignees = []
|
||||
if gitea_issue.get('assignees'):
|
||||
for assignee_data in gitea_issue['assignees']:
|
||||
assignees.append(User(
|
||||
id=str(assignee_data['id']),
|
||||
username=assignee_data['login'],
|
||||
display_name=assignee_data.get('full_name', ''),
|
||||
email=assignee_data.get('email', ''),
|
||||
avatar_url=assignee_data.get('avatar_url', ''),
|
||||
backend_id=str(assignee_data['id'])
|
||||
))
|
||||
|
||||
# Convert milestone
|
||||
milestone = None
|
||||
if gitea_issue.get('milestone'):
|
||||
milestone_data = gitea_issue['milestone']
|
||||
milestone = Milestone(
|
||||
id=str(milestone_data['id']),
|
||||
title=milestone_data['title'],
|
||||
description=milestone_data.get('description', ''),
|
||||
state=milestone_data['state'],
|
||||
due_date=datetime.fromisoformat(milestone_data['due_on'].replace('Z', '+00:00')) if milestone_data.get('due_on') else None,
|
||||
created_at=datetime.fromisoformat(milestone_data['created_at'].replace('Z', '+00:00')) if milestone_data.get('created_at') else None,
|
||||
updated_at=datetime.fromisoformat(milestone_data['updated_at'].replace('Z', '+00:00')) if milestone_data.get('updated_at') else None,
|
||||
backend_id=str(milestone_data['id'])
|
||||
)
|
||||
|
||||
# Determine state
|
||||
if gitea_issue['state'] == 'closed':
|
||||
state = IssueState.CLOSED
|
||||
else:
|
||||
# Check for status labels to determine more specific state
|
||||
for label in labels:
|
||||
if label.name == 'status:in_progress' or label.name == 'status:in-progress':
|
||||
state = IssueState.IN_PROGRESS
|
||||
break
|
||||
elif label.name == 'status:blocked':
|
||||
state = IssueState.BLOCKED
|
||||
break
|
||||
else:
|
||||
state = IssueState.OPEN
|
||||
|
||||
return Issue(
|
||||
id=str(gitea_issue['id']),
|
||||
number=gitea_issue['number'],
|
||||
title=gitea_issue['title'],
|
||||
description=gitea_issue.get('body', ''),
|
||||
state=state,
|
||||
created_at=datetime.fromisoformat(gitea_issue['created_at'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(gitea_issue['updated_at'].replace('Z', '+00:00')),
|
||||
closed_at=datetime.fromisoformat(gitea_issue['closed_at'].replace('Z', '+00:00')) if gitea_issue.get('closed_at') else None,
|
||||
labels=labels,
|
||||
assignees=assignees,
|
||||
milestone=milestone,
|
||||
backend_id=str(gitea_issue['id']),
|
||||
backend_type='gitea'
|
||||
)
|
||||
|
||||
def _unified_issue_to_gitea(self, issue: Issue) -> Dict[str, Any]:
|
||||
"""Convert unified Issue to Gitea API format."""
|
||||
data = {
|
||||
'title': issue.title,
|
||||
'body': issue.description,
|
||||
'state': 'closed' if issue.state == IssueState.CLOSED else 'open'
|
||||
}
|
||||
|
||||
if issue.assignees:
|
||||
data['assignees'] = [assignee.username for assignee in issue.assignees]
|
||||
|
||||
if issue.milestone:
|
||||
data['milestone'] = int(issue.milestone.backend_id) if issue.milestone.backend_id else None
|
||||
|
||||
# Convert labels
|
||||
if issue.labels:
|
||||
data['labels'] = [label.name for label in issue.labels]
|
||||
|
||||
return data
|
||||
|
||||
# Issue CRUD Operations
|
||||
def create_issue(self, issue: Issue) -> Issue:
|
||||
"""Create issue in Gitea."""
|
||||
data = self._unified_issue_to_gitea(issue)
|
||||
|
||||
response = self._api_request('POST', f'/repos/{self.owner}/{self.repo}/issues', data=data)
|
||||
gitea_issue = response.json()
|
||||
|
||||
return self._gitea_issue_to_unified(gitea_issue)
|
||||
|
||||
def get_issue(self, issue_id: str) -> Optional[Issue]:
|
||||
"""Get issue from Gitea by ID."""
|
||||
try:
|
||||
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/issues/{issue_id}')
|
||||
gitea_issue = response.json()
|
||||
return self._gitea_issue_to_unified(gitea_issue)
|
||||
except GiteaAPIError:
|
||||
return None
|
||||
|
||||
def get_issue_by_number(self, number: int) -> Optional[Issue]:
|
||||
"""Get issue by number."""
|
||||
return self.get_issue(str(number))
|
||||
|
||||
def update_issue(self, issue: Issue) -> Issue:
|
||||
"""Update issue in Gitea."""
|
||||
data = self._unified_issue_to_gitea(issue)
|
||||
|
||||
response = self._api_request('PATCH', f'/repos/{self.owner}/{self.repo}/issues/{issue.backend_id}', data=data)
|
||||
gitea_issue = response.json()
|
||||
|
||||
return self._gitea_issue_to_unified(gitea_issue)
|
||||
|
||||
def delete_issue(self, issue_id: str) -> bool:
|
||||
"""Delete issue - not supported by Gitea API."""
|
||||
# Gitea doesn't support deleting issues via API
|
||||
# We could close it instead
|
||||
try:
|
||||
issue = self.get_issue(issue_id)
|
||||
if issue:
|
||||
issue.close()
|
||||
self.update_issue(issue)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def list_issues(self, filter_criteria: Optional[IssueFilter] = None) -> List[Issue]:
|
||||
"""List issues from Gitea."""
|
||||
params = {
|
||||
'state': 'all', # Get both open and closed
|
||||
'sort': 'updated',
|
||||
'order': 'desc'
|
||||
}
|
||||
|
||||
if filter_criteria:
|
||||
if filter_criteria.state:
|
||||
if filter_criteria.state == 'open':
|
||||
params['state'] = 'open'
|
||||
elif filter_criteria.state == 'closed':
|
||||
params['state'] = 'closed'
|
||||
|
||||
if filter_criteria.assignee:
|
||||
params['assignee'] = filter_criteria.assignee
|
||||
|
||||
if filter_criteria.milestone:
|
||||
params['milestone'] = filter_criteria.milestone
|
||||
|
||||
if filter_criteria.labels:
|
||||
params['labels'] = ','.join(filter_criteria.labels)
|
||||
|
||||
if filter_criteria.created_after:
|
||||
params['since'] = filter_criteria.created_after.isoformat()
|
||||
|
||||
if filter_criteria.limit:
|
||||
params['limit'] = filter_criteria.limit
|
||||
|
||||
if filter_criteria.offset:
|
||||
params['page'] = (filter_criteria.offset // (filter_criteria.limit or 30)) + 1
|
||||
|
||||
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/issues', params=params)
|
||||
gitea_issues = response.json()
|
||||
|
||||
issues = [self._gitea_issue_to_unified(gitea_issue) for gitea_issue in gitea_issues]
|
||||
|
||||
# Apply additional filtering that Gitea API doesn't support
|
||||
if filter_criteria:
|
||||
if filter_criteria.search:
|
||||
search_term = filter_criteria.search.lower()
|
||||
issues = [
|
||||
issue for issue in issues
|
||||
if search_term in issue.title.lower() or search_term in issue.description.lower()
|
||||
]
|
||||
|
||||
return issues
|
||||
|
||||
def search_issues(self, query: str, limit: Optional[int] = None) -> List[Issue]:
|
||||
"""Search issues - limited Gitea API support."""
|
||||
# Gitea has limited search API, fallback to list with search filter
|
||||
filter_criteria = IssueFilter(search=query, limit=limit)
|
||||
return self.list_issues(filter_criteria)
|
||||
|
||||
# Label Operations
|
||||
def create_label(self, label: Label) -> Label:
|
||||
"""Create label in Gitea."""
|
||||
data = {
|
||||
'name': label.name,
|
||||
'color': label.color or '#000000',
|
||||
'description': label.description or ''
|
||||
}
|
||||
|
||||
response = self._api_request('POST', f'/repos/{self.owner}/{self.repo}/labels', data=data)
|
||||
gitea_label = response.json()
|
||||
|
||||
return Label(
|
||||
name=gitea_label['name'],
|
||||
color=gitea_label['color'],
|
||||
description=gitea_label.get('description', ''),
|
||||
backend_id=str(gitea_label['id'])
|
||||
)
|
||||
|
||||
def get_labels(self) -> List[Label]:
|
||||
"""Get all labels from Gitea."""
|
||||
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/labels')
|
||||
gitea_labels = response.json()
|
||||
|
||||
return [Label(
|
||||
name=label['name'],
|
||||
color=label['color'],
|
||||
description=label.get('description', ''),
|
||||
backend_id=str(label['id'])
|
||||
) for label in gitea_labels]
|
||||
|
||||
def update_label(self, label: Label) -> Label:
|
||||
"""Update label in Gitea."""
|
||||
data = {
|
||||
'name': label.name,
|
||||
'color': label.color or '#000000',
|
||||
'description': label.description or ''
|
||||
}
|
||||
|
||||
response = self._api_request('PATCH', f'/repos/{self.owner}/{self.repo}/labels/{label.name}', data=data)
|
||||
gitea_label = response.json()
|
||||
|
||||
return Label(
|
||||
name=gitea_label['name'],
|
||||
color=gitea_label['color'],
|
||||
description=gitea_label.get('description', ''),
|
||||
backend_id=str(gitea_label['id'])
|
||||
)
|
||||
|
||||
def delete_label(self, label_name: str) -> bool:
|
||||
"""Delete label from Gitea."""
|
||||
try:
|
||||
self._api_request('DELETE', f'/repos/{self.owner}/{self.repo}/labels/{label_name}')
|
||||
return True
|
||||
except GiteaAPIError:
|
||||
return False
|
||||
|
||||
# User Operations
|
||||
def get_users(self) -> List[User]:
|
||||
"""Get repository collaborators."""
|
||||
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/collaborators')
|
||||
gitea_users = response.json()
|
||||
|
||||
return [User(
|
||||
id=str(user['id']),
|
||||
username=user['login'],
|
||||
display_name=user.get('full_name', ''),
|
||||
email=user.get('email', ''),
|
||||
avatar_url=user.get('avatar_url', ''),
|
||||
backend_id=str(user['id'])
|
||||
) for user in gitea_users]
|
||||
|
||||
def get_user(self, user_id: str) -> Optional[User]:
|
||||
"""Get specific user."""
|
||||
try:
|
||||
response = self._api_request('GET', f'/users/{user_id}')
|
||||
user = response.json()
|
||||
return User(
|
||||
id=str(user['id']),
|
||||
username=user['login'],
|
||||
display_name=user.get('full_name', ''),
|
||||
email=user.get('email', ''),
|
||||
avatar_url=user.get('avatar_url', ''),
|
||||
backend_id=str(user['id'])
|
||||
)
|
||||
except GiteaAPIError:
|
||||
return None
|
||||
|
||||
def search_users(self, query: str) -> List[User]:
|
||||
"""Search users in Gitea."""
|
||||
params = {'q': query, 'limit': 50}
|
||||
response = self._api_request('GET', '/users/search', params=params)
|
||||
search_result = response.json()
|
||||
|
||||
return [User(
|
||||
id=str(user['id']),
|
||||
username=user['login'],
|
||||
display_name=user.get('full_name', ''),
|
||||
email=user.get('email', ''),
|
||||
avatar_url=user.get('avatar_url', ''),
|
||||
backend_id=str(user['id'])
|
||||
) for user in search_result.get('data', [])]
|
||||
|
||||
# Milestone Operations
|
||||
def create_milestone(self, milestone: Milestone) -> Milestone:
|
||||
"""Create milestone in Gitea."""
|
||||
data = {
|
||||
'title': milestone.title,
|
||||
'description': milestone.description or '',
|
||||
'due_on': milestone.due_date.isoformat() if milestone.due_date else None
|
||||
}
|
||||
|
||||
response = self._api_request('POST', f'/repos/{self.owner}/{self.repo}/milestones', data=data)
|
||||
gitea_milestone = response.json()
|
||||
|
||||
return Milestone(
|
||||
id=str(gitea_milestone['id']),
|
||||
title=gitea_milestone['title'],
|
||||
description=gitea_milestone.get('description', ''),
|
||||
state=gitea_milestone['state'],
|
||||
due_date=datetime.fromisoformat(gitea_milestone['due_on'].replace('Z', '+00:00')) if gitea_milestone.get('due_on') else None,
|
||||
created_at=datetime.fromisoformat(gitea_milestone['created_at'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(gitea_milestone['updated_at'].replace('Z', '+00:00')),
|
||||
backend_id=str(gitea_milestone['id'])
|
||||
)
|
||||
|
||||
def get_milestones(self) -> List[Milestone]:
|
||||
"""Get all milestones."""
|
||||
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/milestones')
|
||||
gitea_milestones = response.json()
|
||||
|
||||
return [Milestone(
|
||||
id=str(m['id']),
|
||||
title=m['title'],
|
||||
description=m.get('description', ''),
|
||||
state=m['state'],
|
||||
due_date=datetime.fromisoformat(m['due_on'].replace('Z', '+00:00')) if m.get('due_on') else None,
|
||||
created_at=datetime.fromisoformat(m['created_at'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(m['updated_at'].replace('Z', '+00:00')),
|
||||
backend_id=str(m['id'])
|
||||
) for m in gitea_milestones]
|
||||
|
||||
def update_milestone(self, milestone: Milestone) -> Milestone:
|
||||
"""Update milestone."""
|
||||
data = {
|
||||
'title': milestone.title,
|
||||
'description': milestone.description or '',
|
||||
'state': milestone.state,
|
||||
'due_on': milestone.due_date.isoformat() if milestone.due_date else None
|
||||
}
|
||||
|
||||
response = self._api_request('PATCH', f'/repos/{self.owner}/{self.repo}/milestones/{milestone.backend_id}', data=data)
|
||||
gitea_milestone = response.json()
|
||||
|
||||
return Milestone(
|
||||
id=str(gitea_milestone['id']),
|
||||
title=gitea_milestone['title'],
|
||||
description=gitea_milestone.get('description', ''),
|
||||
state=gitea_milestone['state'],
|
||||
due_date=datetime.fromisoformat(gitea_milestone['due_on'].replace('Z', '+00:00')) if gitea_milestone.get('due_on') else None,
|
||||
created_at=datetime.fromisoformat(gitea_milestone['created_at'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(gitea_milestone['updated_at'].replace('Z', '+00:00')),
|
||||
backend_id=str(gitea_milestone['id'])
|
||||
)
|
||||
|
||||
def delete_milestone(self, milestone_id: str) -> bool:
|
||||
"""Delete milestone."""
|
||||
try:
|
||||
self._api_request('DELETE', f'/repos/{self.owner}/{self.repo}/milestones/{milestone_id}')
|
||||
return True
|
||||
except GiteaAPIError:
|
||||
return False
|
||||
|
||||
# Comment Operations
|
||||
def add_comment(self, issue_id: str, comment: Comment) -> Comment:
|
||||
"""Add comment to issue."""
|
||||
data = {'body': comment.body}
|
||||
|
||||
response = self._api_request('POST', f'/repos/{self.owner}/{self.repo}/issues/{issue_id}/comments', data=data)
|
||||
gitea_comment = response.json()
|
||||
|
||||
# Convert author
|
||||
author_data = gitea_comment['user']
|
||||
author = User(
|
||||
id=str(author_data['id']),
|
||||
username=author_data['login'],
|
||||
display_name=author_data.get('full_name', ''),
|
||||
email=author_data.get('email', ''),
|
||||
avatar_url=author_data.get('avatar_url', ''),
|
||||
backend_id=str(author_data['id'])
|
||||
)
|
||||
|
||||
return Comment(
|
||||
id=str(gitea_comment['id']),
|
||||
body=gitea_comment['body'],
|
||||
author=author,
|
||||
created_at=datetime.fromisoformat(gitea_comment['created_at'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(gitea_comment['updated_at'].replace('Z', '+00:00')),
|
||||
backend_id=str(gitea_comment['id'])
|
||||
)
|
||||
|
||||
def get_comments(self, issue_id: str) -> List[Comment]:
|
||||
"""Get comments for issue."""
|
||||
response = self._api_request('GET', f'/repos/{self.owner}/{self.repo}/issues/{issue_id}/comments')
|
||||
gitea_comments = response.json()
|
||||
|
||||
comments = []
|
||||
for gc in gitea_comments:
|
||||
author_data = gc['user']
|
||||
author = User(
|
||||
id=str(author_data['id']),
|
||||
username=author_data['login'],
|
||||
display_name=author_data.get('full_name', ''),
|
||||
email=author_data.get('email', ''),
|
||||
avatar_url=author_data.get('avatar_url', ''),
|
||||
backend_id=str(author_data['id'])
|
||||
)
|
||||
|
||||
comment = Comment(
|
||||
id=str(gc['id']),
|
||||
body=gc['body'],
|
||||
author=author,
|
||||
created_at=datetime.fromisoformat(gc['created_at'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(gc['updated_at'].replace('Z', '+00:00')),
|
||||
backend_id=str(gc['id'])
|
||||
)
|
||||
comments.append(comment)
|
||||
|
||||
return comments
|
||||
|
||||
def update_comment(self, comment: Comment) -> Comment:
|
||||
"""Update comment."""
|
||||
data = {'body': comment.body}
|
||||
|
||||
response = self._api_request('PATCH', f'/repos/{self.owner}/{self.repo}/issues/comments/{comment.backend_id}', data=data)
|
||||
gitea_comment = response.json()
|
||||
|
||||
# Update comment object
|
||||
comment.updated_at = datetime.fromisoformat(gitea_comment['updated_at'].replace('Z', '+00:00'))
|
||||
return comment
|
||||
|
||||
def delete_comment(self, comment_id: str) -> bool:
|
||||
"""Delete comment."""
|
||||
try:
|
||||
self._api_request('DELETE', f'/repos/{self.owner}/{self.repo}/issues/comments/{comment_id}')
|
||||
return True
|
||||
except GiteaAPIError:
|
||||
return False
|
||||
|
||||
# Sync Support
|
||||
def get_last_sync_timestamp(self) -> Optional[datetime]:
|
||||
"""Get last sync timestamp - stored in metadata."""
|
||||
# Could be stored in repository description or other metadata
|
||||
return None
|
||||
|
||||
def get_issues_modified_since(self, timestamp: datetime) -> List[Issue]:
|
||||
"""Get issues modified since timestamp."""
|
||||
filter_criteria = IssueFilter(updated_after=timestamp)
|
||||
return self.list_issues(filter_criteria)
|
||||
|
||||
# SyncableBackend Implementation
|
||||
def prepare_for_sync(self) -> None:
|
||||
"""Prepare for sync operation."""
|
||||
# Could implement rate limiting preparation
|
||||
pass
|
||||
|
||||
def finalize_sync(self, success: bool) -> None:
|
||||
"""Finalize sync operation."""
|
||||
# Could log sync status
|
||||
pass
|
||||
|
||||
def get_sync_conflicts(self) -> List[Dict[str, Any]]:
|
||||
"""Get sync conflicts."""
|
||||
# Would compare timestamps and detect conflicts
|
||||
return []
|
||||
|
||||
def resolve_sync_conflict(self, issue_id: str, resolution: str) -> Issue:
|
||||
"""Resolve sync conflict."""
|
||||
issue = self.get_issue(issue_id)
|
||||
if not issue:
|
||||
raise GiteaAPIError(f"Issue {issue_id} not found")
|
||||
|
||||
if resolution == 'remote':
|
||||
# Keep remote version (current issue)
|
||||
return issue
|
||||
elif resolution == 'local':
|
||||
# This would require the local version to be provided
|
||||
raise NotImplementedError("Local resolution requires local issue data")
|
||||
else:
|
||||
raise ValueError(f"Unknown resolution: {resolution}")
|
||||
19
issue-facade/backends/local/__init__.py
Normal file
19
issue-facade/backends/local/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Local SQLite Backend
|
||||
|
||||
A local, file-based issue tracking backend using SQLite for storage.
|
||||
This backend provides complete offline functionality and serves as the
|
||||
reference implementation for the backend interface.
|
||||
|
||||
Features:
|
||||
- Full CRUD operations
|
||||
- SQLite database storage
|
||||
- No external dependencies
|
||||
- Offline operation
|
||||
- Fast local search
|
||||
- Backup and export capabilities
|
||||
"""
|
||||
|
||||
from .backend import LocalSQLiteBackend
|
||||
|
||||
__all__ = ['LocalSQLiteBackend']
|
||||
618
issue-facade/backends/local/backend.py
Normal file
618
issue-facade/backends/local/backend.py
Normal file
@@ -0,0 +1,618 @@
|
||||
"""
|
||||
Local SQLite Backend Implementation
|
||||
|
||||
Provides a complete local issue tracking backend using SQLite for storage.
|
||||
This implementation serves as the reference for the backend interface and
|
||||
provides full offline functionality.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from ...core.interfaces import LocalBackend, BackendCapabilities, IssueFilter, SyncableBackend
|
||||
from ...core.models import Issue, Label, User, Milestone, Comment, IssueState, Priority, IssueType
|
||||
|
||||
|
||||
class LocalSQLiteBackend(LocalBackend, SyncableBackend):
|
||||
"""SQLite-based local backend for issue tracking."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or "issues.db"
|
||||
self.connection: Optional[sqlite3.Connection] = None
|
||||
self._capabilities = BackendCapabilities(
|
||||
supports_milestones=True,
|
||||
supports_assignees=True,
|
||||
supports_comments=True,
|
||||
supports_labels=True,
|
||||
supports_search=True,
|
||||
supports_bulk_operations=True,
|
||||
supports_webhooks=False,
|
||||
supports_real_time=False,
|
||||
max_labels_per_issue=None,
|
||||
max_assignees_per_issue=None
|
||||
)
|
||||
|
||||
@property
|
||||
def backend_type(self) -> str:
|
||||
return "local"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> BackendCapabilities:
|
||||
return self._capabilities
|
||||
|
||||
def connect(self, config: Dict[str, Any]) -> None:
|
||||
"""Connect to SQLite database."""
|
||||
db_path = config.get('db_path', self.db_path)
|
||||
self.db_path = db_path
|
||||
|
||||
# Ensure directory exists
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.connection = sqlite3.connect(db_path)
|
||||
self.connection.row_factory = sqlite3.Row # Enable dict-like access
|
||||
self.connection.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Initialize schema
|
||||
self._initialize_schema()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from database."""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
self.connection = None
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""Test database connection."""
|
||||
if not self.connection:
|
||||
return False
|
||||
try:
|
||||
self.connection.execute("SELECT 1")
|
||||
return True
|
||||
except sqlite3.Error:
|
||||
return False
|
||||
|
||||
def _initialize_schema(self) -> None:
|
||||
"""Initialize database schema."""
|
||||
schema_path = Path(__file__).parent / "schema.sql"
|
||||
with open(schema_path, 'r') as f:
|
||||
schema_sql = f.read()
|
||||
|
||||
# Execute schema in parts (SQLite doesn't like multiple statements)
|
||||
for statement in schema_sql.split(';'):
|
||||
statement = statement.strip()
|
||||
if statement:
|
||||
self.connection.execute(statement)
|
||||
self.connection.commit()
|
||||
|
||||
def _get_next_issue_number(self) -> int:
|
||||
"""Get the next available issue number."""
|
||||
cursor = self.connection.execute("SELECT MAX(number) FROM issues")
|
||||
result = cursor.fetchone()
|
||||
return (result[0] or 0) + 1
|
||||
|
||||
def _issue_from_row(self, row: sqlite3.Row) -> Issue:
|
||||
"""Convert database row to Issue object."""
|
||||
# Get labels
|
||||
cursor = self.connection.execute("""
|
||||
SELECT l.id, l.name, l.color, l.description, l.backend_id
|
||||
FROM labels l
|
||||
JOIN issue_labels il ON l.id = il.label_id
|
||||
WHERE il.issue_id = ?
|
||||
""", (row['id'],))
|
||||
label_rows = cursor.fetchall()
|
||||
labels = [Label(
|
||||
name=lr['name'],
|
||||
color=lr['color'],
|
||||
description=lr['description'],
|
||||
backend_id=lr['backend_id']
|
||||
) for lr in label_rows]
|
||||
|
||||
# Get assignees
|
||||
cursor = self.connection.execute("""
|
||||
SELECT u.id, u.username, u.display_name, u.email, u.avatar_url, u.backend_id
|
||||
FROM users u
|
||||
JOIN issue_assignees ia ON u.id = ia.user_id
|
||||
WHERE ia.issue_id = ?
|
||||
""", (row['id'],))
|
||||
user_rows = cursor.fetchall()
|
||||
assignees = [User(
|
||||
id=ur['id'],
|
||||
username=ur['username'],
|
||||
display_name=ur['display_name'],
|
||||
email=ur['email'],
|
||||
avatar_url=ur['avatar_url'],
|
||||
backend_id=ur['backend_id']
|
||||
) for ur in user_rows]
|
||||
|
||||
# Get milestone
|
||||
milestone = None
|
||||
if row['milestone_id']:
|
||||
cursor = self.connection.execute("""
|
||||
SELECT id, title, description, state, due_date, created_at, updated_at, backend_id
|
||||
FROM milestones WHERE id = ?
|
||||
""", (row['milestone_id'],))
|
||||
m_row = cursor.fetchone()
|
||||
if m_row:
|
||||
milestone = Milestone(
|
||||
id=m_row['id'],
|
||||
title=m_row['title'],
|
||||
description=m_row['description'],
|
||||
state=m_row['state'],
|
||||
due_date=datetime.fromisoformat(m_row['due_date']) if m_row['due_date'] else None,
|
||||
created_at=datetime.fromisoformat(m_row['created_at']) if m_row['created_at'] else None,
|
||||
updated_at=datetime.fromisoformat(m_row['updated_at']) if m_row['updated_at'] else None,
|
||||
backend_id=m_row['backend_id']
|
||||
)
|
||||
|
||||
# Parse sync metadata
|
||||
sync_metadata = {}
|
||||
if row['sync_metadata']:
|
||||
try:
|
||||
sync_metadata = json.loads(row['sync_metadata'])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return Issue(
|
||||
id=row['id'],
|
||||
number=row['number'],
|
||||
title=row['title'],
|
||||
description=row['description'],
|
||||
state=IssueState.from_string(row['state']),
|
||||
created_at=datetime.fromisoformat(row['created_at']),
|
||||
updated_at=datetime.fromisoformat(row['updated_at']),
|
||||
closed_at=datetime.fromisoformat(row['closed_at']) if row['closed_at'] else None,
|
||||
labels=labels,
|
||||
assignees=assignees,
|
||||
milestone=milestone,
|
||||
backend_id=row['backend_id'],
|
||||
backend_type=row['backend_type'],
|
||||
sync_metadata=sync_metadata
|
||||
)
|
||||
|
||||
# Issue CRUD Operations
|
||||
def create_issue(self, issue: Issue) -> Issue:
|
||||
"""Create a new issue."""
|
||||
if not issue.id:
|
||||
issue.id = str(uuid.uuid4())
|
||||
|
||||
if not issue.number:
|
||||
issue.number = self._get_next_issue_number()
|
||||
|
||||
# Insert issue
|
||||
self.connection.execute("""
|
||||
INSERT INTO issues (id, number, title, description, state, created_at, updated_at,
|
||||
closed_at, milestone_id, backend_id, backend_type, sync_metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
issue.id,
|
||||
issue.number,
|
||||
issue.title,
|
||||
issue.description,
|
||||
issue.state.value,
|
||||
issue.created_at.isoformat(),
|
||||
issue.updated_at.isoformat(),
|
||||
issue.closed_at.isoformat() if issue.closed_at else None,
|
||||
issue.milestone.id if issue.milestone else None,
|
||||
issue.backend_id,
|
||||
issue.backend_type or 'local',
|
||||
json.dumps(issue.sync_metadata) if issue.sync_metadata else None
|
||||
))
|
||||
|
||||
# Add labels
|
||||
for label in issue.labels:
|
||||
self._ensure_label_exists(label)
|
||||
self.connection.execute("""
|
||||
INSERT OR IGNORE INTO issue_labels (issue_id, label_id)
|
||||
VALUES (?, ?)
|
||||
""", (issue.id, label.name)) # Using name as ID for simplicity
|
||||
|
||||
# Add assignees
|
||||
for user in issue.assignees:
|
||||
self._ensure_user_exists(user)
|
||||
self.connection.execute("""
|
||||
INSERT OR IGNORE INTO issue_assignees (issue_id, user_id)
|
||||
VALUES (?, ?)
|
||||
""", (issue.id, user.id))
|
||||
|
||||
self.connection.commit()
|
||||
return issue
|
||||
|
||||
def get_issue(self, issue_id: str) -> Optional[Issue]:
|
||||
"""Get issue by ID."""
|
||||
cursor = self.connection.execute("""
|
||||
SELECT * FROM issues WHERE id = ? OR backend_id = ?
|
||||
""", (issue_id, issue_id))
|
||||
row = cursor.fetchone()
|
||||
return self._issue_from_row(row) if row else None
|
||||
|
||||
def get_issue_by_number(self, number: int) -> Optional[Issue]:
|
||||
"""Get issue by number."""
|
||||
cursor = self.connection.execute("""
|
||||
SELECT * FROM issues WHERE number = ?
|
||||
""", (number,))
|
||||
row = cursor.fetchone()
|
||||
return self._issue_from_row(row) if row else None
|
||||
|
||||
def update_issue(self, issue: Issue) -> Issue:
|
||||
"""Update existing issue."""
|
||||
# Update main issue record
|
||||
self.connection.execute("""
|
||||
UPDATE issues SET
|
||||
title = ?, description = ?, state = ?, updated_at = ?,
|
||||
closed_at = ?, milestone_id = ?, sync_metadata = ?
|
||||
WHERE id = ?
|
||||
""", (
|
||||
issue.title,
|
||||
issue.description,
|
||||
issue.state.value,
|
||||
issue.updated_at.isoformat(),
|
||||
issue.closed_at.isoformat() if issue.closed_at else None,
|
||||
issue.milestone.id if issue.milestone else None,
|
||||
json.dumps(issue.sync_metadata) if issue.sync_metadata else None,
|
||||
issue.id
|
||||
))
|
||||
|
||||
# Update labels (remove all and re-add)
|
||||
self.connection.execute("DELETE FROM issue_labels WHERE issue_id = ?", (issue.id,))
|
||||
for label in issue.labels:
|
||||
self._ensure_label_exists(label)
|
||||
self.connection.execute("""
|
||||
INSERT INTO issue_labels (issue_id, label_id) VALUES (?, ?)
|
||||
""", (issue.id, label.name))
|
||||
|
||||
# Update assignees (remove all and re-add)
|
||||
self.connection.execute("DELETE FROM issue_assignees WHERE issue_id = ?", (issue.id,))
|
||||
for user in issue.assignees:
|
||||
self._ensure_user_exists(user)
|
||||
self.connection.execute("""
|
||||
INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?)
|
||||
""", (issue.id, user.id))
|
||||
|
||||
self.connection.commit()
|
||||
return issue
|
||||
|
||||
def delete_issue(self, issue_id: str) -> bool:
|
||||
"""Delete issue."""
|
||||
cursor = self.connection.execute("DELETE FROM issues WHERE id = ?", (issue_id,))
|
||||
self.connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def list_issues(self, filter_criteria: Optional[IssueFilter] = None) -> List[Issue]:
|
||||
"""List issues with optional filtering."""
|
||||
query = "SELECT * FROM issues WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if filter_criteria:
|
||||
if filter_criteria.state:
|
||||
query += " AND state = ?"
|
||||
params.append(filter_criteria.state)
|
||||
|
||||
if filter_criteria.search:
|
||||
query += " AND (title LIKE ? OR description LIKE ?)"
|
||||
search_term = f"%{filter_criteria.search}%"
|
||||
params.extend([search_term, search_term])
|
||||
|
||||
if filter_criteria.created_after:
|
||||
query += " AND created_at >= ?"
|
||||
params.append(filter_criteria.created_after.isoformat())
|
||||
|
||||
if filter_criteria.created_before:
|
||||
query += " AND created_at <= ?"
|
||||
params.append(filter_criteria.created_before.isoformat())
|
||||
|
||||
if filter_criteria.updated_after:
|
||||
query += " AND updated_at >= ?"
|
||||
params.append(filter_criteria.updated_after.isoformat())
|
||||
|
||||
if filter_criteria.updated_before:
|
||||
query += " AND updated_at <= ?"
|
||||
params.append(filter_criteria.updated_before.isoformat())
|
||||
|
||||
query += " ORDER BY updated_at DESC"
|
||||
|
||||
if filter_criteria and filter_criteria.limit:
|
||||
query += " LIMIT ?"
|
||||
params.append(filter_criteria.limit)
|
||||
if filter_criteria.offset:
|
||||
query += " OFFSET ?"
|
||||
params.append(filter_criteria.offset)
|
||||
|
||||
cursor = self.connection.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
return [self._issue_from_row(row) for row in rows]
|
||||
|
||||
def search_issues(self, query: str, limit: Optional[int] = None) -> List[Issue]:
|
||||
"""Search issues using FTS if available, otherwise fallback to LIKE."""
|
||||
try:
|
||||
# Try FTS search first
|
||||
fts_query = """
|
||||
SELECT i.* FROM issues i
|
||||
JOIN issue_search s ON i.id = s.issue_id
|
||||
WHERE issue_search MATCH ?
|
||||
ORDER BY rank
|
||||
"""
|
||||
params = [query]
|
||||
if limit:
|
||||
fts_query += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor = self.connection.execute(fts_query, params)
|
||||
rows = cursor.fetchall()
|
||||
return [self._issue_from_row(row) for row in rows]
|
||||
|
||||
except sqlite3.OperationalError:
|
||||
# Fallback to LIKE search
|
||||
filter_criteria = IssueFilter(search=query, limit=limit)
|
||||
return self.list_issues(filter_criteria)
|
||||
|
||||
# Helper methods
|
||||
def _ensure_label_exists(self, label: Label) -> None:
|
||||
"""Ensure label exists in database."""
|
||||
self.connection.execute("""
|
||||
INSERT OR IGNORE INTO labels (id, name, color, description, backend_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (label.name, label.name, label.color, label.description, label.backend_id))
|
||||
|
||||
def _ensure_user_exists(self, user: User) -> None:
|
||||
"""Ensure user exists in database."""
|
||||
self.connection.execute("""
|
||||
INSERT OR IGNORE INTO users (id, username, display_name, email, avatar_url, backend_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (user.id, user.username, user.display_name, user.email, user.avatar_url, user.backend_id))
|
||||
|
||||
# Label Operations
|
||||
def create_label(self, label: Label) -> Label:
|
||||
"""Create a new label."""
|
||||
label_id = label.name # Use name as ID
|
||||
self.connection.execute("""
|
||||
INSERT INTO labels (id, name, color, description, backend_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (label_id, label.name, label.color, label.description, label.backend_id))
|
||||
self.connection.commit()
|
||||
return label
|
||||
|
||||
def get_labels(self) -> List[Label]:
|
||||
"""Get all labels."""
|
||||
cursor = self.connection.execute("SELECT * FROM labels ORDER BY name")
|
||||
rows = cursor.fetchall()
|
||||
return [Label(
|
||||
name=row['name'],
|
||||
color=row['color'],
|
||||
description=row['description'],
|
||||
backend_id=row['backend_id']
|
||||
) for row in rows]
|
||||
|
||||
def update_label(self, label: Label) -> Label:
|
||||
"""Update label."""
|
||||
self.connection.execute("""
|
||||
UPDATE labels SET color = ?, description = ? WHERE name = ?
|
||||
""", (label.color, label.description, label.name))
|
||||
self.connection.commit()
|
||||
return label
|
||||
|
||||
def delete_label(self, label_name: str) -> bool:
|
||||
"""Delete label."""
|
||||
cursor = self.connection.execute("DELETE FROM labels WHERE name = ?", (label_name,))
|
||||
self.connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# User Operations
|
||||
def get_users(self) -> List[User]:
|
||||
"""Get all users."""
|
||||
cursor = self.connection.execute("SELECT * FROM users ORDER BY username")
|
||||
rows = cursor.fetchall()
|
||||
return [User(
|
||||
id=row['id'],
|
||||
username=row['username'],
|
||||
display_name=row['display_name'],
|
||||
email=row['email'],
|
||||
avatar_url=row['avatar_url'],
|
||||
backend_id=row['backend_id']
|
||||
) for row in rows]
|
||||
|
||||
def get_user(self, user_id: str) -> Optional[User]:
|
||||
"""Get user by ID."""
|
||||
cursor = self.connection.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return User(
|
||||
id=row['id'],
|
||||
username=row['username'],
|
||||
display_name=row['display_name'],
|
||||
email=row['email'],
|
||||
avatar_url=row['avatar_url'],
|
||||
backend_id=row['backend_id']
|
||||
)
|
||||
return None
|
||||
|
||||
def search_users(self, query: str) -> List[User]:
|
||||
"""Search users."""
|
||||
cursor = self.connection.execute("""
|
||||
SELECT * FROM users
|
||||
WHERE username LIKE ? OR display_name LIKE ? OR email LIKE ?
|
||||
ORDER BY username
|
||||
""", (f"%{query}%", f"%{query}%", f"%{query}%"))
|
||||
rows = cursor.fetchall()
|
||||
return [User(
|
||||
id=row['id'],
|
||||
username=row['username'],
|
||||
display_name=row['display_name'],
|
||||
email=row['email'],
|
||||
avatar_url=row['avatar_url'],
|
||||
backend_id=row['backend_id']
|
||||
) for row in rows]
|
||||
|
||||
# Milestone Operations
|
||||
def create_milestone(self, milestone: Milestone) -> Milestone:
|
||||
"""Create milestone."""
|
||||
if not milestone.id:
|
||||
milestone.id = str(uuid.uuid4())
|
||||
|
||||
self.connection.execute("""
|
||||
INSERT INTO milestones (id, title, description, state, due_date, created_at, updated_at, backend_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
milestone.id,
|
||||
milestone.title,
|
||||
milestone.description,
|
||||
milestone.state,
|
||||
milestone.due_date.isoformat() if milestone.due_date else None,
|
||||
milestone.created_at.isoformat() if milestone.created_at else datetime.now(timezone.utc).isoformat(),
|
||||
milestone.updated_at.isoformat() if milestone.updated_at else datetime.now(timezone.utc).isoformat(),
|
||||
milestone.backend_id
|
||||
))
|
||||
self.connection.commit()
|
||||
return milestone
|
||||
|
||||
def get_milestones(self) -> List[Milestone]:
|
||||
"""Get all milestones."""
|
||||
cursor = self.connection.execute("SELECT * FROM milestones ORDER BY title")
|
||||
rows = cursor.fetchall()
|
||||
return [Milestone(
|
||||
id=row['id'],
|
||||
title=row['title'],
|
||||
description=row['description'],
|
||||
state=row['state'],
|
||||
due_date=datetime.fromisoformat(row['due_date']) if row['due_date'] else None,
|
||||
created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
|
||||
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
|
||||
backend_id=row['backend_id']
|
||||
) for row in rows]
|
||||
|
||||
def update_milestone(self, milestone: Milestone) -> Milestone:
|
||||
"""Update milestone."""
|
||||
self.connection.execute("""
|
||||
UPDATE milestones SET title = ?, description = ?, state = ?, due_date = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", (
|
||||
milestone.title,
|
||||
milestone.description,
|
||||
milestone.state,
|
||||
milestone.due_date.isoformat() if milestone.due_date else None,
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
milestone.id
|
||||
))
|
||||
self.connection.commit()
|
||||
return milestone
|
||||
|
||||
def delete_milestone(self, milestone_id: str) -> bool:
|
||||
"""Delete milestone."""
|
||||
cursor = self.connection.execute("DELETE FROM milestones WHERE id = ?", (milestone_id,))
|
||||
self.connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# Comment Operations
|
||||
def add_comment(self, issue_id: str, comment: Comment) -> Comment:
|
||||
"""Add comment to issue."""
|
||||
if not comment.id:
|
||||
comment.id = str(uuid.uuid4())
|
||||
|
||||
self._ensure_user_exists(comment.author)
|
||||
|
||||
self.connection.execute("""
|
||||
INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at, backend_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
comment.id,
|
||||
issue_id,
|
||||
comment.author.id,
|
||||
comment.body,
|
||||
comment.created_at.isoformat(),
|
||||
comment.updated_at.isoformat() if comment.updated_at else None,
|
||||
comment.backend_id
|
||||
))
|
||||
self.connection.commit()
|
||||
return comment
|
||||
|
||||
def get_comments(self, issue_id: str) -> List[Comment]:
|
||||
"""Get comments for issue."""
|
||||
cursor = self.connection.execute("""
|
||||
SELECT c.*, u.id as user_id, u.username, u.display_name, u.email, u.avatar_url, u.backend_id as user_backend_id
|
||||
FROM comments c
|
||||
JOIN users u ON c.author_id = u.id
|
||||
WHERE c.issue_id = ?
|
||||
ORDER BY c.created_at
|
||||
""", (issue_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
comments = []
|
||||
for row in rows:
|
||||
author = User(
|
||||
id=row['user_id'],
|
||||
username=row['username'],
|
||||
display_name=row['display_name'],
|
||||
email=row['email'],
|
||||
avatar_url=row['avatar_url'],
|
||||
backend_id=row['user_backend_id']
|
||||
)
|
||||
comment = Comment(
|
||||
id=row['id'],
|
||||
body=row['body'],
|
||||
author=author,
|
||||
created_at=datetime.fromisoformat(row['created_at']),
|
||||
updated_at=datetime.fromisoformat(row['updated_at']) if row['updated_at'] else None,
|
||||
backend_id=row['backend_id']
|
||||
)
|
||||
comments.append(comment)
|
||||
|
||||
return comments
|
||||
|
||||
def update_comment(self, comment: Comment) -> Comment:
|
||||
"""Update comment."""
|
||||
self.connection.execute("""
|
||||
UPDATE comments SET body = ?, updated_at = ? WHERE id = ?
|
||||
""", (comment.body, datetime.now(timezone.utc).isoformat(), comment.id))
|
||||
self.connection.commit()
|
||||
return comment
|
||||
|
||||
def delete_comment(self, comment_id: str) -> bool:
|
||||
"""Delete comment."""
|
||||
cursor = self.connection.execute("DELETE FROM comments WHERE id = ?", (comment_id,))
|
||||
self.connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
# Sync Support
|
||||
def get_last_sync_timestamp(self) -> Optional[datetime]:
|
||||
"""Get last sync timestamp."""
|
||||
cursor = self.connection.execute("""
|
||||
SELECT sync_timestamp FROM sync_history
|
||||
WHERE success = 1
|
||||
ORDER BY sync_timestamp DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
return datetime.fromisoformat(row[0]) if row else None
|
||||
|
||||
def get_issues_modified_since(self, timestamp: datetime) -> List[Issue]:
|
||||
"""Get issues modified since timestamp."""
|
||||
filter_criteria = IssueFilter(updated_after=timestamp)
|
||||
return self.list_issues(filter_criteria)
|
||||
|
||||
# SyncableBackend Implementation
|
||||
def prepare_for_sync(self) -> None:
|
||||
"""Prepare for sync operation."""
|
||||
# Could create backup or start transaction
|
||||
pass
|
||||
|
||||
def finalize_sync(self, success: bool) -> None:
|
||||
"""Finalize sync operation."""
|
||||
# Log sync operation
|
||||
self.connection.execute("""
|
||||
INSERT INTO sync_history (backend_type, success, sync_timestamp)
|
||||
VALUES (?, ?, ?)
|
||||
""", ('sync', success, datetime.now(timezone.utc).isoformat()))
|
||||
self.connection.commit()
|
||||
|
||||
def get_sync_conflicts(self) -> List[Dict[str, Any]]:
|
||||
"""Get sync conflicts."""
|
||||
# For local backend, no conflicts since it's the source of truth
|
||||
return []
|
||||
|
||||
def resolve_sync_conflict(self, issue_id: str, resolution: str) -> Issue:
|
||||
"""Resolve sync conflict."""
|
||||
# Local backend doesn't have conflicts
|
||||
return self.get_issue(issue_id)
|
||||
189
issue-facade/backends/local/schema.sql
Normal file
189
issue-facade/backends/local/schema.sql
Normal file
@@ -0,0 +1,189 @@
|
||||
-- Local Issue Tracking Database Schema
|
||||
-- SQLite schema for local issue storage with full referential integrity
|
||||
|
||||
-- Enable foreign key constraints
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Issues table - core issue data
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id TEXT PRIMARY KEY,
|
||||
number INTEGER UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL CHECK (state IN ('open', 'closed', 'in_progress', 'blocked')),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP NULL,
|
||||
milestone_id TEXT,
|
||||
backend_id TEXT,
|
||||
backend_type TEXT DEFAULT 'local',
|
||||
sync_metadata TEXT, -- JSON for sync data
|
||||
FOREIGN KEY (milestone_id) REFERENCES milestones(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Create index for issue number lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_number ON issues(number);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_state ON issues(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_updated_at ON issues(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_backend_id ON issues(backend_id);
|
||||
|
||||
-- Labels table
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
color TEXT,
|
||||
description TEXT,
|
||||
backend_id TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index for label name lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_labels_name ON labels(name);
|
||||
|
||||
-- Issue-Label many-to-many relationship
|
||||
CREATE TABLE IF NOT EXISTS issue_labels (
|
||||
issue_id TEXT NOT NULL,
|
||||
label_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (issue_id, label_id),
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
avatar_url TEXT,
|
||||
backend_id TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index for username lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- Issue-User assignment many-to-many relationship
|
||||
CREATE TABLE IF NOT EXISTS issue_assignees (
|
||||
issue_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (issue_id, user_id),
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Milestones table
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
|
||||
due_date TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
backend_id TEXT
|
||||
);
|
||||
|
||||
-- Create index for milestone title lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_milestones_title ON milestones(title);
|
||||
|
||||
-- Comments table
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
issue_id TEXT NOT NULL,
|
||||
author_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
backend_id TEXT,
|
||||
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create index for comment lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_issue_id ON comments(issue_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_created_at ON comments(created_at);
|
||||
|
||||
-- Sync tracking table
|
||||
CREATE TABLE IF NOT EXISTS sync_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
backend_type TEXT NOT NULL,
|
||||
sync_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
success BOOLEAN NOT NULL,
|
||||
issues_synced INTEGER DEFAULT 0,
|
||||
errors_count INTEGER DEFAULT 0,
|
||||
details TEXT -- JSON for sync details
|
||||
);
|
||||
|
||||
-- Configuration table for backend settings
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Triggers to automatically update updated_at timestamps
|
||||
CREATE TRIGGER IF NOT EXISTS update_issues_timestamp
|
||||
AFTER UPDATE ON issues
|
||||
BEGIN
|
||||
UPDATE issues SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS update_milestones_timestamp
|
||||
AFTER UPDATE ON milestones
|
||||
BEGIN
|
||||
UPDATE milestones SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- Views for common queries
|
||||
CREATE VIEW IF NOT EXISTS issue_summary AS
|
||||
SELECT
|
||||
i.id,
|
||||
i.number,
|
||||
i.title,
|
||||
i.state,
|
||||
i.created_at,
|
||||
i.updated_at,
|
||||
i.closed_at,
|
||||
m.title as milestone_title,
|
||||
COUNT(c.id) as comment_count,
|
||||
GROUP_CONCAT(l.name) as labels,
|
||||
GROUP_CONCAT(u.username) as assignees
|
||||
FROM issues i
|
||||
LEFT JOIN milestones m ON i.milestone_id = m.id
|
||||
LEFT JOIN comments c ON i.id = c.issue_id
|
||||
LEFT JOIN issue_labels il ON i.id = il.issue_id
|
||||
LEFT JOIN labels l ON il.label_id = l.id
|
||||
LEFT JOIN issue_assignees ia ON i.id = ia.issue_id
|
||||
LEFT JOIN users u ON ia.user_id = u.id
|
||||
GROUP BY i.id, i.number, i.title, i.state, i.created_at, i.updated_at, i.closed_at, m.title;
|
||||
|
||||
-- Full-text search setup (if SQLite supports FTS)
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS issue_search USING fts5(
|
||||
issue_id,
|
||||
title,
|
||||
description,
|
||||
labels,
|
||||
content='issues'
|
||||
);
|
||||
|
||||
-- Trigger to keep FTS index updated
|
||||
CREATE TRIGGER IF NOT EXISTS issue_search_insert AFTER INSERT ON issues
|
||||
BEGIN
|
||||
INSERT INTO issue_search(issue_id, title, description)
|
||||
VALUES (NEW.id, NEW.title, NEW.description);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS issue_search_update AFTER UPDATE ON issues
|
||||
BEGIN
|
||||
UPDATE issue_search
|
||||
SET title = NEW.title, description = NEW.description
|
||||
WHERE issue_id = NEW.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS issue_search_delete AFTER DELETE ON issues
|
||||
BEGIN
|
||||
DELETE FROM issue_search WHERE issue_id = OLD.id;
|
||||
END;
|
||||
20
issue-facade/cli/__init__.py
Normal file
20
issue-facade/cli/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Command Line Interface for Universal Issue Tracking
|
||||
|
||||
Provides a comprehensive CLI for managing issues across different backends.
|
||||
The CLI is designed to be intuitive and follows common patterns from
|
||||
tools like git, gh (GitHub CLI), and similar utilities.
|
||||
|
||||
Commands:
|
||||
- issue list: List issues
|
||||
- issue show: Show issue details
|
||||
- issue create: Create new issue
|
||||
- issue edit: Edit existing issue
|
||||
- issue close: Close issue
|
||||
- issue reopen: Reopen issue
|
||||
- issue comment: Add comment
|
||||
- issue label: Manage labels
|
||||
- issue assign: Manage assignments
|
||||
- backend: Manage backends
|
||||
- sync: Synchronization operations
|
||||
"""
|
||||
141
issue-facade/cli/backend_commands.py
Normal file
141
issue-facade/cli/backend_commands.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Backend Management CLI Commands
|
||||
|
||||
Commands for configuring and managing issue tracking backends.
|
||||
"""
|
||||
|
||||
import click
|
||||
from .utils import (
|
||||
load_backend_configs, save_backend_configs, format_backend_list,
|
||||
test_backend_connection, validate_backend_type, echo_success,
|
||||
echo_error, echo_warning, confirm_action
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
def backend_group():
|
||||
"""Backend configuration and management."""
|
||||
pass
|
||||
|
||||
|
||||
@backend_group.command('list')
|
||||
def list_backends():
|
||||
"""List configured backends."""
|
||||
configs = load_backend_configs()
|
||||
click.echo(format_backend_list(configs))
|
||||
|
||||
|
||||
@backend_group.command('add')
|
||||
@click.argument('name')
|
||||
@click.argument('backend_type', type=click.Choice(['local', 'gitea']))
|
||||
@click.pass_context
|
||||
def add_backend(ctx, name, backend_type):
|
||||
"""Add a new backend configuration."""
|
||||
configs = load_backend_configs()
|
||||
|
||||
if name in configs:
|
||||
if not confirm_action(f"Backend '{name}' already exists. Overwrite?"):
|
||||
click.echo("Aborted")
|
||||
return
|
||||
|
||||
if backend_type == 'local':
|
||||
db_path = click.prompt('Database path', default=f'~/.config/issue-tracker/{name}.db')
|
||||
config = {
|
||||
'type': 'local',
|
||||
'db_path': str(db_path)
|
||||
}
|
||||
elif backend_type == 'gitea':
|
||||
base_url = click.prompt('Gitea base URL (e.g., https://git.example.com)')
|
||||
owner = click.prompt('Repository owner/organization')
|
||||
repo = click.prompt('Repository name')
|
||||
token = click.prompt('Access token', hide_input=True)
|
||||
|
||||
config = {
|
||||
'type': 'gitea',
|
||||
'base_url': base_url.rstrip('/'),
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'token': token
|
||||
}
|
||||
|
||||
# Test connection
|
||||
click.echo("Testing connection...")
|
||||
if test_backend_connection(config):
|
||||
echo_success("Connection successful!")
|
||||
else:
|
||||
echo_warning("Connection test failed, but configuration will be saved anyway.")
|
||||
|
||||
# Save configuration
|
||||
configs[name] = config
|
||||
save_backend_configs(configs)
|
||||
|
||||
echo_success(f"Backend '{name}' added successfully")
|
||||
|
||||
# Set as default if it's the first one
|
||||
if 'default' not in configs:
|
||||
configs['default'] = name
|
||||
save_backend_configs(configs)
|
||||
echo_info(f"Set '{name}' as default backend")
|
||||
|
||||
|
||||
@backend_group.command('remove')
|
||||
@click.argument('name')
|
||||
def remove_backend(name):
|
||||
"""Remove a backend configuration."""
|
||||
configs = load_backend_configs()
|
||||
|
||||
if name not in configs:
|
||||
echo_error(f"Backend '{name}' not found")
|
||||
return
|
||||
|
||||
if not confirm_action(f"Remove backend '{name}'?"):
|
||||
click.echo("Aborted")
|
||||
return
|
||||
|
||||
del configs[name]
|
||||
|
||||
# Update default if necessary
|
||||
if configs.get('default') == name:
|
||||
remaining_backends = [k for k in configs.keys() if k != 'default']
|
||||
if remaining_backends:
|
||||
configs['default'] = remaining_backends[0]
|
||||
echo_info(f"Set '{configs['default']}' as new default backend")
|
||||
else:
|
||||
del configs['default']
|
||||
|
||||
save_backend_configs(configs)
|
||||
echo_success(f"Backend '{name}' removed")
|
||||
|
||||
|
||||
@backend_group.command('test')
|
||||
@click.argument('name')
|
||||
def test_backend(name):
|
||||
"""Test backend connection."""
|
||||
configs = load_backend_configs()
|
||||
|
||||
if name not in configs:
|
||||
echo_error(f"Backend '{name}' not found")
|
||||
return
|
||||
|
||||
config = configs[name]
|
||||
click.echo(f"Testing connection to '{name}'...")
|
||||
|
||||
if test_backend_connection(config):
|
||||
echo_success("Connection successful!")
|
||||
else:
|
||||
echo_error("Connection failed!")
|
||||
|
||||
|
||||
@backend_group.command('set-default')
|
||||
@click.argument('name')
|
||||
def set_default_backend(name):
|
||||
"""Set default backend."""
|
||||
configs = load_backend_configs()
|
||||
|
||||
if name not in configs:
|
||||
echo_error(f"Backend '{name}' not found")
|
||||
return
|
||||
|
||||
configs['default'] = name
|
||||
save_backend_configs(configs)
|
||||
echo_success(f"Set '{name}' as default backend")
|
||||
417
issue-facade/cli/commands.py
Normal file
417
issue-facade/cli/commands.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Issue Management CLI Commands
|
||||
|
||||
Core commands for managing issues: create, list, show, edit, close, etc.
|
||||
"""
|
||||
|
||||
import click
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from ..core.models import Issue, Label, User, IssueState, Priority, IssueType
|
||||
from ..core.interfaces import IssueFilter
|
||||
from .utils import get_backend, format_issue, format_issue_list, get_user_input
|
||||
|
||||
|
||||
@click.group()
|
||||
def issue_group():
|
||||
"""Issue management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@issue_group.command('list')
|
||||
@click.option('--state', type=click.Choice(['open', 'closed', 'all']), default='open', help='Issue state filter')
|
||||
@click.option('--assignee', help='Filter by assignee')
|
||||
@click.option('--label', multiple=True, help='Filter by labels')
|
||||
@click.option('--milestone', help='Filter by milestone')
|
||||
@click.option('--search', help='Search in title and description')
|
||||
@click.option('--limit', type=int, default=30, help='Maximum number of issues to show')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['table', 'json', 'compact']), default='table', help='Output format')
|
||||
@click.pass_context
|
||||
def list_issues(ctx, state, assignee, label, milestone, search, limit, output_format):
|
||||
"""List issues with optional filtering."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
# Build filter criteria
|
||||
filter_criteria = IssueFilter(
|
||||
state=None if state == 'all' else state,
|
||||
assignee=assignee,
|
||||
labels=list(label) if label else None,
|
||||
milestone=milestone,
|
||||
search=search,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
try:
|
||||
issues = backend.list_issues(filter_criteria)
|
||||
|
||||
if not issues:
|
||||
click.echo("No issues found.")
|
||||
return
|
||||
|
||||
if output_format == 'json':
|
||||
import json
|
||||
click.echo(json.dumps([issue.to_dict() for issue in issues], indent=2))
|
||||
elif output_format == 'compact':
|
||||
for issue in issues:
|
||||
labels_str = ', '.join(label.name for label in issue.labels[:3])
|
||||
if len(issue.labels) > 3:
|
||||
labels_str += f' (+{len(issue.labels) - 3} more)'
|
||||
|
||||
assignee_str = issue.primary_assignee.username if issue.primary_assignee else 'unassigned'
|
||||
|
||||
click.echo(f"#{issue.number:4d} {issue.state.value:10s} {issue.title[:50]:50s} {assignee_str:15s} {labels_str}")
|
||||
else: # table format
|
||||
click.echo(format_issue_list(issues))
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to list issues: {e}")
|
||||
|
||||
|
||||
@issue_group.command('show')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--comments', is_flag=True, help='Show comments')
|
||||
@click.option('--format', 'output_format', type=click.Choice(['detailed', 'json', 'compact']), default='detailed', help='Output format')
|
||||
@click.pass_context
|
||||
def show_issue(ctx, issue_number, comments, output_format):
|
||||
"""Show detailed information about an issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
if output_format == 'json':
|
||||
import json
|
||||
issue_dict = issue.to_dict()
|
||||
if comments:
|
||||
issue_dict['comments'] = [
|
||||
{
|
||||
'id': c.id,
|
||||
'body': c.body,
|
||||
'author': c.author.username,
|
||||
'created_at': c.created_at.isoformat()
|
||||
}
|
||||
for c in backend.get_comments(issue.id)
|
||||
]
|
||||
click.echo(json.dumps(issue_dict, indent=2))
|
||||
else:
|
||||
click.echo(format_issue(issue, show_comments=comments, backend=backend if comments else None))
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to show issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('create')
|
||||
@click.argument('title')
|
||||
@click.option('--description', '-d', help='Issue description')
|
||||
@click.option('--label', '-l', multiple=True, help='Labels to add')
|
||||
@click.option('--assignee', '-a', help='Assign to user')
|
||||
@click.option('--milestone', '-m', help='Milestone')
|
||||
@click.option('--priority', type=click.Choice(['low', 'medium', 'high', 'critical']), help='Issue priority')
|
||||
@click.option('--type', 'issue_type', type=click.Choice(['bug', 'feature', 'enhancement', 'task', 'documentation', 'question']), help='Issue type')
|
||||
@click.option('--interactive', '-i', is_flag=True, help='Interactive mode')
|
||||
@click.pass_context
|
||||
def create_issue(ctx, title, description, label, assignee, milestone, priority, issue_type, interactive):
|
||||
"""Create a new issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
# Interactive mode
|
||||
if interactive:
|
||||
title = title or click.prompt('Title')
|
||||
description = get_user_input('Description (optional)', multiline=True)
|
||||
|
||||
# Show available labels
|
||||
available_labels = backend.get_labels()
|
||||
if available_labels:
|
||||
click.echo(f"\nAvailable labels: {', '.join(l.name for l in available_labels)}")
|
||||
label = click.prompt('Labels (comma-separated, optional)', default='').split(',') if click.prompt('Add labels?', type=bool, default=False) else []
|
||||
|
||||
# Show available users
|
||||
available_users = backend.get_users()
|
||||
if available_users:
|
||||
click.echo(f"\nAvailable users: {', '.join(u.username for u in available_users)}")
|
||||
assignee = click.prompt('Assignee (optional)', default='') or None
|
||||
|
||||
# Show available milestones
|
||||
available_milestones = backend.get_milestones()
|
||||
if available_milestones:
|
||||
click.echo(f"\nAvailable milestones: {', '.join(m.title for m in available_milestones)}")
|
||||
milestone = click.prompt('Milestone (optional)', default='') or None
|
||||
|
||||
# Build labels list
|
||||
labels = []
|
||||
|
||||
# Add explicit labels
|
||||
for label_name in label:
|
||||
label_name = label_name.strip()
|
||||
if label_name:
|
||||
labels.append(Label(name=label_name))
|
||||
|
||||
# Add priority label
|
||||
if priority:
|
||||
labels.append(Label(name=f'priority:{priority}'))
|
||||
|
||||
# Add type label
|
||||
if issue_type:
|
||||
labels.append(Label(name=issue_type))
|
||||
|
||||
# Build assignees list
|
||||
assignees = []
|
||||
if assignee:
|
||||
# Try to find user
|
||||
users = backend.search_users(assignee)
|
||||
if users:
|
||||
assignees.append(users[0])
|
||||
else:
|
||||
# Create a basic user object
|
||||
assignees.append(User(id=assignee, username=assignee))
|
||||
|
||||
# Find milestone
|
||||
milestone_obj = None
|
||||
if milestone:
|
||||
milestones = backend.get_milestones()
|
||||
for m in milestones:
|
||||
if m.title == milestone or m.id == milestone:
|
||||
milestone_obj = m
|
||||
break
|
||||
|
||||
# Create issue
|
||||
now = datetime.now(timezone.utc)
|
||||
issue = Issue(
|
||||
id="", # Will be set by backend
|
||||
number=0, # Will be set by backend
|
||||
title=title,
|
||||
description=description or "",
|
||||
state=IssueState.OPEN,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
labels=labels,
|
||||
assignees=assignees,
|
||||
milestone=milestone_obj
|
||||
)
|
||||
|
||||
created_issue = backend.create_issue(issue)
|
||||
click.echo(f"Created issue #{created_issue.number}: {created_issue.title}")
|
||||
|
||||
if ctx.obj.get('verbose'):
|
||||
click.echo(format_issue(created_issue))
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to create issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('edit')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--title', help='New title')
|
||||
@click.option('--description', help='New description')
|
||||
@click.option('--add-label', multiple=True, help='Labels to add')
|
||||
@click.option('--remove-label', multiple=True, help='Labels to remove')
|
||||
@click.option('--assign', help='User to assign')
|
||||
@click.option('--unassign', help='User to unassign')
|
||||
@click.option('--milestone', help='Milestone to set')
|
||||
@click.option('--interactive', '-i', is_flag=True, help='Interactive editing')
|
||||
@click.pass_context
|
||||
def edit_issue(ctx, issue_number, title, description, add_label, remove_label, assign, unassign, milestone, interactive):
|
||||
"""Edit an existing issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
# Interactive mode
|
||||
if interactive:
|
||||
click.echo(f"Editing issue #{issue.number}: {issue.title}")
|
||||
|
||||
new_title = click.prompt('Title', default=issue.title)
|
||||
if new_title != issue.title:
|
||||
title = new_title
|
||||
|
||||
new_description = get_user_input('Description', default=issue.description, multiline=True)
|
||||
if new_description != issue.description:
|
||||
description = new_description
|
||||
|
||||
# Apply changes
|
||||
modified = False
|
||||
|
||||
if title:
|
||||
issue.title = title
|
||||
modified = True
|
||||
|
||||
if description is not None:
|
||||
issue.description = description
|
||||
modified = True
|
||||
|
||||
# Add labels
|
||||
for label_name in add_label:
|
||||
issue.add_label(Label(name=label_name.strip()))
|
||||
modified = True
|
||||
|
||||
# Remove labels
|
||||
for label_name in remove_label:
|
||||
if issue.remove_label(label_name.strip()):
|
||||
modified = True
|
||||
|
||||
# Assign user
|
||||
if assign:
|
||||
users = backend.search_users(assign)
|
||||
if users:
|
||||
issue.add_assignee(users[0])
|
||||
modified = True
|
||||
else:
|
||||
issue.add_assignee(User(id=assign, username=assign))
|
||||
modified = True
|
||||
|
||||
# Unassign user
|
||||
if unassign:
|
||||
if issue.remove_assignee(unassign):
|
||||
modified = True
|
||||
|
||||
# Set milestone
|
||||
if milestone:
|
||||
milestones = backend.get_milestones()
|
||||
for m in milestones:
|
||||
if m.title == milestone or m.id == milestone:
|
||||
issue.milestone = m
|
||||
modified = True
|
||||
break
|
||||
|
||||
if modified:
|
||||
issue.updated_at = datetime.now(timezone.utc)
|
||||
updated_issue = backend.update_issue(issue)
|
||||
click.echo(f"Updated issue #{updated_issue.number}")
|
||||
|
||||
if ctx.obj.get('verbose'):
|
||||
click.echo(format_issue(updated_issue))
|
||||
else:
|
||||
click.echo("No changes made")
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to edit issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('close')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--comment', '-c', help='Closing comment')
|
||||
@click.pass_context
|
||||
def close_issue(ctx, issue_number, comment):
|
||||
"""Close an issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
if issue.state == IssueState.CLOSED:
|
||||
click.echo(f"Issue #{issue_number} is already closed")
|
||||
return
|
||||
|
||||
# Close the issue
|
||||
issue.close()
|
||||
|
||||
# Add closing comment if provided
|
||||
if comment:
|
||||
from ..core.models import Comment
|
||||
current_user = User(id="cli-user", username="cli-user") # TODO: Get actual user
|
||||
closing_comment = Comment(
|
||||
id="",
|
||||
body=comment,
|
||||
author=current_user,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
backend.add_comment(issue.id, closing_comment)
|
||||
|
||||
updated_issue = backend.update_issue(issue)
|
||||
click.echo(f"Closed issue #{updated_issue.number}: {updated_issue.title}")
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to close issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('reopen')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--comment', '-c', help='Reopening comment')
|
||||
@click.pass_context
|
||||
def reopen_issue(ctx, issue_number, comment):
|
||||
"""Reopen a closed issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
if issue.state != IssueState.CLOSED:
|
||||
click.echo(f"Issue #{issue_number} is not closed (current state: {issue.state.value})")
|
||||
return
|
||||
|
||||
# Reopen the issue
|
||||
issue.reopen()
|
||||
|
||||
# Add reopening comment if provided
|
||||
if comment:
|
||||
from ..core.models import Comment
|
||||
current_user = User(id="cli-user", username="cli-user") # TODO: Get actual user
|
||||
reopening_comment = Comment(
|
||||
id="",
|
||||
body=comment,
|
||||
author=current_user,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
backend.add_comment(issue.id, reopening_comment)
|
||||
|
||||
updated_issue = backend.update_issue(issue)
|
||||
click.echo(f"Reopened issue #{updated_issue.number}: {updated_issue.title}")
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to reopen issue: {e}")
|
||||
|
||||
|
||||
@issue_group.command('comment')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.argument('comment_text', required=False)
|
||||
@click.option('--editor', is_flag=True, help='Open editor for comment')
|
||||
@click.pass_context
|
||||
def add_comment(ctx, issue_number, comment_text, editor):
|
||||
"""Add a comment to an issue."""
|
||||
backend = get_backend(ctx)
|
||||
|
||||
try:
|
||||
issue = backend.get_issue_by_number(issue_number)
|
||||
if not issue:
|
||||
raise click.ClickException(f"Issue #{issue_number} not found")
|
||||
|
||||
# Get comment text
|
||||
if editor:
|
||||
comment_text = click.edit() or ""
|
||||
elif not comment_text:
|
||||
comment_text = get_user_input("Comment", multiline=True)
|
||||
|
||||
if not comment_text.strip():
|
||||
click.echo("Empty comment, aborting")
|
||||
return
|
||||
|
||||
# Create comment
|
||||
from ..core.models import Comment
|
||||
current_user = User(id="cli-user", username="cli-user") # TODO: Get actual user
|
||||
comment = Comment(
|
||||
id="",
|
||||
body=comment_text.strip(),
|
||||
author=current_user,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
added_comment = backend.add_comment(issue.id, comment)
|
||||
click.echo(f"Added comment to issue #{issue_number}")
|
||||
|
||||
if ctx.obj.get('verbose'):
|
||||
click.echo(f"\nComment by {added_comment.author.username} at {added_comment.created_at}:")
|
||||
click.echo(added_comment.body)
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to add comment: {e}")
|
||||
117
issue-facade/cli/main.py
Normal file
117
issue-facade/cli/main.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Main CLI Entry Point
|
||||
|
||||
Universal Issue Tracking System CLI
|
||||
"""
|
||||
|
||||
import click
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .commands import issue_group
|
||||
from .backend_commands import backend_group
|
||||
from .sync_commands import sync_group
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option()
|
||||
@click.option('--config', type=click.Path(), help='Configuration file path')
|
||||
@click.option('--backend', help='Backend to use (local, gitea)')
|
||||
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
|
||||
@click.pass_context
|
||||
def cli(ctx, config, backend, verbose):
|
||||
"""
|
||||
Universal Issue Tracking System
|
||||
|
||||
A backend-agnostic issue tracking tool that works with local SQLite,
|
||||
Gitea, GitHub, and other issue tracking systems.
|
||||
|
||||
Examples:
|
||||
issue list # List all issues
|
||||
issue create "Bug in parser" # Create new issue
|
||||
issue show 42 # Show issue #42
|
||||
issue close 42 # Close issue #42
|
||||
|
||||
backend add local ~/.issues # Add local backend
|
||||
backend add gitea myrepo # Add Gitea backend
|
||||
|
||||
sync pull gitea # Sync from Gitea
|
||||
sync push gitea # Sync to Gitea
|
||||
"""
|
||||
# Ensure the object exists
|
||||
ctx.ensure_object(dict)
|
||||
|
||||
# Store global options in context
|
||||
ctx.obj['config_path'] = config
|
||||
ctx.obj['backend'] = backend
|
||||
ctx.obj['verbose'] = verbose
|
||||
|
||||
|
||||
# Register command groups
|
||||
cli.add_command(issue_group, name='issue')
|
||||
cli.add_command(backend_group, name='backend')
|
||||
cli.add_command(sync_group, name='sync')
|
||||
|
||||
|
||||
# Convenience aliases - direct issue commands
|
||||
@cli.command('list')
|
||||
@click.pass_context
|
||||
def list_issues(ctx):
|
||||
"""List all issues (alias for 'issue list')."""
|
||||
ctx.invoke(issue_group.get_command(ctx, 'list'))
|
||||
|
||||
|
||||
@cli.command('show')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.pass_context
|
||||
def show_issue(ctx, issue_number):
|
||||
"""Show issue details (alias for 'issue show')."""
|
||||
ctx.invoke(issue_group.get_command(ctx, 'show'), issue_number=issue_number)
|
||||
|
||||
|
||||
@cli.command('create')
|
||||
@click.argument('title')
|
||||
@click.option('--description', '-d', help='Issue description')
|
||||
@click.option('--label', '-l', multiple=True, help='Labels to add')
|
||||
@click.option('--assignee', '-a', help='Assign to user')
|
||||
@click.option('--milestone', '-m', help='Milestone')
|
||||
@click.pass_context
|
||||
def create_issue(ctx, title, description, label, assignee, milestone):
|
||||
"""Create new issue (alias for 'issue create')."""
|
||||
ctx.invoke(
|
||||
issue_group.get_command(ctx, 'create'),
|
||||
title=title,
|
||||
description=description,
|
||||
label=label,
|
||||
assignee=assignee,
|
||||
milestone=milestone
|
||||
)
|
||||
|
||||
|
||||
@cli.command('close')
|
||||
@click.argument('issue_number', type=int)
|
||||
@click.option('--comment', '-c', help='Closing comment')
|
||||
@click.pass_context
|
||||
def close_issue(ctx, issue_number, comment):
|
||||
"""Close issue (alias for 'issue close')."""
|
||||
ctx.invoke(
|
||||
issue_group.get_command(ctx, 'close'),
|
||||
issue_number=issue_number,
|
||||
comment=comment
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the CLI."""
|
||||
try:
|
||||
cli(obj={})
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nAborted by user", err=True)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
235
issue-facade/cli/sync_commands.py
Normal file
235
issue-facade/cli/sync_commands.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Synchronization CLI Commands
|
||||
|
||||
Commands for synchronizing issues between different backends.
|
||||
"""
|
||||
|
||||
import click
|
||||
from datetime import datetime, timezone
|
||||
from .utils import (
|
||||
load_backend_configs, get_backend, echo_success, echo_error,
|
||||
echo_warning, echo_info, progress_bar, confirm_action
|
||||
)
|
||||
from ..core.interfaces import BackendFactory
|
||||
|
||||
|
||||
@click.group()
|
||||
def sync_group():
|
||||
"""Issue synchronization between backends."""
|
||||
pass
|
||||
|
||||
|
||||
@sync_group.command('status')
|
||||
@click.argument('backend_name', required=False)
|
||||
@click.pass_context
|
||||
def sync_status(ctx, backend_name):
|
||||
"""Show sync status for backends."""
|
||||
configs = load_backend_configs()
|
||||
|
||||
if backend_name:
|
||||
backends_to_check = [backend_name] if backend_name in configs else []
|
||||
if not backends_to_check:
|
||||
echo_error(f"Backend '{backend_name}' not found")
|
||||
return
|
||||
else:
|
||||
backends_to_check = [k for k in configs.keys() if k != 'default']
|
||||
|
||||
for name in backends_to_check:
|
||||
config = configs[name]
|
||||
try:
|
||||
backend = BackendFactory.create_backend(config['type'])
|
||||
backend.connect(config)
|
||||
|
||||
# Get basic stats
|
||||
all_issues = backend.list_issues()
|
||||
open_issues = [i for i in all_issues if i.state.value != 'closed']
|
||||
|
||||
click.echo(f"\n{name} ({config['type']}):")
|
||||
click.echo(f" Total issues: {len(all_issues)}")
|
||||
click.echo(f" Open issues: {len(open_issues)}")
|
||||
click.echo(f" Closed issues: {len(all_issues) - len(open_issues)}")
|
||||
|
||||
# Check last sync
|
||||
if hasattr(backend, 'get_last_sync_timestamp'):
|
||||
last_sync = backend.get_last_sync_timestamp()
|
||||
if last_sync:
|
||||
click.echo(f" Last sync: {last_sync}")
|
||||
else:
|
||||
click.echo(f" Last sync: never")
|
||||
|
||||
backend.disconnect()
|
||||
|
||||
except Exception as e:
|
||||
echo_error(f" Error accessing {name}: {e}")
|
||||
|
||||
|
||||
@sync_group.command('pull')
|
||||
@click.argument('source_backend')
|
||||
@click.option('--target', default='local', help='Target backend (default: local)')
|
||||
@click.option('--dry-run', is_flag=True, help='Show what would be synced without making changes')
|
||||
@click.option('--force', is_flag=True, help='Force sync even with conflicts')
|
||||
@click.pass_context
|
||||
def sync_pull(ctx, source_backend, target, dry_run, force):
|
||||
"""Pull issues from source backend to target backend."""
|
||||
configs = load_backend_configs()
|
||||
|
||||
if source_backend not in configs:
|
||||
echo_error(f"Source backend '{source_backend}' not found")
|
||||
return
|
||||
|
||||
if target not in configs:
|
||||
echo_error(f"Target backend '{target}' not found")
|
||||
return
|
||||
|
||||
if source_backend == target:
|
||||
echo_error("Source and target backends cannot be the same")
|
||||
return
|
||||
|
||||
try:
|
||||
# Connect to backends
|
||||
source_config = configs[source_backend]
|
||||
target_config = configs[target]
|
||||
|
||||
source = BackendFactory.create_backend(source_config['type'])
|
||||
source.connect(source_config)
|
||||
|
||||
target = BackendFactory.create_backend(target_config['type'])
|
||||
target.connect(target_config)
|
||||
|
||||
echo_info(f"Syncing from {source_backend} to {target}")
|
||||
|
||||
# Get issues from source
|
||||
source_issues = source.list_issues()
|
||||
echo_info(f"Found {len(source_issues)} issues in source")
|
||||
|
||||
# Get existing issues in target
|
||||
target_issues = target.list_issues()
|
||||
target_numbers = {issue.number for issue in target_issues}
|
||||
|
||||
# Determine what needs to be synced
|
||||
new_issues = []
|
||||
updated_issues = []
|
||||
|
||||
for issue in source_issues:
|
||||
if issue.number not in target_numbers:
|
||||
new_issues.append(issue)
|
||||
else:
|
||||
# Check if update is needed (simplified check by updated_at)
|
||||
target_issue = next((i for i in target_issues if i.number == issue.number), None)
|
||||
if target_issue and issue.updated_at > target_issue.updated_at:
|
||||
updated_issues.append(issue)
|
||||
|
||||
echo_info(f"New issues to sync: {len(new_issues)}")
|
||||
echo_info(f"Updated issues to sync: {len(updated_issues)}")
|
||||
|
||||
if dry_run:
|
||||
if new_issues:
|
||||
click.echo("\nNew issues:")
|
||||
for issue in new_issues:
|
||||
click.echo(f" #{issue.number}: {issue.title}")
|
||||
|
||||
if updated_issues:
|
||||
click.echo("\nUpdated issues:")
|
||||
for issue in updated_issues:
|
||||
click.echo(f" #{issue.number}: {issue.title}")
|
||||
|
||||
click.echo(f"\nDry run complete. {len(new_issues + updated_issues)} issues would be synced.")
|
||||
return
|
||||
|
||||
# Confirm sync
|
||||
total_sync = len(new_issues) + len(updated_issues)
|
||||
if total_sync == 0:
|
||||
echo_success("No issues need syncing")
|
||||
return
|
||||
|
||||
if not force and not confirm_action(f"Sync {total_sync} issues?"):
|
||||
click.echo("Aborted")
|
||||
return
|
||||
|
||||
# Perform sync
|
||||
synced_count = 0
|
||||
errors = []
|
||||
|
||||
all_to_sync = new_issues + updated_issues
|
||||
with progress_bar(all_to_sync, label="Syncing issues") as items:
|
||||
for issue in items:
|
||||
try:
|
||||
# Clear backend-specific IDs for new backend
|
||||
issue.backend_id = None
|
||||
issue.backend_type = target_config['type']
|
||||
|
||||
if issue in new_issues:
|
||||
target.create_issue(issue)
|
||||
else:
|
||||
# For updates, we need to find the target issue and update it
|
||||
target_issue = target.get_issue_by_number(issue.number)
|
||||
if target_issue:
|
||||
# Copy relevant fields
|
||||
target_issue.title = issue.title
|
||||
target_issue.description = issue.description
|
||||
target_issue.state = issue.state
|
||||
target_issue.labels = issue.labels
|
||||
target_issue.assignees = issue.assignees
|
||||
target_issue.milestone = issue.milestone
|
||||
target_issue.updated_at = issue.updated_at
|
||||
target_issue.closed_at = issue.closed_at
|
||||
target.update_issue(target_issue)
|
||||
|
||||
synced_count += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Issue #{issue.number}: {e}")
|
||||
|
||||
# Report results
|
||||
echo_success(f"Synced {synced_count} issues successfully")
|
||||
|
||||
if errors:
|
||||
echo_warning(f"{len(errors)} errors occurred:")
|
||||
for error in errors[:5]: # Show first 5 errors
|
||||
echo_error(f" {error}")
|
||||
if len(errors) > 5:
|
||||
echo_warning(f" ... and {len(errors) - 5} more errors")
|
||||
|
||||
# Cleanup
|
||||
source.disconnect()
|
||||
target.disconnect()
|
||||
|
||||
except Exception as e:
|
||||
echo_error(f"Sync failed: {e}")
|
||||
|
||||
|
||||
@sync_group.command('push')
|
||||
@click.argument('target_backend')
|
||||
@click.option('--source', default='local', help='Source backend (default: local)')
|
||||
@click.option('--dry-run', is_flag=True, help='Show what would be synced without making changes')
|
||||
@click.option('--force', is_flag=True, help='Force sync even with conflicts')
|
||||
@click.pass_context
|
||||
def sync_push(ctx, target_backend, source, dry_run, force):
|
||||
"""Push issues from source backend to target backend."""
|
||||
# This is essentially the same as pull but with arguments swapped
|
||||
ctx.invoke(sync_pull, source_backend=source, target=target_backend, dry_run=dry_run, force=force)
|
||||
|
||||
|
||||
@sync_group.command('bidirectional')
|
||||
@click.argument('backend1')
|
||||
@click.argument('backend2')
|
||||
@click.option('--dry-run', is_flag=True, help='Show what would be synced without making changes')
|
||||
@click.option('--force', is_flag=True, help='Force sync even with conflicts')
|
||||
@click.pass_context
|
||||
def sync_bidirectional(ctx, backend1, backend2, dry_run, force):
|
||||
"""Bidirectional sync between two backends."""
|
||||
echo_warning("Bidirectional sync is a complex operation that can cause conflicts.")
|
||||
|
||||
if not force and not confirm_action("Continue with bidirectional sync?"):
|
||||
click.echo("Aborted")
|
||||
return
|
||||
|
||||
# First sync backend1 -> backend2
|
||||
echo_info(f"Step 1: Syncing {backend1} -> {backend2}")
|
||||
ctx.invoke(sync_pull, source_backend=backend1, target=backend2, dry_run=dry_run, force=True)
|
||||
|
||||
# Then sync backend2 -> backend1
|
||||
echo_info(f"Step 2: Syncing {backend2} -> {backend1}")
|
||||
ctx.invoke(sync_pull, source_backend=backend2, target=backend1, dry_run=dry_run, force=True)
|
||||
|
||||
echo_success("Bidirectional sync completed")
|
||||
336
issue-facade/cli/utils.py
Normal file
336
issue-facade/cli/utils.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
CLI Utility Functions
|
||||
|
||||
Helper functions for the CLI commands including formatting, configuration,
|
||||
backend management, and user interaction.
|
||||
"""
|
||||
|
||||
import click
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
import tempfile
|
||||
|
||||
from ..core.interfaces import IssueBackend, BackendFactory
|
||||
from ..core.models import Issue, Comment
|
||||
from ..backends.local import LocalSQLiteBackend
|
||||
from ..backends.gitea import GiteaBackend
|
||||
|
||||
|
||||
# Register available backends
|
||||
BackendFactory.register_backend('local', LocalSQLiteBackend)
|
||||
BackendFactory.register_backend('gitea', GiteaBackend)
|
||||
|
||||
|
||||
def get_config_dir() -> Path:
|
||||
"""Get configuration directory."""
|
||||
config_dir = Path.home() / '.config' / 'issue-tracker'
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir
|
||||
|
||||
|
||||
def get_backend_config_path() -> Path:
|
||||
"""Get backend configuration file path."""
|
||||
return get_config_dir() / 'backends.json'
|
||||
|
||||
|
||||
def load_backend_configs() -> dict:
|
||||
"""Load backend configurations."""
|
||||
config_path = get_backend_config_path()
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
|
||||
import json
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_backend_configs(configs: dict) -> None:
|
||||
"""Save backend configurations."""
|
||||
config_path = get_backend_config_path()
|
||||
import json
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(configs, f, indent=2)
|
||||
|
||||
|
||||
def get_default_backend() -> str:
|
||||
"""Get the default backend name."""
|
||||
configs = load_backend_configs()
|
||||
return configs.get('default', 'local')
|
||||
|
||||
|
||||
def get_backend(ctx) -> IssueBackend:
|
||||
"""Get backend instance from context."""
|
||||
backend_name = ctx.obj.get('backend') or get_default_backend()
|
||||
configs = load_backend_configs()
|
||||
|
||||
if backend_name not in configs:
|
||||
if backend_name == 'local':
|
||||
# Auto-configure local backend
|
||||
local_config = {
|
||||
'type': 'local',
|
||||
'db_path': str(get_config_dir() / 'issues.db')
|
||||
}
|
||||
configs['local'] = local_config
|
||||
save_backend_configs(configs)
|
||||
else:
|
||||
raise click.ClickException(f"Backend '{backend_name}' not configured. Use 'backend add' to configure it.")
|
||||
|
||||
backend_config = configs[backend_name]
|
||||
backend_type = backend_config['type']
|
||||
|
||||
try:
|
||||
backend = BackendFactory.create_backend(backend_type)
|
||||
backend.connect(backend_config)
|
||||
return backend
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"Failed to connect to backend '{backend_name}': {e}")
|
||||
|
||||
|
||||
def format_issue_list(issues: List[Issue]) -> str:
|
||||
"""Format list of issues as a table."""
|
||||
if not issues:
|
||||
return "No issues found."
|
||||
|
||||
# Calculate column widths
|
||||
max_title_width = min(50, max(len(issue.title) for issue in issues))
|
||||
max_assignee_width = 15
|
||||
|
||||
# Header
|
||||
lines = []
|
||||
header = f"{'#':<6} {'State':<12} {'Title':<{max_title_width}} {'Assignee':<{max_assignee_width}} Labels"
|
||||
lines.append(header)
|
||||
lines.append("-" * len(header))
|
||||
|
||||
# Issues
|
||||
for issue in issues:
|
||||
title = issue.title[:max_title_width]
|
||||
if len(issue.title) > max_title_width:
|
||||
title = title[:-3] + "..."
|
||||
|
||||
assignee = issue.primary_assignee.username if issue.primary_assignee else "unassigned"
|
||||
assignee = assignee[:max_assignee_width]
|
||||
|
||||
labels = ", ".join(label.name for label in issue.labels[:3])
|
||||
if len(issue.labels) > 3:
|
||||
labels += f" (+{len(issue.labels) - 3})"
|
||||
|
||||
line = f"{issue.number:<6} {issue.state.value:<12} {title:<{max_title_width}} {assignee:<{max_assignee_width}} {labels}"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_issue(issue: Issue, show_comments: bool = False, backend: Optional[IssueBackend] = None) -> str:
|
||||
"""Format a single issue with details."""
|
||||
lines = []
|
||||
|
||||
# Header
|
||||
lines.append(f"#{issue.number}: {issue.title}")
|
||||
lines.append("=" * (len(f"#{issue.number}: {issue.title}")))
|
||||
lines.append("")
|
||||
|
||||
# Basic info
|
||||
lines.append(f"State: {issue.state.value}")
|
||||
lines.append(f"Created: {format_datetime(issue.created_at)}")
|
||||
lines.append(f"Updated: {format_datetime(issue.updated_at)}")
|
||||
|
||||
if issue.closed_at:
|
||||
lines.append(f"Closed: {format_datetime(issue.closed_at)}")
|
||||
|
||||
# Assignees
|
||||
if issue.assignees:
|
||||
assignees_str = ", ".join(assignee.username for assignee in issue.assignees)
|
||||
lines.append(f"Assignees: {assignees_str}")
|
||||
else:
|
||||
lines.append("Assignees: none")
|
||||
|
||||
# Milestone
|
||||
if issue.milestone:
|
||||
lines.append(f"Milestone: {issue.milestone.title}")
|
||||
|
||||
# Labels
|
||||
if issue.labels:
|
||||
labels_by_category = {}
|
||||
for label in issue.labels:
|
||||
category = label.category
|
||||
if category not in labels_by_category:
|
||||
labels_by_category[category] = []
|
||||
labels_by_category[category].append(label.name)
|
||||
|
||||
for category, label_names in labels_by_category.items():
|
||||
lines.append(f"{category.title()} labels: {', '.join(label_names)}")
|
||||
else:
|
||||
lines.append("Labels: none")
|
||||
|
||||
# Description
|
||||
lines.append("")
|
||||
lines.append("Description:")
|
||||
lines.append("-" * 12)
|
||||
if issue.description:
|
||||
lines.append(issue.description)
|
||||
else:
|
||||
lines.append("(no description)")
|
||||
|
||||
# Comments
|
||||
if show_comments and backend:
|
||||
comments = backend.get_comments(issue.id)
|
||||
if comments:
|
||||
lines.append("")
|
||||
lines.append(f"Comments ({len(comments)}):")
|
||||
lines.append("-" * 20)
|
||||
|
||||
for comment in comments:
|
||||
lines.append("")
|
||||
lines.append(f"Comment by {comment.author.username} at {format_datetime(comment.created_at)}:")
|
||||
lines.append(comment.body)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Format datetime for display."""
|
||||
if dt.tzinfo:
|
||||
dt = dt.astimezone()
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def get_user_input(prompt: str, default: str = "", multiline: bool = False) -> str:
|
||||
"""Get user input with optional default and multiline support."""
|
||||
if multiline:
|
||||
click.echo(f"{prompt} (Press Ctrl+D when done, Ctrl+C to cancel):")
|
||||
if default:
|
||||
click.echo(f"Current value:\n{default}\n")
|
||||
|
||||
lines = []
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
line = input()
|
||||
lines.append(line)
|
||||
except EOFError:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
raise click.Abort()
|
||||
|
||||
return "\n".join(lines) if lines else default
|
||||
else:
|
||||
return click.prompt(prompt, default=default)
|
||||
|
||||
|
||||
def validate_backend_type(backend_type: str) -> bool:
|
||||
"""Validate that a backend type is supported."""
|
||||
return backend_type in BackendFactory.get_available_backends()
|
||||
|
||||
|
||||
def test_backend_connection(backend_config: dict) -> bool:
|
||||
"""Test if a backend configuration works."""
|
||||
try:
|
||||
backend_type = backend_config['type']
|
||||
backend = BackendFactory.create_backend(backend_type)
|
||||
backend.connect(backend_config)
|
||||
result = backend.test_connection()
|
||||
backend.disconnect()
|
||||
return result
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def format_backend_list(configs: dict) -> str:
|
||||
"""Format backend configurations for display."""
|
||||
if not configs:
|
||||
return "No backends configured."
|
||||
|
||||
lines = []
|
||||
default_backend = configs.get('default', 'local')
|
||||
|
||||
lines.append(f"{'Name':<15} {'Type':<10} {'Status':<10} Description")
|
||||
lines.append("-" * 60)
|
||||
|
||||
for name, config in configs.items():
|
||||
if name == 'default':
|
||||
continue
|
||||
|
||||
backend_type = config.get('type', 'unknown')
|
||||
is_default = name == default_backend
|
||||
|
||||
# Test connection
|
||||
try:
|
||||
status = "connected" if test_backend_connection(config) else "error"
|
||||
except Exception:
|
||||
status = "error"
|
||||
|
||||
# Description
|
||||
if backend_type == 'local':
|
||||
desc = f"Local SQLite: {config.get('db_path', 'unknown')}"
|
||||
elif backend_type == 'gitea':
|
||||
desc = f"Gitea: {config.get('base_url', 'unknown')}/{config.get('owner', 'unknown')}/{config.get('repo', 'unknown')}"
|
||||
else:
|
||||
desc = f"{backend_type} backend"
|
||||
|
||||
if is_default:
|
||||
desc += " (default)"
|
||||
|
||||
line = f"{name:<15} {backend_type:<10} {status:<10} {desc}"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_editor() -> str:
|
||||
"""Get the user's preferred editor."""
|
||||
return os.environ.get('EDITOR', 'nano')
|
||||
|
||||
|
||||
def edit_text(initial_text: str = "") -> Optional[str]:
|
||||
"""Open text in editor and return edited content."""
|
||||
with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as f:
|
||||
f.write(initial_text)
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
editor = get_editor()
|
||||
os.system(f'{editor} {temp_path}')
|
||||
|
||||
with open(temp_path, 'r') as f:
|
||||
edited_text = f.read()
|
||||
|
||||
return edited_text if edited_text != initial_text else None
|
||||
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
def confirm_action(message: str, default: bool = False) -> bool:
|
||||
"""Ask user for confirmation."""
|
||||
return click.confirm(message, default=default)
|
||||
|
||||
|
||||
def progress_bar(items, label: str = "Processing"):
|
||||
"""Create a progress bar for iterating over items."""
|
||||
return click.progressbar(items, label=label)
|
||||
|
||||
|
||||
def echo_success(message: str) -> None:
|
||||
"""Echo success message in green."""
|
||||
click.echo(click.style(message, fg='green'))
|
||||
|
||||
|
||||
def echo_warning(message: str) -> None:
|
||||
"""Echo warning message in yellow."""
|
||||
click.echo(click.style(message, fg='yellow'))
|
||||
|
||||
|
||||
def echo_error(message: str) -> None:
|
||||
"""Echo error message in red."""
|
||||
click.echo(click.style(message, fg='red'))
|
||||
|
||||
|
||||
def echo_info(message: str) -> None:
|
||||
"""Echo info message in blue."""
|
||||
click.echo(click.style(message, fg='blue'))
|
||||
407
issue-facade/core/interfaces.py
Normal file
407
issue-facade/core/interfaces.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
Backend Plugin Interfaces
|
||||
|
||||
Defines the contracts that all issue tracking backend plugins must implement.
|
||||
This enables a clean plugin architecture where new backends can be added
|
||||
without modifying core code.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Dict, Any, Iterator
|
||||
from datetime import datetime
|
||||
|
||||
from .models import Issue, Label, User, Milestone, Comment
|
||||
|
||||
|
||||
class IssueFilter:
|
||||
"""Filter criteria for issue queries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
milestone: Optional[str] = None,
|
||||
created_after: Optional[datetime] = None,
|
||||
created_before: Optional[datetime] = None,
|
||||
updated_after: Optional[datetime] = None,
|
||||
updated_before: Optional[datetime] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = 0
|
||||
):
|
||||
self.state = state
|
||||
self.assignee = assignee
|
||||
self.labels = labels or []
|
||||
self.milestone = milestone
|
||||
self.created_after = created_after
|
||||
self.created_before = created_before
|
||||
self.updated_after = updated_after
|
||||
self.updated_before = updated_before
|
||||
self.search = search
|
||||
self.limit = limit
|
||||
self.offset = offset
|
||||
|
||||
|
||||
class BackendCapabilities:
|
||||
"""Describes what features a backend supports."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
supports_milestones: bool = True,
|
||||
supports_assignees: bool = True,
|
||||
supports_comments: bool = True,
|
||||
supports_labels: bool = True,
|
||||
supports_search: bool = True,
|
||||
supports_bulk_operations: bool = False,
|
||||
supports_webhooks: bool = False,
|
||||
supports_real_time: bool = False,
|
||||
max_labels_per_issue: Optional[int] = None,
|
||||
max_assignees_per_issue: Optional[int] = None
|
||||
):
|
||||
self.supports_milestones = supports_milestones
|
||||
self.supports_assignees = supports_assignees
|
||||
self.supports_comments = supports_comments
|
||||
self.supports_labels = supports_labels
|
||||
self.supports_search = supports_search
|
||||
self.supports_bulk_operations = supports_bulk_operations
|
||||
self.supports_webhooks = supports_webhooks
|
||||
self.supports_real_time = supports_real_time
|
||||
self.max_labels_per_issue = max_labels_per_issue
|
||||
self.max_assignees_per_issue = max_assignees_per_issue
|
||||
|
||||
|
||||
class IssueBackend(ABC):
|
||||
"""
|
||||
Abstract base class for all issue tracking backends.
|
||||
|
||||
Each backend plugin must implement this interface to provide
|
||||
issue tracking functionality for a specific system.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def backend_type(self) -> str:
|
||||
"""Return the backend type identifier (e.g., 'local', 'gitea', 'github')."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def capabilities(self) -> BackendCapabilities:
|
||||
"""Return the capabilities supported by this backend."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def connect(self, config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Connect to the backend using provided configuration.
|
||||
|
||||
Args:
|
||||
config: Backend-specific configuration (URLs, tokens, etc.)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the backend and clean up resources."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test_connection(self) -> bool:
|
||||
"""Test if the backend connection is working."""
|
||||
pass
|
||||
|
||||
# Issue CRUD Operations
|
||||
@abstractmethod
|
||||
def create_issue(self, issue: Issue) -> Issue:
|
||||
"""
|
||||
Create a new issue in the backend.
|
||||
|
||||
Args:
|
||||
issue: Issue to create (id may be None for new issues)
|
||||
|
||||
Returns:
|
||||
Created issue with backend_id populated
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_issue(self, issue_id: str) -> Optional[Issue]:
|
||||
"""
|
||||
Retrieve an issue by its backend ID.
|
||||
|
||||
Args:
|
||||
issue_id: Backend-specific issue ID
|
||||
|
||||
Returns:
|
||||
Issue if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_issue_by_number(self, number: int) -> Optional[Issue]:
|
||||
"""
|
||||
Retrieve an issue by its human-readable number.
|
||||
|
||||
Args:
|
||||
number: Issue number
|
||||
|
||||
Returns:
|
||||
Issue if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_issue(self, issue: Issue) -> Issue:
|
||||
"""
|
||||
Update an existing issue in the backend.
|
||||
|
||||
Args:
|
||||
issue: Issue with modifications
|
||||
|
||||
Returns:
|
||||
Updated issue
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_issue(self, issue_id: str) -> bool:
|
||||
"""
|
||||
Delete an issue from the backend.
|
||||
|
||||
Args:
|
||||
issue_id: Backend-specific issue ID
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_issues(self, filter_criteria: Optional[IssueFilter] = None) -> List[Issue]:
|
||||
"""
|
||||
List issues matching filter criteria.
|
||||
|
||||
Args:
|
||||
filter_criteria: Optional filter to apply
|
||||
|
||||
Returns:
|
||||
List of matching issues
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_issues(self, query: str, limit: Optional[int] = None) -> List[Issue]:
|
||||
"""
|
||||
Search issues using backend-specific query syntax.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of matching issues
|
||||
"""
|
||||
pass
|
||||
|
||||
# Label Operations
|
||||
@abstractmethod
|
||||
def create_label(self, label: Label) -> Label:
|
||||
"""Create a new label."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_labels(self) -> List[Label]:
|
||||
"""Get all available labels."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_label(self, label: Label) -> Label:
|
||||
"""Update an existing label."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_label(self, label_name: str) -> bool:
|
||||
"""Delete a label."""
|
||||
pass
|
||||
|
||||
# User Operations
|
||||
@abstractmethod
|
||||
def get_users(self) -> List[User]:
|
||||
"""Get all available users."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_user(self, user_id: str) -> Optional[User]:
|
||||
"""Get a specific user by ID."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_users(self, query: str) -> List[User]:
|
||||
"""Search for users."""
|
||||
pass
|
||||
|
||||
# Milestone Operations
|
||||
@abstractmethod
|
||||
def create_milestone(self, milestone: Milestone) -> Milestone:
|
||||
"""Create a new milestone."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_milestones(self) -> List[Milestone]:
|
||||
"""Get all milestones."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_milestone(self, milestone: Milestone) -> Milestone:
|
||||
"""Update a milestone."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_milestone(self, milestone_id: str) -> bool:
|
||||
"""Delete a milestone."""
|
||||
pass
|
||||
|
||||
# Comment Operations
|
||||
@abstractmethod
|
||||
def add_comment(self, issue_id: str, comment: Comment) -> Comment:
|
||||
"""Add a comment to an issue."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_comments(self, issue_id: str) -> List[Comment]:
|
||||
"""Get all comments for an issue."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_comment(self, comment: Comment) -> Comment:
|
||||
"""Update a comment."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_comment(self, comment_id: str) -> bool:
|
||||
"""Delete a comment."""
|
||||
pass
|
||||
|
||||
# Bulk Operations (optional, depends on capabilities)
|
||||
def bulk_update_issues(self, updates: List[Dict[str, Any]]) -> List[Issue]:
|
||||
"""
|
||||
Bulk update multiple issues.
|
||||
|
||||
Args:
|
||||
updates: List of update operations
|
||||
|
||||
Returns:
|
||||
List of updated issues
|
||||
"""
|
||||
if not self.capabilities.supports_bulk_operations:
|
||||
raise NotImplementedError(f"{self.backend_type} backend does not support bulk operations")
|
||||
|
||||
# Default implementation: update one by one
|
||||
results = []
|
||||
for update in updates:
|
||||
issue_id = update['id']
|
||||
issue = self.get_issue(issue_id)
|
||||
if issue:
|
||||
# Apply updates to issue
|
||||
for key, value in update.items():
|
||||
if key != 'id' and hasattr(issue, key):
|
||||
setattr(issue, key, value)
|
||||
updated_issue = self.update_issue(issue)
|
||||
results.append(updated_issue)
|
||||
return results
|
||||
|
||||
# Sync Support
|
||||
def get_last_sync_timestamp(self) -> Optional[datetime]:
|
||||
"""
|
||||
Get the timestamp of the last successful sync.
|
||||
Used for incremental synchronization.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_issues_modified_since(self, timestamp: datetime) -> List[Issue]:
|
||||
"""
|
||||
Get issues modified since a specific timestamp.
|
||||
Used for incremental synchronization.
|
||||
"""
|
||||
filter_criteria = IssueFilter(updated_after=timestamp)
|
||||
return self.list_issues(filter_criteria)
|
||||
|
||||
|
||||
class LocalBackend(IssueBackend):
|
||||
"""
|
||||
Marker interface for local backends.
|
||||
|
||||
Local backends store data locally and can work offline.
|
||||
They serve as the source of truth for synchronization.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class RemoteBackend(IssueBackend):
|
||||
"""
|
||||
Marker interface for remote backends.
|
||||
|
||||
Remote backends connect to external issue tracking systems.
|
||||
They participate in bidirectional synchronization.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SyncableBackend(ABC):
|
||||
"""
|
||||
Interface for backends that support synchronization.
|
||||
|
||||
Backends implementing this interface can participate in
|
||||
bidirectional sync operations.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def prepare_for_sync(self) -> None:
|
||||
"""Prepare backend for sync operation (e.g., create backup)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def finalize_sync(self, success: bool) -> None:
|
||||
"""Finalize sync operation (e.g., commit or rollback)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sync_conflicts(self) -> List[Dict[str, Any]]:
|
||||
"""Get issues that have sync conflicts."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def resolve_sync_conflict(self, issue_id: str, resolution: str) -> Issue:
|
||||
"""
|
||||
Resolve a sync conflict.
|
||||
|
||||
Args:
|
||||
issue_id: Issue with conflict
|
||||
resolution: 'local' or 'remote' or 'merge'
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class BackendFactory:
|
||||
"""Factory for creating backend instances."""
|
||||
|
||||
_backends: Dict[str, type] = {}
|
||||
|
||||
@classmethod
|
||||
def register_backend(cls, backend_type: str, backend_class: type) -> None:
|
||||
"""Register a backend implementation."""
|
||||
cls._backends[backend_type] = backend_class
|
||||
|
||||
@classmethod
|
||||
def create_backend(cls, backend_type: str) -> IssueBackend:
|
||||
"""Create a backend instance."""
|
||||
if backend_type not in cls._backends:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
|
||||
return cls._backends[backend_type]()
|
||||
|
||||
@classmethod
|
||||
def get_available_backends(cls) -> List[str]:
|
||||
"""Get list of available backend types."""
|
||||
return list(cls._backends.keys())
|
||||
341
issue-facade/core/models.py
Normal file
341
issue-facade/core/models.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Core Issue Domain Models
|
||||
|
||||
Unified, backend-agnostic issue models that serve as the single source of truth
|
||||
for all issue tracking operations. These models combine the best features from
|
||||
various issue tracking systems while maintaining clean domain logic.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from functools import cached_property
|
||||
|
||||
|
||||
class IssueState(Enum):
|
||||
"""Universal issue state enumeration with backend mapping support."""
|
||||
OPEN = "open"
|
||||
CLOSED = "closed"
|
||||
IN_PROGRESS = "in_progress"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, state_str: str) -> 'IssueState':
|
||||
"""Convert string to IssueState, with fallback handling."""
|
||||
state_map = {
|
||||
'open': cls.OPEN,
|
||||
'closed': cls.CLOSED,
|
||||
'in_progress': cls.IN_PROGRESS,
|
||||
'in-progress': cls.IN_PROGRESS,
|
||||
'progress': cls.IN_PROGRESS,
|
||||
'blocked': cls.BLOCKED,
|
||||
}
|
||||
return state_map.get(state_str.lower(), cls.OPEN)
|
||||
|
||||
def to_backend_string(self, backend_type: str) -> str:
|
||||
"""Convert to backend-specific string representation."""
|
||||
if backend_type == 'gitea':
|
||||
return 'open' if self in [self.OPEN, self.IN_PROGRESS, self.BLOCKED] else 'closed'
|
||||
elif backend_type == 'github':
|
||||
return self.value
|
||||
else:
|
||||
return self.value
|
||||
|
||||
|
||||
class Priority(Enum):
|
||||
"""Universal priority levels."""
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
@classmethod
|
||||
def from_label(cls, label_name: str) -> Optional['Priority']:
|
||||
"""Extract priority from label name."""
|
||||
if label_name.startswith('priority:'):
|
||||
priority_str = label_name.replace('priority:', '')
|
||||
try:
|
||||
return cls(priority_str)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class IssueType(Enum):
|
||||
"""Universal issue types."""
|
||||
BUG = "bug"
|
||||
FEATURE = "feature"
|
||||
ENHANCEMENT = "enhancement"
|
||||
TASK = "task"
|
||||
DOCUMENTATION = "documentation"
|
||||
QUESTION = "question"
|
||||
|
||||
@classmethod
|
||||
def from_label(cls, label_name: str) -> Optional['IssueType']:
|
||||
"""Extract type from label name."""
|
||||
try:
|
||||
return cls(label_name.lower())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Label:
|
||||
"""Universal label model with backend mapping support."""
|
||||
name: str
|
||||
color: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
backend_id: Optional[str] = None # Backend-specific ID for sync
|
||||
|
||||
@cached_property
|
||||
def category(self) -> str:
|
||||
"""Categorize label for efficient filtering."""
|
||||
if self.name.startswith('priority:'):
|
||||
return 'priority'
|
||||
elif self.name.startswith('status:'):
|
||||
return 'status'
|
||||
elif self.name.startswith('type:'):
|
||||
return 'type'
|
||||
elif self.name in ['bug', 'feature', 'enhancement', 'task', 'documentation', 'question']:
|
||||
return 'type'
|
||||
else:
|
||||
return 'other'
|
||||
|
||||
@cached_property
|
||||
def priority(self) -> Optional[Priority]:
|
||||
"""Extract priority if this is a priority label."""
|
||||
return Priority.from_label(self.name)
|
||||
|
||||
@cached_property
|
||||
def issue_type(self) -> Optional[IssueType]:
|
||||
"""Extract issue type if this is a type label."""
|
||||
return IssueType.from_label(self.name)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LabelCategories:
|
||||
"""Categorized labels for efficient access."""
|
||||
priority_labels: List[Label]
|
||||
type_labels: List[Label]
|
||||
status_labels: List[Label]
|
||||
other_labels: List[Label]
|
||||
|
||||
@cached_property
|
||||
def priority(self) -> Optional[Priority]:
|
||||
"""Get the issue priority."""
|
||||
for label in self.priority_labels:
|
||||
if label.priority:
|
||||
return label.priority
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def issue_type(self) -> Optional[IssueType]:
|
||||
"""Get the issue type."""
|
||||
for label in self.type_labels:
|
||||
if label.issue_type:
|
||||
return label.issue_type
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Universal user model."""
|
||||
id: str # String ID to handle different backend ID types
|
||||
username: str
|
||||
display_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
backend_id: Optional[str] = None # Backend-specific ID for sync
|
||||
|
||||
|
||||
@dataclass
|
||||
class Milestone:
|
||||
"""Universal milestone/project model."""
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
state: str = "open" # open, closed
|
||||
due_date: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
backend_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Comment:
|
||||
"""Universal comment model."""
|
||||
id: str
|
||||
body: str
|
||||
author: User
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
backend_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
"""
|
||||
Universal Issue model - single source of truth.
|
||||
|
||||
Combines the best features from domain and API models while maintaining
|
||||
clean separation between core data and backend-specific details.
|
||||
"""
|
||||
# Core Issue Data
|
||||
id: str # Universal ID (UUID for local, external ID for remotes)
|
||||
number: int # Human-readable number
|
||||
title: str
|
||||
description: str
|
||||
state: IssueState
|
||||
|
||||
# Metadata
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
|
||||
# Relationships
|
||||
labels: List[Label] = field(default_factory=list)
|
||||
assignees: List[User] = field(default_factory=list)
|
||||
milestone: Optional[Milestone] = None
|
||||
comments: List[Comment] = field(default_factory=list)
|
||||
|
||||
# Backend Integration
|
||||
backend_id: Optional[str] = None # Backend-specific ID
|
||||
backend_type: Optional[str] = None # e.g., 'local', 'gitea', 'github'
|
||||
sync_metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Performance Optimization
|
||||
_label_categories: Optional[LabelCategories] = field(default=None, init=False)
|
||||
|
||||
@cached_property
|
||||
def label_categories(self) -> LabelCategories:
|
||||
"""Efficiently categorize labels with caching."""
|
||||
if self._label_categories is None:
|
||||
# Single-pass categorization for performance
|
||||
priority_labels = []
|
||||
type_labels = []
|
||||
status_labels = []
|
||||
other_labels = []
|
||||
|
||||
for label in self.labels:
|
||||
if label.category == 'priority':
|
||||
priority_labels.append(label)
|
||||
elif label.category == 'type':
|
||||
type_labels.append(label)
|
||||
elif label.category == 'status':
|
||||
status_labels.append(label)
|
||||
else:
|
||||
other_labels.append(label)
|
||||
|
||||
self._label_categories = LabelCategories(
|
||||
priority_labels=priority_labels,
|
||||
type_labels=type_labels,
|
||||
status_labels=status_labels,
|
||||
other_labels=other_labels
|
||||
)
|
||||
return self._label_categories
|
||||
|
||||
@property
|
||||
def priority(self) -> Optional[Priority]:
|
||||
"""Get issue priority from labels."""
|
||||
return self.label_categories.priority
|
||||
|
||||
@property
|
||||
def issue_type(self) -> Optional[IssueType]:
|
||||
"""Get issue type from labels."""
|
||||
return self.label_categories.issue_type
|
||||
|
||||
@property
|
||||
def primary_assignee(self) -> Optional[User]:
|
||||
"""Get primary assignee (first one)."""
|
||||
return self.assignees[0] if self.assignees else None
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Invalidate cached properties when labels change."""
|
||||
if hasattr(self, '_label_categories'):
|
||||
object.__setattr__(self, '_label_categories', None)
|
||||
|
||||
# Domain Logic Methods
|
||||
def close(self, closed_at: Optional[datetime] = None) -> None:
|
||||
"""Close the issue with business rule validation."""
|
||||
if self.state == IssueState.CLOSED:
|
||||
raise ValueError(f"Issue #{self.number} is already closed")
|
||||
|
||||
self.state = IssueState.CLOSED
|
||||
self.closed_at = closed_at or datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def reopen(self) -> None:
|
||||
"""Reopen the issue with business rule validation."""
|
||||
if self.state != IssueState.CLOSED:
|
||||
raise ValueError(f"Issue #{self.number} is not closed (current state: {self.state.value})")
|
||||
|
||||
self.state = IssueState.OPEN
|
||||
self.closed_at = None
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def add_label(self, label: Label) -> None:
|
||||
"""Add a label to the issue."""
|
||||
if label not in self.labels:
|
||||
self.labels.append(label)
|
||||
self.invalidate_cache()
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def remove_label(self, label_name: str) -> bool:
|
||||
"""Remove a label by name. Returns True if removed."""
|
||||
original_count = len(self.labels)
|
||||
self.labels = [label for label in self.labels if label.name != label_name]
|
||||
if len(self.labels) < original_count:
|
||||
self.invalidate_cache()
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_label(self, label_name: str) -> bool:
|
||||
"""Check if issue has a specific label."""
|
||||
return any(label.name == label_name for label in self.labels)
|
||||
|
||||
def add_assignee(self, user: User) -> None:
|
||||
"""Add an assignee to the issue."""
|
||||
if user not in self.assignees:
|
||||
self.assignees.append(user)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def remove_assignee(self, user_id: str) -> bool:
|
||||
"""Remove an assignee by ID. Returns True if removed."""
|
||||
original_count = len(self.assignees)
|
||||
self.assignees = [user for user in self.assignees if user.id != user_id]
|
||||
if len(self.assignees) < original_count:
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_comment(self, comment: Comment) -> None:
|
||||
"""Add a comment to the issue."""
|
||||
self.comments.append(comment)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'number': self.number,
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
'state': self.state.value,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'closed_at': self.closed_at.isoformat() if self.closed_at else None,
|
||||
'labels': [{'name': l.name, 'color': l.color, 'description': l.description} for l in self.labels],
|
||||
'assignees': [{'id': u.id, 'username': u.username, 'display_name': u.display_name} for u in self.assignees],
|
||||
'milestone': {'id': self.milestone.id, 'title': self.milestone.title} if self.milestone else None,
|
||||
'backend_id': self.backend_id,
|
||||
'backend_type': self.backend_type,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Issue':
|
||||
"""Create Issue from dictionary."""
|
||||
# This would be implemented with proper parsing
|
||||
# Simplified version for now
|
||||
raise NotImplementedError("from_dict implementation needed")
|
||||
454
issue-facade/core/repository.py
Normal file
454
issue-facade/core/repository.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Repository Pattern Implementation
|
||||
|
||||
Provides a high-level repository interface that abstracts backend operations
|
||||
and adds features like caching, transaction support, and business logic.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from .interfaces import IssueBackend, IssueFilter
|
||||
from .models import Issue, Label, User, Milestone, Comment, IssueState
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IssueRepository:
|
||||
"""
|
||||
High-level repository for issue operations.
|
||||
|
||||
Provides a clean interface for issue management with additional features
|
||||
like caching, validation, and business rule enforcement.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: IssueBackend, enable_caching: bool = True):
|
||||
self.backend = backend
|
||||
self.enable_caching = enable_caching
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._cache_timeout = 300 # 5 minutes
|
||||
|
||||
def _cache_key(self, operation: str, *args) -> str:
|
||||
"""Generate cache key for operation."""
|
||||
return f"{operation}:{':'.join(str(arg) for arg in args)}"
|
||||
|
||||
def _get_from_cache(self, key: str) -> Optional[Any]:
|
||||
"""Get value from cache if enabled and not expired."""
|
||||
if not self.enable_caching or key not in self._cache:
|
||||
return None
|
||||
|
||||
cached_item = self._cache[key]
|
||||
if datetime.now(timezone.utc).timestamp() - cached_item['timestamp'] > self._cache_timeout:
|
||||
del self._cache[key]
|
||||
return None
|
||||
|
||||
return cached_item['value']
|
||||
|
||||
def _set_cache(self, key: str, value: Any) -> None:
|
||||
"""Set value in cache if enabled."""
|
||||
if self.enable_caching:
|
||||
self._cache[key] = {
|
||||
'value': value,
|
||||
'timestamp': datetime.now(timezone.utc).timestamp()
|
||||
}
|
||||
|
||||
def _invalidate_cache_pattern(self, pattern: str) -> None:
|
||||
"""Invalidate cache entries matching pattern."""
|
||||
if not self.enable_caching:
|
||||
return
|
||||
|
||||
keys_to_remove = [key for key in self._cache.keys() if pattern in key]
|
||||
for key in keys_to_remove:
|
||||
del self._cache[key]
|
||||
|
||||
# Issue Operations
|
||||
def create_issue(
|
||||
self,
|
||||
title: str,
|
||||
description: str = "",
|
||||
labels: Optional[List[str]] = None,
|
||||
assignees: Optional[List[str]] = None,
|
||||
milestone: Optional[str] = None,
|
||||
issue_type: Optional[str] = None,
|
||||
priority: Optional[str] = None
|
||||
) -> Issue:
|
||||
"""
|
||||
Create a new issue with business rule validation.
|
||||
|
||||
Args:
|
||||
title: Issue title (required)
|
||||
description: Issue description
|
||||
labels: List of label names
|
||||
assignees: List of usernames to assign
|
||||
milestone: Milestone title or ID
|
||||
issue_type: Issue type (bug, feature, etc.)
|
||||
priority: Priority level (low, medium, high, critical)
|
||||
|
||||
Returns:
|
||||
Created issue
|
||||
"""
|
||||
# Validation
|
||||
if not title.strip():
|
||||
raise ValueError("Issue title cannot be empty")
|
||||
|
||||
# Build labels
|
||||
issue_labels = []
|
||||
if labels:
|
||||
for label_name in labels:
|
||||
issue_labels.append(Label(name=label_name.strip()))
|
||||
|
||||
# Add type and priority as labels
|
||||
if issue_type:
|
||||
issue_labels.append(Label(name=issue_type))
|
||||
|
||||
if priority:
|
||||
issue_labels.append(Label(name=f'priority:{priority}'))
|
||||
|
||||
# Resolve assignees
|
||||
issue_assignees = []
|
||||
if assignees:
|
||||
for username in assignees:
|
||||
users = self.backend.search_users(username)
|
||||
if users:
|
||||
issue_assignees.append(users[0])
|
||||
else:
|
||||
# Create basic user if not found
|
||||
issue_assignees.append(User(id=username, username=username))
|
||||
|
||||
# Resolve milestone
|
||||
issue_milestone = None
|
||||
if milestone:
|
||||
milestones = self.backend.get_milestones()
|
||||
for m in milestones:
|
||||
if m.title == milestone or m.id == milestone:
|
||||
issue_milestone = m
|
||||
break
|
||||
|
||||
# Create issue
|
||||
now = datetime.now(timezone.utc)
|
||||
issue = Issue(
|
||||
id="", # Will be set by backend
|
||||
number=0, # Will be set by backend
|
||||
title=title.strip(),
|
||||
description=description.strip(),
|
||||
state=IssueState.OPEN,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
labels=issue_labels,
|
||||
assignees=issue_assignees,
|
||||
milestone=issue_milestone
|
||||
)
|
||||
|
||||
created_issue = self.backend.create_issue(issue)
|
||||
|
||||
# Invalidate relevant caches
|
||||
self._invalidate_cache_pattern("list_issues")
|
||||
self._invalidate_cache_pattern("search_issues")
|
||||
|
||||
logger.info(f"Created issue #{created_issue.number}: {created_issue.title}")
|
||||
return created_issue
|
||||
|
||||
def get_issue(self, issue_id: Union[str, int]) -> Optional[Issue]:
|
||||
"""Get issue by ID or number."""
|
||||
if isinstance(issue_id, int):
|
||||
return self.get_issue_by_number(issue_id)
|
||||
|
||||
cache_key = self._cache_key("get_issue", issue_id)
|
||||
cached_issue = self._get_from_cache(cache_key)
|
||||
if cached_issue:
|
||||
return cached_issue
|
||||
|
||||
issue = self.backend.get_issue(str(issue_id))
|
||||
if issue:
|
||||
self._set_cache(cache_key, issue)
|
||||
|
||||
return issue
|
||||
|
||||
def get_issue_by_number(self, number: int) -> Optional[Issue]:
|
||||
"""Get issue by number."""
|
||||
cache_key = self._cache_key("get_issue_by_number", number)
|
||||
cached_issue = self._get_from_cache(cache_key)
|
||||
if cached_issue:
|
||||
return cached_issue
|
||||
|
||||
issue = self.backend.get_issue_by_number(number)
|
||||
if issue:
|
||||
self._set_cache(cache_key, issue)
|
||||
|
||||
return issue
|
||||
|
||||
def update_issue(self, issue: Issue) -> Issue:
|
||||
"""Update issue with validation."""
|
||||
if not issue.title.strip():
|
||||
raise ValueError("Issue title cannot be empty")
|
||||
|
||||
issue.updated_at = datetime.now(timezone.utc)
|
||||
updated_issue = self.backend.update_issue(issue)
|
||||
|
||||
# Invalidate caches
|
||||
self._invalidate_cache_pattern("get_issue")
|
||||
self._invalidate_cache_pattern("list_issues")
|
||||
self._invalidate_cache_pattern("search_issues")
|
||||
|
||||
logger.info(f"Updated issue #{updated_issue.number}: {updated_issue.title}")
|
||||
return updated_issue
|
||||
|
||||
def close_issue(self, issue_id: Union[str, int], comment: Optional[str] = None) -> Issue:
|
||||
"""Close issue with optional comment."""
|
||||
issue = self.get_issue(issue_id)
|
||||
if not issue:
|
||||
raise ValueError(f"Issue {issue_id} not found")
|
||||
|
||||
if issue.state == IssueState.CLOSED:
|
||||
raise ValueError(f"Issue #{issue.number} is already closed")
|
||||
|
||||
issue.close()
|
||||
|
||||
# Add closing comment if provided
|
||||
if comment:
|
||||
self.add_comment(issue.id, comment)
|
||||
|
||||
return self.update_issue(issue)
|
||||
|
||||
def reopen_issue(self, issue_id: Union[str, int], comment: Optional[str] = None) -> Issue:
|
||||
"""Reopen issue with optional comment."""
|
||||
issue = self.get_issue(issue_id)
|
||||
if not issue:
|
||||
raise ValueError(f"Issue {issue_id} not found")
|
||||
|
||||
if issue.state != IssueState.CLOSED:
|
||||
raise ValueError(f"Issue #{issue.number} is not closed")
|
||||
|
||||
issue.reopen()
|
||||
|
||||
# Add reopening comment if provided
|
||||
if comment:
|
||||
self.add_comment(issue.id, comment)
|
||||
|
||||
return self.update_issue(issue)
|
||||
|
||||
def list_issues(
|
||||
self,
|
||||
state: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
milestone: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None
|
||||
) -> List[Issue]:
|
||||
"""List issues with filtering and caching."""
|
||||
filter_criteria = IssueFilter(
|
||||
state=state,
|
||||
assignee=assignee,
|
||||
labels=labels,
|
||||
milestone=milestone,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
# Create cache key from filter criteria
|
||||
cache_key = self._cache_key(
|
||||
"list_issues",
|
||||
state or "all",
|
||||
assignee or "any",
|
||||
",".join(labels) if labels else "any",
|
||||
milestone or "any",
|
||||
search or "any",
|
||||
limit or 0,
|
||||
offset or 0
|
||||
)
|
||||
|
||||
cached_issues = self._get_from_cache(cache_key)
|
||||
if cached_issues:
|
||||
return cached_issues
|
||||
|
||||
issues = self.backend.list_issues(filter_criteria)
|
||||
self._set_cache(cache_key, issues)
|
||||
|
||||
return issues
|
||||
|
||||
def search_issues(self, query: str, limit: Optional[int] = None) -> List[Issue]:
|
||||
"""Search issues with caching."""
|
||||
cache_key = self._cache_key("search_issues", query, limit or 0)
|
||||
cached_issues = self._get_from_cache(cache_key)
|
||||
if cached_issues:
|
||||
return cached_issues
|
||||
|
||||
issues = self.backend.search_issues(query, limit)
|
||||
self._set_cache(cache_key, issues)
|
||||
|
||||
return issues
|
||||
|
||||
# Comment Operations
|
||||
def add_comment(self, issue_id: str, comment_text: str, author_username: str = "cli-user") -> Comment:
|
||||
"""Add comment to issue."""
|
||||
if not comment_text.strip():
|
||||
raise ValueError("Comment text cannot be empty")
|
||||
|
||||
# Try to find user
|
||||
users = self.backend.search_users(author_username)
|
||||
if users:
|
||||
author = users[0]
|
||||
else:
|
||||
author = User(id=author_username, username=author_username)
|
||||
|
||||
comment = Comment(
|
||||
id="",
|
||||
body=comment_text.strip(),
|
||||
author=author,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
added_comment = self.backend.add_comment(issue_id, comment)
|
||||
|
||||
# Invalidate issue cache
|
||||
self._invalidate_cache_pattern("get_issue")
|
||||
|
||||
logger.info(f"Added comment to issue {issue_id}")
|
||||
return added_comment
|
||||
|
||||
def get_comments(self, issue_id: str) -> List[Comment]:
|
||||
"""Get comments for issue."""
|
||||
cache_key = self._cache_key("get_comments", issue_id)
|
||||
cached_comments = self._get_from_cache(cache_key)
|
||||
if cached_comments:
|
||||
return cached_comments
|
||||
|
||||
comments = self.backend.get_comments(issue_id)
|
||||
self._set_cache(cache_key, comments)
|
||||
|
||||
return comments
|
||||
|
||||
# Label Operations
|
||||
def get_or_create_label(self, name: str, color: Optional[str] = None, description: Optional[str] = None) -> Label:
|
||||
"""Get existing label or create new one."""
|
||||
existing_labels = self.backend.get_labels()
|
||||
for label in existing_labels:
|
||||
if label.name == name:
|
||||
return label
|
||||
|
||||
# Create new label
|
||||
new_label = Label(name=name, color=color, description=description)
|
||||
return self.backend.create_label(new_label)
|
||||
|
||||
# Statistics and Analytics
|
||||
def get_issue_stats(self) -> Dict[str, Any]:
|
||||
"""Get issue statistics."""
|
||||
cache_key = self._cache_key("get_issue_stats")
|
||||
cached_stats = self._get_from_cache(cache_key)
|
||||
if cached_stats:
|
||||
return cached_stats
|
||||
|
||||
all_issues = self.backend.list_issues()
|
||||
|
||||
stats = {
|
||||
'total': len(all_issues),
|
||||
'open': len([i for i in all_issues if i.state != IssueState.CLOSED]),
|
||||
'closed': len([i for i in all_issues if i.state == IssueState.CLOSED]),
|
||||
'by_state': {},
|
||||
'by_priority': {},
|
||||
'by_type': {},
|
||||
'by_assignee': {},
|
||||
'recent_activity': 0
|
||||
}
|
||||
|
||||
# Count by state
|
||||
for issue in all_issues:
|
||||
state = issue.state.value
|
||||
stats['by_state'][state] = stats['by_state'].get(state, 0) + 1
|
||||
|
||||
# Count by priority and type
|
||||
one_week_ago = datetime.now(timezone.utc).timestamp() - 604800 # 7 days
|
||||
|
||||
for issue in all_issues:
|
||||
# Priority
|
||||
priority = issue.priority
|
||||
if priority:
|
||||
priority_name = priority.value
|
||||
stats['by_priority'][priority_name] = stats['by_priority'].get(priority_name, 0) + 1
|
||||
|
||||
# Type
|
||||
issue_type = issue.issue_type
|
||||
if issue_type:
|
||||
type_name = issue_type.value
|
||||
stats['by_type'][type_name] = stats['by_type'].get(type_name, 0) + 1
|
||||
|
||||
# Assignee
|
||||
if issue.assignees:
|
||||
for assignee in issue.assignees:
|
||||
username = assignee.username
|
||||
stats['by_assignee'][username] = stats['by_assignee'].get(username, 0) + 1
|
||||
|
||||
# Recent activity
|
||||
if issue.updated_at.timestamp() > one_week_ago:
|
||||
stats['recent_activity'] += 1
|
||||
|
||||
self._set_cache(cache_key, stats)
|
||||
return stats
|
||||
|
||||
# Bulk Operations
|
||||
def bulk_close_issues(self, issue_numbers: List[int], comment: Optional[str] = None) -> List[Issue]:
|
||||
"""Close multiple issues."""
|
||||
results = []
|
||||
for number in issue_numbers:
|
||||
try:
|
||||
closed_issue = self.close_issue(number, comment)
|
||||
results.append(closed_issue)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to close issue #{number}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def bulk_add_label(self, issue_numbers: List[int], label_name: str) -> List[Issue]:
|
||||
"""Add label to multiple issues."""
|
||||
label = self.get_or_create_label(label_name)
|
||||
results = []
|
||||
|
||||
for number in issue_numbers:
|
||||
try:
|
||||
issue = self.get_issue_by_number(number)
|
||||
if issue:
|
||||
issue.add_label(label)
|
||||
updated_issue = self.update_issue(issue)
|
||||
results.append(updated_issue)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add label to issue #{number}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
# Cache Management
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cached data."""
|
||||
self._cache.clear()
|
||||
logger.info("Repository cache cleared")
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics."""
|
||||
if not self.enable_caching:
|
||||
return {'enabled': False}
|
||||
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
expired_count = 0
|
||||
for cached_item in self._cache.values():
|
||||
if now - cached_item['timestamp'] > self._cache_timeout:
|
||||
expired_count += 1
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
'total_entries': len(self._cache),
|
||||
'expired_entries': expired_count,
|
||||
'cache_timeout': self._cache_timeout
|
||||
}
|
||||
|
||||
# Context Manager Support
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if hasattr(self.backend, 'disconnect'):
|
||||
self.backend.disconnect()
|
||||
@@ -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