From a8e5b4b0448fc5383e967f90a7a626abacd274f1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 24 Oct 2025 21:25:04 +0200 Subject: [PATCH] refactor: remove obsolete issue management system in favor of issue-facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete cleanup of the legacy TDD AI and issue management system, establishing clear separation of concerns as requested. All issue handling is now provided by the standalone issue-facade system. Removed components: - TDD AI framework (tddai/ directory and tddai_cli.py) - Legacy issue management CLI commands and services - Issue-related Makefile targets and helper commands - Obsolete tests and infrastructure dependencies - Finance modules that depended on the old issue system Updated: - Makefile: Removed issue-*, tdd-*, and test-from-issue commands - CLI framework: Simplified to core functionality only - Documentation: Added deprecation notice for old config system The issue-facade now serves as the universal CLI for issue tracking, providing backend-agnostic interface to GitHub, GitLab, Gitea, and local SQLite storage as documented in issue-facade/README.md. ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CONFIG.md | 4 + Makefile | 260 +----- cli/commands/__init__.py | 8 - cli/commands/export.py | 82 -- cli/commands/issues.py | 134 --- cli/commands/project.py | 88 -- cli/commands/workspace.py | 100 --- cli/core.py | 79 +- cli/issue_cli.py | 180 ---- .../repositories/gitea_repository.py | 646 -------------- markitect/cli.py | 13 - markitect/finance/allocation_engine.py | 566 ------------ markitect/finance/day_wrapup_commands.py | 507 ----------- markitect/issues/__init__.py | 7 - markitect/issues/activity_commands.py | 271 ------ markitect/issues/activity_tracker.py | 417 --------- markitect/issues/base.py | 109 --- markitect/issues/commands.py | 165 ---- markitect/issues/exceptions.py | 18 - markitect/issues/issue_wrapup_commands.py | 600 ------------- markitect/issues/manager.py | 151 ---- markitect/issues/plugins/__init__.py | 5 - markitect/issues/plugins/gitea.py | 102 --- markitect/issues/plugins/local.py | 300 ------- services/issue_service.py | 178 ---- services/project_service.py | 76 -- services/workspace_service.py | 116 --- tddai-setup.sh | 13 - tddai/__init__.py | 37 - tddai/config.py | 171 ---- tddai/coverage_analyzer.py | 430 ---------- tddai/exceptions.py | 28 - tddai/issue_closer.py | 180 ---- tddai/issue_creator.py | 211 ----- tddai/issue_fetcher.py | 92 -- tddai/issue_writer.py | 120 --- tddai/project_manager.py | 244 ------ tddai/test_generator.py | 163 ---- tddai/workspace.py | 238 ------ tddai_cli.py | 342 -------- tests/test_cli_consolidation.py | 411 --------- tests/test_issue_113_activity_tracking.py | 627 -------------- tests/test_issue_114_allocation_engine.py | 809 ------------------ tests/test_issue_123_issue_wrapup.py | 562 ------------ tests/test_issue_124_day_wrapup.py | 621 -------------- tests/test_issue_59_cli_interface.py | 485 ----------- tests/test_issue_59_gitea_plugin.py | 446 ---------- tests/test_issue_59_local_plugin.py | 684 --------------- tests/test_issue_59_plugin_manager.py | 233 ----- tests/test_issue_wrapup_bug_fix.py | 168 ---- tests/test_l1_presentation_cli_interface.py | 349 -------- tests/test_l2_application_tdd_workflows.py | 184 ---- ...est_l2_application_workspace_validation.py | 158 ---- ...test_l2_application_workspace_workflows.py | 156 ---- tests/test_l3_domain_issue_models.py | 287 ------- tests/test_l3_domain_issue_services.py | 368 -------- tests/test_l6_integration_issue_creation.py | 417 --------- tests/test_mock_compatibility.py | 223 ----- 58 files changed, 11 insertions(+), 14628 deletions(-) delete mode 100644 cli/commands/export.py delete mode 100644 cli/commands/issues.py delete mode 100644 cli/commands/project.py delete mode 100644 cli/commands/workspace.py delete mode 100644 cli/issue_cli.py delete mode 100644 infrastructure/repositories/gitea_repository.py delete mode 100644 markitect/finance/allocation_engine.py delete mode 100644 markitect/finance/day_wrapup_commands.py delete mode 100644 markitect/issues/__init__.py delete mode 100644 markitect/issues/activity_commands.py delete mode 100644 markitect/issues/activity_tracker.py delete mode 100644 markitect/issues/base.py delete mode 100644 markitect/issues/commands.py delete mode 100644 markitect/issues/exceptions.py delete mode 100644 markitect/issues/issue_wrapup_commands.py delete mode 100644 markitect/issues/manager.py delete mode 100644 markitect/issues/plugins/__init__.py delete mode 100644 markitect/issues/plugins/gitea.py delete mode 100644 markitect/issues/plugins/local.py delete mode 100644 services/issue_service.py delete mode 100644 services/project_service.py delete mode 100644 services/workspace_service.py delete mode 100644 tddai-setup.sh delete mode 100644 tddai/__init__.py delete mode 100644 tddai/config.py delete mode 100644 tddai/coverage_analyzer.py delete mode 100644 tddai/exceptions.py delete mode 100644 tddai/issue_closer.py delete mode 100644 tddai/issue_creator.py delete mode 100644 tddai/issue_fetcher.py delete mode 100644 tddai/issue_writer.py delete mode 100644 tddai/project_manager.py delete mode 100644 tddai/test_generator.py delete mode 100644 tddai/workspace.py delete mode 100644 tddai_cli.py delete mode 100644 tests/test_cli_consolidation.py delete mode 100644 tests/test_issue_113_activity_tracking.py delete mode 100644 tests/test_issue_114_allocation_engine.py delete mode 100644 tests/test_issue_123_issue_wrapup.py delete mode 100644 tests/test_issue_124_day_wrapup.py delete mode 100644 tests/test_issue_59_cli_interface.py delete mode 100644 tests/test_issue_59_gitea_plugin.py delete mode 100644 tests/test_issue_59_local_plugin.py delete mode 100644 tests/test_issue_59_plugin_manager.py delete mode 100644 tests/test_issue_wrapup_bug_fix.py delete mode 100644 tests/test_l1_presentation_cli_interface.py delete mode 100644 tests/test_l2_application_tdd_workflows.py delete mode 100644 tests/test_l2_application_workspace_validation.py delete mode 100644 tests/test_l2_application_workspace_workflows.py delete mode 100644 tests/test_l3_domain_issue_models.py delete mode 100644 tests/test_l3_domain_issue_services.py delete mode 100644 tests/test_l6_integration_issue_creation.py delete mode 100644 tests/test_mock_compatibility.py diff --git a/CONFIG.md b/CONFIG.md index 4452e113..1b8928f5 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -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 diff --git a/Makefile b/Makefile index 5df7d1d1..fc55dd01 100644 --- a/Makefile +++ b/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" diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py index fe39a4f3..384378d6 100644 --- a/cli/commands/__init__.py +++ b/cli/commands/__init__.py @@ -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' ] \ No newline at end of file diff --git a/cli/commands/export.py b/cli/commands/export.py deleted file mode 100644 index bf28b168..00000000 --- a/cli/commands/export.py +++ /dev/null @@ -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)) \ No newline at end of file diff --git a/cli/commands/issues.py b/cli/commands/issues.py deleted file mode 100644 index 8b753bfc..00000000 --- a/cli/commands/issues.py +++ /dev/null @@ -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)) \ No newline at end of file diff --git a/cli/commands/project.py b/cli/commands/project.py deleted file mode 100644 index 50dcc26b..00000000 --- a/cli/commands/project.py +++ /dev/null @@ -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}") \ No newline at end of file diff --git a/cli/commands/workspace.py b/cli/commands/workspace.py deleted file mode 100644 index 00cb9e79..00000000 --- a/cli/commands/workspace.py +++ /dev/null @@ -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}_.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)) \ No newline at end of file diff --git a/cli/core.py b/cli/core.py index 777fa4e8..d97c16fe 100644 --- a/cli/core.py +++ b/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) diff --git a/cli/issue_cli.py b/cli/issue_cli.py deleted file mode 100644 index cec3f230..00000000 --- a/cli/issue_cli.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/infrastructure/repositories/gitea_repository.py b/infrastructure/repositories/gitea_repository.py deleted file mode 100644 index 23c9f1c6..00000000 --- a/infrastructure/repositories/gitea_repository.py +++ /dev/null @@ -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 - ) \ No newline at end of file diff --git a/markitect/cli.py b/markitect/cli.py index a68b2167..e7a4d033 100644 --- a/markitect/cli.py +++ b/markitect/cli.py @@ -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 diff --git a/markitect/finance/allocation_engine.py b/markitect/finance/allocation_engine.py deleted file mode 100644 index 450470ad..00000000 --- a/markitect/finance/allocation_engine.py +++ /dev/null @@ -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)) \ No newline at end of file diff --git a/markitect/finance/day_wrapup_commands.py b/markitect/finance/day_wrapup_commands.py deleted file mode 100644 index 124ca1d7..00000000 --- a/markitect/finance/day_wrapup_commands.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/markitect/issues/__init__.py b/markitect/issues/__init__.py deleted file mode 100644 index 1a997d8d..00000000 --- a/markitect/issues/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Issue management module for MarkiTect. - -Provides unified CLI interface for issue management with pluggable backend support. -""" - -__version__ = "1.0.0" \ No newline at end of file diff --git a/markitect/issues/activity_commands.py b/markitect/issues/activity_commands.py deleted file mode 100644 index 5061d858..00000000 --- a/markitect/issues/activity_commands.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/markitect/issues/activity_tracker.py b/markitect/issues/activity_tracker.py deleted file mode 100644 index 8d580e16..00000000 --- a/markitect/issues/activity_tracker.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/markitect/issues/base.py b/markitect/issues/base.py deleted file mode 100644 index a6110ca6..00000000 --- a/markitect/issues/base.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/markitect/issues/commands.py b/markitect/issues/commands.py deleted file mode 100644 index 02f2e71d..00000000 --- a/markitect/issues/commands.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/markitect/issues/exceptions.py b/markitect/issues/exceptions.py deleted file mode 100644 index a7794dfb..00000000 --- a/markitect/issues/exceptions.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/markitect/issues/issue_wrapup_commands.py b/markitect/issues/issue_wrapup_commands.py deleted file mode 100644 index 68ce8b07..00000000 --- a/markitect/issues/issue_wrapup_commands.py +++ /dev/null @@ -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 - - -""" - 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 """ - - # 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() \ No newline at end of file diff --git a/markitect/issues/manager.py b/markitect/issues/manager.py deleted file mode 100644 index b58cb118..00000000 --- a/markitect/issues/manager.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/markitect/issues/plugins/__init__.py b/markitect/issues/plugins/__init__.py deleted file mode 100644 index 2f870f9f..00000000 --- a/markitect/issues/plugins/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Issue management plugins. - -This package contains backend implementations for different issue management systems. -""" \ No newline at end of file diff --git a/markitect/issues/plugins/gitea.py b/markitect/issues/plugins/gitea.py deleted file mode 100644 index a489a6b5..00000000 --- a/markitect/issues/plugins/gitea.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/markitect/issues/plugins/local.py b/markitect/issues/plugins/local.py deleted file mode 100644 index 1bd0ddff..00000000 --- a/markitect/issues/plugins/local.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/services/issue_service.py b/services/issue_service.py deleted file mode 100644 index 82c30b68..00000000 --- a/services/issue_service.py +++ /dev/null @@ -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 - } \ No newline at end of file diff --git a/services/project_service.py b/services/project_service.py deleted file mode 100644 index 6cc9f9a8..00000000 --- a/services/project_service.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/services/workspace_service.py b/services/workspace_service.py deleted file mode 100644 index 0d5f118d..00000000 --- a/services/workspace_service.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tddai-setup.sh b/tddai-setup.sh deleted file mode 100644 index 3839285b..00000000 --- a/tddai-setup.sh +++ /dev/null @@ -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" \ No newline at end of file diff --git a/tddai/__init__.py b/tddai/__init__.py deleted file mode 100644 index 3dab4e1e..00000000 --- a/tddai/__init__.py +++ /dev/null @@ -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", -] \ No newline at end of file diff --git a/tddai/config.py b/tddai/config.py deleted file mode 100644 index c48caf7b..00000000 --- a/tddai/config.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tddai/coverage_analyzer.py b/tddai/coverage_analyzer.py deleted file mode 100644 index cb47d3fd..00000000 --- a/tddai/coverage_analyzer.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tddai/exceptions.py b/tddai/exceptions.py deleted file mode 100644 index e2d377ea..00000000 --- a/tddai/exceptions.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tddai/issue_closer.py b/tddai/issue_closer.py deleted file mode 100644 index 77de23dc..00000000 --- a/tddai/issue_closer.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/tddai/issue_creator.py b/tddai/issue_creator.py deleted file mode 100644 index b9837c50..00000000 --- a/tddai/issue_creator.py +++ /dev/null @@ -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}") \ No newline at end of file diff --git a/tddai/issue_fetcher.py b/tddai/issue_fetcher.py deleted file mode 100644 index d66467c5..00000000 --- a/tddai/issue_fetcher.py +++ /dev/null @@ -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] - } \ No newline at end of file diff --git a/tddai/issue_writer.py b/tddai/issue_writer.py deleted file mode 100644 index 33d4b252..00000000 --- a/tddai/issue_writer.py +++ /dev/null @@ -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}") \ No newline at end of file diff --git a/tddai/project_manager.py b/tddai/project_manager.py deleted file mode 100644 index 9049df90..00000000 --- a/tddai/project_manager.py +++ /dev/null @@ -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 - } \ No newline at end of file diff --git a/tddai/test_generator.py b/tddai/test_generator.py deleted file mode 100644 index 1f77d030..00000000 --- a/tddai/test_generator.py +++ /dev/null @@ -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")) \ No newline at end of file diff --git a/tddai/workspace.py b/tddai/workspace.py deleted file mode 100644 index 44098fbb..00000000 --- a/tddai/workspace.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/tddai_cli.py b/tddai_cli.py deleted file mode 100644 index c037a23c..00000000 --- a/tddai_cli.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/tests/test_cli_consolidation.py b/tests/test_cli_consolidation.py deleted file mode 100644 index 90f22123..00000000 --- a/tests/test_cli_consolidation.py +++ /dev/null @@ -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"]) \ No newline at end of file diff --git a/tests/test_issue_113_activity_tracking.py b/tests/test_issue_113_activity_tracking.py deleted file mode 100644 index f5c07a06..00000000 --- a/tests/test_issue_113_activity_tracking.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/test_issue_114_allocation_engine.py b/tests/test_issue_114_allocation_engine.py deleted file mode 100644 index e49511ca..00000000 --- a/tests/test_issue_114_allocation_engine.py +++ /dev/null @@ -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__]) \ No newline at end of file diff --git a/tests/test_issue_123_issue_wrapup.py b/tests/test_issue_123_issue_wrapup.py deleted file mode 100644 index 7c591526..00000000 --- a/tests/test_issue_123_issue_wrapup.py +++ /dev/null @@ -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__]) \ No newline at end of file diff --git a/tests/test_issue_124_day_wrapup.py b/tests/test_issue_124_day_wrapup.py deleted file mode 100644 index ef3c8e74..00000000 --- a/tests/test_issue_124_day_wrapup.py +++ /dev/null @@ -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']) \ No newline at end of file diff --git a/tests/test_issue_59_cli_interface.py b/tests/test_issue_59_cli_interface.py deleted file mode 100644 index 7bca8c17..00000000 --- a/tests/test_issue_59_cli_interface.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/test_issue_59_gitea_plugin.py b/tests/test_issue_59_gitea_plugin.py deleted file mode 100644 index 7d845429..00000000 --- a/tests/test_issue_59_gitea_plugin.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/test_issue_59_local_plugin.py b/tests/test_issue_59_local_plugin.py deleted file mode 100644 index 93fe1e7b..00000000 --- a/tests/test_issue_59_local_plugin.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/test_issue_59_plugin_manager.py b/tests/test_issue_59_plugin_manager.py deleted file mode 100644 index 5d97bf23..00000000 --- a/tests/test_issue_59_plugin_manager.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/test_issue_wrapup_bug_fix.py b/tests/test_issue_wrapup_bug_fix.py deleted file mode 100644 index 168634e4..00000000 --- a/tests/test_issue_wrapup_bug_fix.py +++ /dev/null @@ -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__]) \ No newline at end of file diff --git a/tests/test_l1_presentation_cli_interface.py b/tests/test_l1_presentation_cli_interface.py deleted file mode 100644 index 9dc23cec..00000000 --- a/tests/test_l1_presentation_cli_interface.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/test_l2_application_tdd_workflows.py b/tests/test_l2_application_tdd_workflows.py deleted file mode 100644 index 7a781f6b..00000000 --- a/tests/test_l2_application_tdd_workflows.py +++ /dev/null @@ -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 - - diff --git a/tests/test_l2_application_workspace_validation.py b/tests/test_l2_application_workspace_validation.py deleted file mode 100644 index 40a4d4a8..00000000 --- a/tests/test_l2_application_workspace_validation.py +++ /dev/null @@ -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() - - diff --git a/tests/test_l2_application_workspace_workflows.py b/tests/test_l2_application_workspace_workflows.py deleted file mode 100644 index 73a4a334..00000000 --- a/tests/test_l2_application_workspace_workflows.py +++ /dev/null @@ -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" \ No newline at end of file diff --git a/tests/test_l3_domain_issue_models.py b/tests/test_l3_domain_issue_models.py deleted file mode 100644 index 39339d62..00000000 --- a/tests/test_l3_domain_issue_models.py +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/tests/test_l3_domain_issue_services.py b/tests/test_l3_domain_issue_services.py deleted file mode 100644 index 8d315da6..00000000 --- a/tests/test_l3_domain_issue_services.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/tests/test_l6_integration_issue_creation.py b/tests/test_l6_integration_issue_creation.py deleted file mode 100644 index 5aca6c20..00000000 --- a/tests/test_l6_integration_issue_creation.py +++ /dev/null @@ -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") \ No newline at end of file diff --git a/tests/test_mock_compatibility.py b/tests/test_mock_compatibility.py deleted file mode 100644 index 54be3cd2..00000000 --- a/tests/test_mock_compatibility.py +++ /dev/null @@ -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']) \ No newline at end of file