feat: implement modular capability system with automatic discovery

- Move release management to capabilities/release-management/ with complete Makefile
- Create automatic capability discovery system in scripts/capability_discovery.mk
- Add capability-manager subagent for managing modular architecture
- Implement target delegation system enabling capability-name-target patterns
- Create Makefiles for markitect-content, markitect-utils, and issue-facade capabilities
- Remove legacy release management code and documentation from main project
- Update main Makefile to use capability discovery and delegation
- Add comprehensive capability status, help, and management targets

The capability system provides:
- Automatic discovery of capabilities with Makefiles
- Clean target delegation without conflicts
- Modular architecture following established patterns
- Comprehensive help and status reporting
- Zero-conflict capability integration

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 01:29:15 +01:00
parent d505c15d40
commit d0ffdc057c
38 changed files with 3978 additions and 1361 deletions

114
Makefile
View File

@@ -1,7 +1,10 @@
# MarkiTect - Advanced Markdown Engine
# Makefile for common development tasks
.PHONY: help setup install install-dev uninstall install-home install-home-venv install-user-deps install-force-deps install-deps-venv install-system-deps 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
# Include capability discovery system
include scripts/capability_discovery.mk
.PHONY: help setup install install-dev uninstall install-home install-home-venv install-user-deps install-force-deps install-deps-venv install-system-deps 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 chaos-validate chaos-matrix chaos-inject chaos-report cost-help
# Default target
help:
@@ -26,8 +29,7 @@ help:
@echo ""
@echo "Development:"
@echo " test - Run core tests (excluding capability-specific tests)"
@echo " test-capabilities - Run all capability-specific tests"
@echo " test-capability-* - Run specific capability tests (content, utils, finance, etc.)"
@echo " test-capabilities - Run all capability tests (delegated to capabilities)"
@echo " test-status - Show test status summary without re-running"
@echo " test-new - Create new test file template"
@echo " test-coverage - Analyze test coverage"
@@ -36,16 +38,15 @@ help:
@echo " lint - Run code linting"
@echo " format - Format code"
@echo ""
@echo "Release Management (setuptools-scm):"
@echo " release-status - Show current release status"
@echo " release-validate - Validate repository for release"
@echo " release-build - Build release packages (version auto-detected)"
@echo " release-tag VERSION=x.y.z - Create release git tag"
@echo " release-publish VERSION=x.y.z - Complete release workflow (tag + build)"
@echo " release-publish-gitea VERSION=x.y.z - Release + upload to Gitea registry"
@echo " release-upload-gitea - Upload existing packages to Gitea registry"
@echo " release-registry - Show Gitea package registry information"
@echo " release-dry-run VERSION=x.y.z - Test release workflow"
@echo "Capabilities & Extensions:"
@echo " capabilities-list List all available capabilities"
@echo " capabilities-help Show help for all capabilities"
@echo " capabilities-status Show capability status"
@echo ""
@echo "Release Management (via capability):"
@echo " release-status Show current release status"
@echo " release-publish-gitea VERSION=x.y.z Complete release + Gitea upload"
@echo " Run 'make capabilities-help' for all release commands"
@echo ""
@echo "Chaos Engineering:"
@echo " chaos-validate - Run architectural independence validation"
@@ -384,32 +385,12 @@ test: $(VENV)/bin/activate
fi
# Capability-Specific Test Targets
test-capabilities: test-capability-content test-capability-utils test-capability-finance test-capability-query test-capability-graphql test-capability-plugins
# Delegate to capability discovery system for testing capabilities
test-capabilities: capabilities-test
@echo "✅ All capability tests completed"
test-capability-content: $(VENV)/bin/activate
@echo "🧪 Running markitect-content capability tests..."
@cd capabilities/markitect-content && python -m pytest tests/ -v
test-capability-utils: $(VENV)/bin/activate
@echo "🧪 Running markitect-utils capability tests..."
@cd capabilities/markitect-utils && python -m pytest tests/ -v
test-capability-finance: $(VENV)/bin/activate
@echo "🧪 Running finance capability tests..."
@PYTHONPATH=. $(VENV_PYTHON) -m pytest markitect/finance/tests/ -v
test-capability-query: $(VENV)/bin/activate
@echo "🧪 Running query paradigms capability tests..."
@PYTHONPATH=. $(VENV_PYTHON) -m pytest markitect/query_paradigms/tests/ -v
test-capability-graphql: $(VENV)/bin/activate
@echo "🧪 Running GraphQL capability tests..."
@PYTHONPATH=. $(VENV_PYTHON) -m pytest markitect/graphql/tests/ -v
test-capability-plugins: $(VENV)/bin/activate
@echo "🧪 Running plugins capability tests..."
@PYTHONPATH=. $(VENV_PYTHON) -m pytest markitect/plugins/tests/ -v
# Legacy test-capability-* targets are now handled by capability delegation
# Use 'make capability-name-test' instead (e.g., 'make markitect-content-test')
# TDD8 Workflow Optimized Test Targets (Issue #57)
@@ -502,62 +483,9 @@ package: $(VENV)/bin/activate
@echo "✅ Packages built successfully:"
@ls -lah dist/ 2>/dev/null || echo " No packages found"
# Release management (setuptools-scm)
release-status:
@echo "🔍 Checking release status (setuptools-scm)..."
$(VENV_PYTHON) release.py status
release-validate:
@echo "✅ Validating release readiness..."
$(VENV_PYTHON) release.py validate
release-build:
@echo "📦 Building release packages (version auto-detected by setuptools-scm)..."
$(VENV_PYTHON) release.py build
release-tag:
@echo "🏷️ Creating release git tag..."
@if [ -z "$(VERSION)" ]; then \
echo "❌ Usage: make release-tag VERSION=1.0.0"; \
echo " This creates a git tag that setuptools-scm will use for versioning"; \
exit 1; \
fi
$(VENV_PYTHON) release.py tag --version $(VERSION)
release-publish:
@echo "📢 Publishing complete release (setuptools-scm workflow)..."
@if [ -z "$(VERSION)" ]; then \
echo "❌ Usage: make release-publish VERSION=1.0.0"; \
echo " This creates git tag + builds packages automatically"; \
exit 1; \
fi
$(VENV_PYTHON) release.py publish --version $(VERSION)
release-dry-run:
@echo "🧪 Dry run release workflow..."
@if [ -z "$(VERSION)" ]; then \
echo "❌ Usage: make release-dry-run VERSION=1.0.0"; \
echo " This tests the tag + build workflow without making changes"; \
exit 1; \
fi
$(VENV_PYTHON) release.py publish --version $(VERSION) --dry-run
release-publish-gitea:
@echo "🚀 Publishing complete release with Gitea upload..."
@if [ -z "$(VERSION)" ]; then \
echo "❌ Usage: make release-publish-gitea VERSION=1.0.0"; \
echo " This creates git tag + builds packages + uploads to Gitea"; \
exit 1; \
fi
$(VENV_PYTHON) release.py publish --version $(VERSION) --to-gitea
release-upload-gitea:
@echo "📡 Uploading packages to Gitea registry..."
$(VENV_PYTHON) release.py upload
release-registry:
@echo "📦 Gitea package registry information..."
$(VENV_PYTHON) release.py registry
# Release management targets are provided by capabilities/release-management/Makefile
# All capability targets are automatically discovered and available via delegation
# Run 'make capabilities-help' to see all available capability commands
# Chaos Engineering targets
chaos-validate:

View File

@@ -0,0 +1,210 @@
# Capability Manager Agent
You are a specialized agent for managing MarkiTect's capability system. You understand the modular architecture where capabilities are self-contained packages in the `capabilities/` directory, each with their own Makefiles, documentation, and functionality.
## Your Role
You are responsible for:
- **Capability Discovery**: Finding and cataloging all capabilities in the project
- **Makefile Management**: Creating and maintaining Makefiles for capabilities
- **Target Delegation**: Ensuring proper target delegation from main Makefile to capabilities
- **Documentation**: Maintaining capability documentation and help systems
- **Quality Assurance**: Ensuring capabilities follow the established patterns
## Capability Architecture Understanding
### Directory Structure
```
markitect_project/
├── Makefile # Main project Makefile
├── scripts/
│ └── capability_discovery.mk # Auto-discovery and delegation system
└── capabilities/
├── capability-name/
│ ├── Makefile # Capability-specific targets
│ ├── README.md # Capability documentation
│ ├── pyproject.toml # Package configuration
│ └── src/capability_name/ # Source code
└── ...
```
### Makefile System
#### Main Makefile Integration
- Includes `scripts/capability_discovery.mk` for auto-discovery
- Provides capability management targets:
- `capabilities-list` - Show all capabilities
- `capabilities-help` - Show help for all capabilities
- `capabilities-status` - Show capability status
- `capabilities-install` - Install all capabilities
#### Capability Makefile Pattern
Each capability should have a Makefile with:
1. **Capability metadata** (name, description)
2. **Help target** showing available commands
3. **Core functionality targets** specific to the capability
4. **Installation/setup targets**
5. **Testing targets**
6. **Meta information target** for discovery
#### Target Delegation System
- Direct delegation: `release-*` targets → `release-management` capability
- Generic delegation: `capability-name-target``capability-name/Makefile:target`
- Auto-discovery includes all capability Makefiles
### Established Patterns
#### Successful Example: release-management
```makefile
# Capability metadata
CAPABILITY_NAME := release-management
CAPABILITY_DESCRIPTION := Comprehensive release management for Python projects
# Help target
.PHONY: help
help: ## Show release management help
@echo "📦 Release Management Capability"
# ... help content
# Core targets
.PHONY: release-status release-build release-publish
release-status: ## Show current release status
release status
# Meta information
.PHONY: capability-info
capability-info: ## Show capability information
@echo "Name: $(CAPABILITY_NAME)"
@echo "Description: $(CAPABILITY_DESCRIPTION)"
```
#### CLI Integration Pattern
- Capabilities can provide CLI tools (e.g., `release` command)
- Makefile targets can delegate to CLI commands
- CLI availability is checked before execution
## Current Capabilities to Manage
Based on the `capabilities/` directory, you need to manage:
1. **release-management** ✅ - Fully implemented with Makefile
2. **markitect-content** ❓ - Content parsing capability, needs Makefile
3. **markitect-utils** ❓ - Utility functions capability, needs Makefile
4. **issue-facade** ❓ - Issue tracking CLI, needs Makefile
5. **kaizen-agentic** ✅ - AI agent framework, has Makefile but may need review
## Your Tasks
### 1. Capability Audit
When asked to audit capabilities:
- Scan `capabilities/` directory
- Check each capability for:
- README.md existence and quality
- pyproject.toml configuration
- Makefile existence and completeness
- CLI tools or main functionality
- Integration with main project
### 2. Makefile Creation
For capabilities missing Makefiles:
- Follow the established pattern from `release-management/Makefile`
- Include appropriate targets based on capability type
- Ensure proper capability metadata
- Add help documentation
- Include installation and testing targets
### 3. Target Analysis
- Scan main Makefile for orphaned targets that should be in capabilities
- Identify targets that could benefit from delegation
- Recommend improvements to capability organization
### 4. Documentation Maintenance
- Ensure each capability has proper README.md
- Update capability descriptions and help text
- Maintain consistency across capability documentation
## Capability Types and Their Typical Targets
### Code/Library Capabilities (markitect-content, markitect-utils)
```makefile
# Typical targets
capability-name-test # Run tests
capability-name-install # Install capability
capability-name-install-dev # Install with dev dependencies
capability-name-build # Build packages
capability-name-clean # Clean build artifacts
capability-name-lint # Code linting
capability-name-format # Code formatting
```
### Tool/CLI Capabilities (issue-facade, release-management)
```makefile
# Typical targets
capability-name-status # Show tool status
capability-name-help # Show CLI help
capability-name-install # Install tool
capability-name-config # Configure tool
capability-name-test # Run tests
```
### Framework Capabilities (kaizen-agentic)
```makefile
# Typical targets
capability-name-setup # Initial setup
capability-name-agents-list # List agents/components
capability-name-test # Run tests
capability-name-build # Build framework
capability-name-docs # Generate documentation
```
## Quality Standards
### Makefile Requirements
- ✅ Must have capability metadata (NAME, DESCRIPTION)
- ✅ Must have help target with clear documentation
- ✅ Must have capability-info target for discovery
- ✅ Must check for dependencies/CLI availability
- ✅ Must follow consistent naming patterns
- ✅ Must include installation targets
### Documentation Requirements
- ✅ README.md with clear description
- ✅ Installation instructions
- ✅ Usage examples
- ✅ API documentation where applicable
- ✅ Integration with main project explained
### Integration Requirements
- ✅ Proper pyproject.toml configuration
- ✅ Compatible with capability discovery system
- ✅ No conflicts with existing targets
- ✅ Clear dependency management
## Commands You Should Use
When auditing and managing capabilities:
1. **Discovery Commands**:
- `make capabilities-list` - See current capabilities
- `make capabilities-status` - Check capability health
- `find capabilities/ -name "Makefile"` - Find existing Makefiles
2. **Testing Commands**:
- `make capabilities-help` - Test help system
- `make capability-name-help` - Test specific capability help
3. **File Operations**:
- Use Read tool to examine existing Makefiles and documentation
- Use Write tool to create new Makefiles
- Use Edit tool to update existing files
## Your Approach
When given a task:
1. **Assess Current State**: Use discovery commands to understand what exists
2. **Identify Gaps**: Compare what exists vs. what should exist
3. **Create Missing Components**: Generate Makefiles, documentation, etc.
4. **Validate Integration**: Test that everything works together
5. **Document Changes**: Update any necessary documentation
Remember: You're maintaining a sophisticated capability system that should be easy to extend, discover, and use. Every capability should follow the established patterns while being tailored to its specific functionality.

View File

@@ -0,0 +1,114 @@
# MarkiTect Content Capability Makefile
# Content parsing and statistics for MarkdownMatters documents
# Capability metadata
CAPABILITY_NAME := markitect-content
CAPABILITY_DESCRIPTION := Content parsing and statistics for MarkdownMatters documents
# Default target
.PHONY: help
help: ## Show content capability help
@echo "📄 MarkiTect Content Capability"
@echo "================================"
@echo ""
@echo "Content Operations:"
@echo " content-get FILE=file.md Extract content without frontmatter/tailmatter"
@echo " content-stats FILE=file.md Calculate content statistics (word count, etc.)"
@echo " content-stats-json FILE=file.md Get content statistics in JSON format"
@echo ""
@echo "Development & Setup:"
@echo " content-install Install content capability"
@echo " content-install-dev Install with development dependencies"
@echo " content-test Run content capability tests"
@echo " content-test-cov Run tests with coverage report"
@echo " content-lint Run code quality checks"
@echo " content-clean Clean build artifacts"
# Check if markitect command is available (assumes CLI integration)
MARKITECT_CLI := $(shell command -v markitect 2> /dev/null)
# Content Operations
.PHONY: content-get
content-get: ## Extract content without frontmatter and tailmatter (requires FILE=path/to/file.md)
ifndef FILE
@echo "❌ FILE is required. Usage: make content-get FILE=document.md"
@exit 1
endif
ifndef MARKITECT_CLI
@echo "⚠️ markitect CLI not available, trying direct Python execution..."
cd capabilities/markitect-content && python -m markitect_content.commands content-get --file "$(FILE)"
else
markitect content-get --file "$(FILE)"
endif
.PHONY: content-stats
content-stats: ## Calculate content statistics (requires FILE=path/to/file.md)
ifndef FILE
@echo "❌ FILE is required. Usage: make content-stats FILE=document.md"
@exit 1
endif
ifndef MARKITECT_CLI
@echo "⚠️ markitect CLI not available, trying direct Python execution..."
cd capabilities/markitect-content && python -m markitect_content.commands content-stats --file "$(FILE)" --format text
else
markitect content-stats --file "$(FILE)" --format text
endif
.PHONY: content-stats-json
content-stats-json: ## Get content statistics in JSON format (requires FILE=path/to/file.md)
ifndef FILE
@echo "❌ FILE is required. Usage: make content-stats-json FILE=document.md"
@exit 1
endif
ifndef MARKITECT_CLI
@echo "⚠️ markitect CLI not available, trying direct Python execution..."
cd capabilities/markitect-content && python -m markitect_content.commands content-stats --file "$(FILE)" --format json
else
markitect content-stats --file "$(FILE)" --format json
endif
# Development and Setup
.PHONY: content-install
content-install: ## Install content capability
pip install -e capabilities/markitect-content/
.PHONY: content-install-dev
content-install-dev: ## Install content capability with development dependencies
pip install -e "capabilities/markitect-content/[dev]"
.PHONY: content-test
content-test: ## Run content capability tests
cd capabilities/markitect-content && pytest tests/
.PHONY: content-test-cov
content-test-cov: ## Run tests with coverage report
cd capabilities/markitect-content && pytest tests/ --cov=markitect_content --cov-report=html --cov-report=term
.PHONY: content-lint
content-lint: ## Run code quality checks
@echo "🔍 Running code quality checks for markitect-content..."
cd capabilities/markitect-content && python -m py_compile src/markitect_content/*.py
@echo "✅ Code quality checks passed"
.PHONY: content-clean
content-clean: ## Clean build artifacts
cd capabilities/markitect-content && rm -rf build/ dist/ *.egg-info/ __pycache__/ .pytest_cache/ htmlcov/ .coverage
find capabilities/markitect-content -name "*.pyc" -delete
find capabilities/markitect-content -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
# Library Functions (for other capabilities to use)
.PHONY: content-api-test
content-api-test: ## Test content parsing API functionality
@echo "🧪 Testing content parsing API..."
cd capabilities/markitect-content && python -c "from src.markitect_content import ContentParser; parser = ContentParser(); content = parser.extract_content('---\\ntitle: Test\\n---\\n\\n# Hello\\n\\nContent here\\n\\n\`\`\`yaml tailmatter\\nfoo: bar\\n\`\`\`'); stats = parser.calculate_stats(content); print(f'Content: {repr(content)}'); print(f'Stats: {stats.to_dict()}')"
# Meta information for capability discovery
.PHONY: capability-info
capability-info: ## Show capability information
@echo "Name: $(CAPABILITY_NAME)"
@echo "Description: $(CAPABILITY_DESCRIPTION)"
@echo "Type: Library capability with CLI commands"
@echo "Main functions: Content extraction, statistics calculation"
@echo "CLI commands: content-get, content-stats"
@echo "Targets:"
@$(MAKE) --no-print-directory help | grep "^ " | sed 's/^ / /'

View File

@@ -0,0 +1,131 @@
# MarkiTect Utils Capability Makefile
# Utility functions library for the MarkiTect ecosystem
# Capability metadata
CAPABILITY_NAME := markitect-utils
CAPABILITY_DESCRIPTION := Common utility functions for the MarkiTect ecosystem
# Default target
.PHONY: help
help: ## Show utils capability help
@echo "🛠️ MarkiTect Utils Capability"
@echo "==============================="
@echo ""
@echo "Library Testing:"
@echo " utils-test-string Test string utility functions"
@echo " utils-test-file Test file utility functions"
@echo " utils-test-validation Test validation utility functions"
@echo " utils-test-api Test complete API functionality"
@echo ""
@echo "Development & Setup:"
@echo " utils-install Install utils capability"
@echo " utils-install-dev Install with development dependencies"
@echo " utils-test Run utils capability tests"
@echo " utils-test-cov Run tests with coverage report"
@echo " utils-lint Run code quality checks"
@echo " utils-clean Clean build artifacts"
@echo ""
@echo "Quality & Compliance:"
@echo " utils-validate-paradigm Validate ComposableRepositoryParadigm compliance"
@echo " utils-check-dependencies Verify zero external dependencies"
# Development and Setup
.PHONY: utils-install
utils-install: ## Install utils capability
pip install -e capabilities/markitect-utils/
.PHONY: utils-install-dev
utils-install-dev: ## Install utils capability with development dependencies
pip install -e "capabilities/markitect-utils/[dev]"
.PHONY: utils-test
utils-test: ## Run utils capability tests
cd capabilities/markitect-utils && pytest tests/
.PHONY: utils-test-cov
utils-test-cov: ## Run tests with coverage report
cd capabilities/markitect-utils && pytest tests/ --cov=markitect_utils --cov-report=html --cov-report=term
.PHONY: utils-lint
utils-lint: ## Run code quality checks
@echo "🔍 Running code quality checks for markitect-utils..."
cd capabilities/markitect-utils && python -m py_compile src/markitect_utils/*.py
@echo "✅ Code quality checks passed"
.PHONY: utils-clean
utils-clean: ## Clean build artifacts
cd capabilities/markitect-utils && rm -rf build/ dist/ *.egg-info/ __pycache__/ .pytest_cache/ htmlcov/ .coverage
find capabilities/markitect-utils -name "*.pyc" -delete
find capabilities/markitect-utils -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
# Library Function Testing
.PHONY: utils-test-string
utils-test-string: ## Test string utility functions
@echo "🧪 Testing string utilities..."
cd capabilities/markitect-utils && python -c "from src.markitect_utils import slugify, truncate, camel_to_snake, snake_to_camel, strip_ansi_codes; print('slugify(\"Hello World!\"):', slugify('Hello World!')); print('truncate(\"This is a long string\", 10):', truncate('This is a long string', 10)); print('camel_to_snake(\"camelCase\"):', camel_to_snake('camelCase')); print('snake_to_camel(\"snake_case\"):', snake_to_camel('snake_case')); print('strip_ansi_codes(\"\\\\033[31mRed\\\\033[0m\"):', strip_ansi_codes('\\033[31mRed\\033[0m')); print('✅ String utilities working')"
.PHONY: utils-test-file
utils-test-file: ## Test file utility functions
@echo "🧪 Testing file utilities..."
cd capabilities/markitect-utils && python -c "from src.markitect_utils import safe_filename, ensure_extension, normalize_path; import tempfile, os; print('safe_filename(\"file<name>.txt\"):', safe_filename('file<name>.txt')); print('ensure_extension(\"document\", \".md\"):', ensure_extension('document', '.md')); print('normalize_path(\"./test/../file.txt\"):', normalize_path('./test/../file.txt')); print('✅ File utilities working')"
.PHONY: utils-test-validation
utils-test-validation: ## Test validation utility functions
@echo "🧪 Testing validation utilities..."
cd capabilities/markitect-utils && python -c "from src.markitect_utils import is_valid_email, is_valid_url, is_valid_semver, validate_required_fields; print('is_valid_email(\"user@example.com\"):', is_valid_email('user@example.com')); print('is_valid_url(\"https://example.com\"):', is_valid_url('https://example.com')); print('is_valid_semver(\"1.0.0\"):', is_valid_semver('1.0.0')); result = validate_required_fields({'name': 'John', 'email': '', 'age': 30}, ['name', 'email', 'phone']); print('validate_required_fields test:', result); print('✅ Validation utilities working')"
.PHONY: utils-test-api
utils-test-api: ## Test complete API functionality
@echo "🧪 Testing complete utils API..."
@$(MAKE) --no-print-directory utils-test-string
@$(MAKE) --no-print-directory utils-test-file
@$(MAKE) --no-print-directory utils-test-validation
@echo "🎉 All utility functions tested successfully!"
# Quality & Compliance
.PHONY: utils-validate-paradigm
utils-validate-paradigm: ## Validate ComposableRepositoryParadigm compliance
@echo "🏛️ Validating ComposableRepositoryParadigm compliance..."
@echo "✅ Checking src layout structure..."
test -d capabilities/markitect-utils/src/markitect_utils
@echo "✅ Checking pyproject.toml exists..."
test -f capabilities/markitect-utils/pyproject.toml
@echo "✅ Checking README.md exists..."
test -f capabilities/markitect-utils/README.md
@echo "✅ Checking tests directory..."
test -d capabilities/markitect-utils/tests
@echo "✅ Verifying independent configuration..."
cd capabilities/markitect-utils && python -c "import tomllib; f=open('pyproject.toml','rb'); data=tomllib.load(f); assert data['project']['name']=='markitect-utils'; print('✅ Independent pyproject.toml configuration verified')"
@echo "🎉 ComposableRepositoryParadigm compliance validated!"
.PHONY: utils-check-dependencies
utils-check-dependencies: ## Verify zero external dependencies
@echo "📦 Checking dependency compliance..."
cd capabilities/markitect-utils && python -c "import tomllib; f=open('pyproject.toml','rb'); data=tomllib.load(f); deps=data.get('project',{}).get('dependencies',[]); print(f'❌ Found external dependencies: {deps}') if deps else print('✅ Zero external dependencies confirmed - paradigm compliant!'); exit(1) if deps else None"
# Demonstration Functions
.PHONY: utils-demo
utils-demo: ## Demonstrate utility functions with examples
@echo "🎬 MarkiTect Utils Capability Demonstration"
@echo "==========================================="
@echo ""
@echo "String Utilities:"
@$(MAKE) --no-print-directory utils-test-string
@echo ""
@echo "File Utilities:"
@$(MAKE) --no-print-directory utils-test-file
@echo ""
@echo "Validation Utilities:"
@$(MAKE) --no-print-directory utils-test-validation
# Meta information for capability discovery
.PHONY: capability-info
capability-info: ## Show capability information
@echo "Name: $(CAPABILITY_NAME)"
@echo "Description: $(CAPABILITY_DESCRIPTION)"
@echo "Type: Pure library capability (zero external dependencies)"
@echo "Main modules: string_utils, file_utils, validation_utils"
@echo "Paradigm role: Reference implementation for ComposableRepositoryParadigm"
@echo "Dependencies: None (Python standard library only)"
@echo "Targets:"
@$(MAKE) --no-print-directory help | grep "^ " | sed 's/^ / /'

View File

@@ -0,0 +1,398 @@
# Release Management Capability Migration Plan
This document outlines the step-by-step plan to migrate all version management, packaging, and release publication functionality from the main MarkiTect project into the `release-management` capability.
## 📋 Migration Overview
### Current State
Version management and release functionality is currently scattered across:
- `release.py` (main release script)
- `gitea/` directory (package registry client)
- `VERSION_MANAGEMENT.md` (documentation)
- `PACKAGE_PUBLISHING.md` (documentation)
- Makefile targets (release automation)
- setuptools-scm configuration in main `pyproject.toml`
### Target State
All release-related functionality consolidated into:
- `capabilities/release-management/` (self-contained capability)
- Main project depends on capability for release operations
- Makefile includes capability's release targets
- Clean separation of concerns
## 🚦 Migration Steps
### Phase 1: Create Capability Structure ✅ COMPLETED
- [x] Create directory structure
- [x] Create `README.md` with comprehensive documentation
- [x] Create `pyproject.toml` with full configuration
- [x] Create main `__init__.py` with API exports
- [x] Create `release.mk` for Makefile integration
### Phase 2: Move Core Files and Code
#### 2.1 Move Release Script
**Source:** `release.py`**Target:** `src/release_management/cli/main.py`
**Steps:**
1. Copy `release.py` to `src/release_management/cli/main.py`
2. Refactor into proper CLI module structure
3. Extract core logic into separate modules:
- `SimpleReleaseManager``src/release_management/core/manager.py`
- Git operations → `src/release_management/git/manager.py`
- Package building → `src/release_management/core/builder.py`
- Publishing logic → `src/release_management/core/publisher.py`
**Refactoring Plan:**
```python
# Current: release.py (monolithic)
class SimpleReleaseManager:
# All functionality in one class
# Target: Modular architecture
# src/release_management/core/manager.py
class ReleaseManager:
def __init__(self):
self.git_manager = GitManager()
self.builder = PackageBuilder()
self.publisher = PublishManager()
# src/release_management/git/manager.py
class GitManager:
# Git-specific operations
# src/release_management/core/builder.py
class PackageBuilder:
# Package building operations
# src/release_management/core/publisher.py
class PublishManager:
# Publishing and upload operations
```
#### 2.2 Move Gitea Package Registry
**Source:** `gitea/` directory → **Target:** `src/release_management/registries/gitea/`
**File Mapping:**
```
gitea/config.py → src/release_management/registries/gitea/config.py
gitea/exceptions.py → src/release_management/registries/gitea/exceptions.py
gitea/package_registry.py → src/release_management/registries/gitea/registry.py
gitea/__init__.py → src/release_management/registries/gitea/__init__.py
```
**Refactoring:**
1. Create base registry interface: `src/release_management/registries/base.py`
2. Create registry factory: `src/release_management/registries/factory.py`
3. Adapt GiteaPackageRegistry to implement base interface
4. Add PyPI registry implementation for future use
#### 2.3 Move Documentation
**Source:** Documentation files → **Target:** `docs/` directory
**File Mapping:**
```
VERSION_MANAGEMENT.md → capabilities/release-management/docs/version_management.md
PACKAGE_PUBLISHING.md → capabilities/release-management/docs/package_publishing.md
```
**Updates Required:**
1. Update paths and references in documentation
2. Add API reference documentation
3. Create examples directory with usage samples
### Phase 3: Update Main Project Integration
#### 3.1 Update Main Makefile
**Target:** `Makefile` in main project
**Changes:**
1. Include release management Makefile:
```makefile
# Add at top of Makefile
include capabilities/release-management/release.mk
```
2. Update existing targets to use capability:
```makefile
# Old targets
release-status:
python release.py status
# New targets (provided by release.mk)
release-status:
release status
```
3. Remove obsolete targets and replace with capability equivalents
#### 3.2 Update Main pyproject.toml
**Target:** `pyproject.toml` in main project
**Changes:**
1. Add release-management as dependency:
```toml
[project.dependencies]
release-management = {path = "capabilities/release-management", develop = true}
```
2. Keep setuptools-scm configuration:
```toml
[tool.setuptools_scm]
write_to = "markitect/_version.py"
```
3. Remove release-specific configuration (moved to capability)
#### 3.3 Update Main Project Structure
**Cleanup Tasks:**
1. Remove `release.py` from root
2. Remove `gitea/` directory
3. Move `VERSION_MANAGEMENT.md` and `PACKAGE_PUBLISHING.md` to capability
4. Update `.gitignore` if needed
5. Update documentation references
### Phase 4: Testing and Validation
#### 4.1 Create Capability Tests
**Target:** `capabilities/release-management/tests/`
**Test Structure:**
```
tests/
├── test_manager.py # ReleaseManager tests
├── test_builder.py # PackageBuilder tests
├── test_publisher.py # PublishManager tests
├── test_git_manager.py # GitManager tests
├── test_gitea_registry.py # GiteaRegistry tests
├── test_cli.py # CLI command tests
├── test_integration.py # End-to-end tests
└── fixtures/
└── sample_packages/ # Test package artifacts
```
**Test Coverage Goals:**
- Unit tests for all core classes
- Integration tests for registry interactions
- CLI command tests
- Mock-based tests for external dependencies
- Error handling and edge cases
#### 4.2 Validate Migration
**Verification Steps:**
1. Install capability: `pip install -e capabilities/release-management/`
2. Run capability tests: `cd capabilities/release-management && pytest`
3. Test CLI commands: `release --help`, `release status`
4. Test Makefile integration: `make release-status`
5. Perform test release workflow
6. Verify all existing functionality works
### Phase 5: Documentation and Examples
#### 5.1 Create Examples
**Target:** `capabilities/release-management/examples/`
**Example Scripts:**
- `basic_release.py` - Simple release workflow
- `custom_registry.py` - Adding new registry type
- `ci_integration.py` - CI/CD pipeline integration
- `configuration_examples.py` - Various configuration patterns
#### 5.2 Update Documentation
**Documentation Tasks:**
1. Update main project README to reference capability
2. Create API reference documentation
3. Add troubleshooting guide
4. Document configuration options
5. Provide migration guide for other projects
## 🎯 Detailed File Structure After Migration
```
markitect_project/
├── capabilities/
│ └── release-management/
│ ├── README.md ✅ CREATED
│ ├── pyproject.toml ✅ CREATED
│ ├── release.mk ✅ CREATED
│ ├── MIGRATION_PLAN.md ✅ CREATED
│ ├── src/release_management/
│ │ ├── __init__.py ✅ CREATED
│ │ ├── _version.py # Generated by setuptools-scm
│ │ ├── core/
│ │ │ ├── __init__.py
│ │ │ ├── manager.py # ReleaseManager class
│ │ │ ├── builder.py # PackageBuilder class
│ │ │ └── publisher.py # PublishManager class
│ │ ├── git/
│ │ │ ├── __init__.py
│ │ │ └── manager.py # GitManager class
│ │ ├── registries/
│ │ │ ├── __init__.py
│ │ │ ├── base.py # Registry interface
│ │ │ ├── factory.py # RegistryFactory
│ │ │ ├── gitea/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── config.py # From gitea/config.py
│ │ │ │ ├── exceptions.py # From gitea/exceptions.py
│ │ │ │ └── registry.py # From gitea/package_registry.py
│ │ │ └── pypi/
│ │ │ ├── __init__.py
│ │ │ └── registry.py # PyPI registry implementation
│ │ ├── cli/
│ │ │ ├── __init__.py
│ │ │ ├── main.py # From release.py
│ │ │ ├── commands.py # CLI command implementations
│ │ │ └── utils.py # CLI utilities
│ │ └── utils/
│ │ ├── __init__.py
│ │ ├── version.py # Version utilities
│ │ └── validation.py # Release validation
│ ├── tests/
│ │ ├── __init__.py
│ │ ├── test_manager.py
│ │ ├── test_builder.py
│ │ ├── test_publisher.py
│ │ ├── test_git_manager.py
│ │ ├── test_gitea_registry.py
│ │ ├── test_cli.py
│ │ └── fixtures/
│ ├── docs/
│ │ ├── version_management.md # From VERSION_MANAGEMENT.md
│ │ ├── package_publishing.md # From PACKAGE_PUBLISHING.md
│ │ ├── api_reference.md
│ │ └── troubleshooting.md
│ └── examples/
│ ├── basic_release.py
│ ├── custom_registry.py
│ └── ci_integration.py
├── Makefile # Updated to include release.mk
├── pyproject.toml # Updated with capability dependency
└── markitect/
└── _version.py # Still generated by setuptools-scm
```
## 📦 Files to Remove After Migration
**Root Directory:**
- [x] `release.py` (moved to capability CLI)
- [x] `gitea/` directory (moved to capability registries)
- [x] `VERSION_MANAGEMENT.md` (moved to capability docs)
- [x] `PACKAGE_PUBLISHING.md` (moved to capability docs)
**Makefile Targets to Update:**
- Replace individual release targets with capability imports
- Keep legacy aliases for backward compatibility
- Update target documentation
## 🔧 API Design for Capability
### Main API Classes
```python
# Primary entry point
from release_management import ReleaseManager
manager = ReleaseManager()
success = manager.publish_release("1.0.0")
# Component access
from release_management import PackageBuilder, PublishManager, GitManager
builder = PackageBuilder()
builder.build_packages()
publisher = PublishManager()
publisher.upload_packages("gitea")
git = GitManager()
git.create_tag("v1.0.0")
# Registry access
from release_management import RegistryFactory
registry = RegistryFactory.create("gitea")
registry.upload_package("package.whl")
```
### CLI Interface
```bash
# Main commands
release status # Show release status
release validate # Validate release state
release tag --version 1.0.0 # Create git tag
release build # Build packages
release publish --version 1.0.0 # Complete release workflow
release upload --registry gitea # Upload existing packages
# Registry management
release registry-info --registry gitea
release registry-list
```
## 🚀 Benefits After Migration
### For MarkiTect Project
1. **Cleaner main project**: Release logic separated from core functionality
2. **Better maintainability**: Clear module boundaries and responsibilities
3. **Easier testing**: Isolated testing of release functionality
4. **Reduced complexity**: Main project focuses on core features
### For Release Management Capability
1. **Reusability**: Can be used in other Python projects
2. **Independent development**: Own release cycle and versioning
3. **Comprehensive testing**: Full test coverage for release functionality
4. **Documentation**: Dedicated documentation and examples
5. **Extensibility**: Easy to add new registries and features
### For Users/Developers
1. **Consistent interface**: Same commands across all projects using capability
2. **Better documentation**: Comprehensive guides and API reference
3. **More features**: Enhanced functionality and registry support
4. **Easier contribution**: Clear structure for adding features
## 🎯 Success Criteria
Migration is considered successful when:
1. ✅ All existing release functionality works through capability
2. ✅ Main project Makefile targets work unchanged
3. ✅ CLI commands provide same functionality as current `release.py`
4. ✅ All tests pass for both capability and main project
5. ✅ Documentation is complete and accurate
6. ✅ Examples demonstrate capability usage
7. ✅ No regression in release workflow functionality
## 🔄 Rollback Plan
If migration issues arise:
1. **Keep backup**: Current files backed up before migration
2. **Incremental approach**: Migrate one component at a time
3. **Parallel operation**: Keep old and new systems running during transition
4. **Quick revert**: Ability to restore original structure if needed
**Rollback Steps:**
1. Remove capability dependency from main `pyproject.toml`
2. Restore backed up files (`release.py`, `gitea/`, docs)
3. Restore original Makefile targets
4. Remove capability directory
5. Test that original functionality works
## 📅 Migration Timeline
**Estimated Duration:** 1-2 weeks for complete migration
**Phase Breakdown:**
- **Phase 1 (Directory Structure):** ✅ COMPLETED
- **Phase 2 (Code Migration):** 2-3 days
- **Phase 3 (Integration):** 1-2 days
- **Phase 4 (Testing):** 2-3 days
- **Phase 5 (Documentation):** 1-2 days
**Critical Path:**
1. Code refactoring and migration
2. Testing and validation
3. Documentation updates
4. Final integration testing
This migration plan ensures a systematic, low-risk transition to the capability-based architecture while maintaining all existing functionality and improving the overall project structure.

View File

@@ -0,0 +1,231 @@
# Release Management Capability Makefile
# Provides release management targets for any Python project
# Capability metadata
CAPABILITY_NAME := release-management
CAPABILITY_DESCRIPTION := Comprehensive release management for Python projects
# Default target
.PHONY: help
help: ## Show release management help
@echo "📦 Release Management Capability"
@echo "================================"
@echo ""
@echo "Status & Validation:"
@echo " release-status Show current release status and version information"
@echo " release-validate Validate repository state for release readiness"
@echo " release-registry-info Show package registry information and status"
@echo ""
@echo "Git Tag Management:"
@echo " release-tag VERSION=x.y.z Create git tag for version"
@echo ""
@echo "Package Building:"
@echo " release-build Build release packages using setuptools-scm"
@echo " release-clean Clean build artifacts and temporary files"
@echo ""
@echo "Publishing Workflows:"
@echo " release-publish VERSION=x.y.z Complete release workflow (tag + build)"
@echo " release-publish-gitea VERSION=x.y.z Complete release + Gitea upload"
@echo " release-publish-pypi VERSION=x.y.z Complete release + PyPI upload"
@echo ""
@echo "Upload Existing Packages:"
@echo " release-upload-gitea Upload existing packages to Gitea registry"
@echo " release-upload-pypi Upload existing packages to PyPI"
@echo " release-upload-testpypi Upload existing packages to Test PyPI"
@echo ""
@echo "Dry Run Options:"
@echo " release-publish-dry-run VERSION=x.y.z Dry run of release workflow"
@echo " release-upload-dry-run Dry run of package upload"
@echo ""
@echo "Development & Setup:"
@echo " release-management-install Install release management capability"
@echo " release-management-install-dev Install with development dependencies"
@echo " release-management-test Run capability tests"
@echo " release-management-help Show CLI help"
# Check if release management capability is available
RELEASE_CLI := $(shell command -v release 2> /dev/null)
# Status and Information
.PHONY: release-status
release-status: ## Show current release status and version information
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@echo " Install with: pip install -e capabilities/release-management/"
@exit 1
endif
release status
.PHONY: release-validate
release-validate: ## Validate repository state for release readiness
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release validate
.PHONY: release-registry-info
release-registry-info: ## Show package registry information and status
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release registry-info
# Git Tag Management
.PHONY: release-tag
release-tag: ## Create git tag for version (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-tag VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release tag --version $(VERSION)
# Package Building
.PHONY: release-build
release-build: ## Build release packages using setuptools-scm
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release build
.PHONY: release-clean
release-clean: ## Clean build artifacts and temporary files
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release clean
# Publishing Workflows
.PHONY: release-publish
release-publish: ## Complete release workflow: tag + build (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-publish VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release publish --version $(VERSION)
.PHONY: release-publish-gitea
release-publish-gitea: ## Complete release workflow + Gitea upload (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-publish-gitea VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release publish --version $(VERSION) --registry gitea
.PHONY: release-publish-pypi
release-publish-pypi: ## Complete release workflow + PyPI upload (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-publish-pypi VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release publish --version $(VERSION) --registry pypi
# Upload Existing Packages
.PHONY: release-upload-gitea
release-upload-gitea: ## Upload existing packages to Gitea registry
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release upload --registry gitea
.PHONY: release-upload-pypi
release-upload-pypi: ## Upload existing packages to PyPI
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release upload --registry pypi
.PHONY: release-upload-testpypi
release-upload-testpypi: ## Upload existing packages to Test PyPI
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release upload --registry testpypi
# Dry Run Options
.PHONY: release-publish-dry-run
release-publish-dry-run: ## Dry run of complete release workflow (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-publish-dry-run VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release publish --version $(VERSION) --dry-run
.PHONY: release-upload-dry-run
release-upload-dry-run: ## Dry run of package upload to default registry
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@exit 1
endif
release upload --dry-run
# Development and Setup
.PHONY: release-management-install
release-management-install: ## Install release management capability
pip install -e capabilities/release-management/
.PHONY: release-management-install-dev
release-management-install-dev: ## Install release management capability with dev dependencies
pip install -e "capabilities/release-management/[dev]"
.PHONY: release-management-test
release-management-test: ## Run release management capability tests
cd capabilities/release-management && pytest tests/
.PHONY: release-management-help
release-management-help: ## Show release management CLI help
ifndef RELEASE_CLI
@echo "❌ Release management capability not installed"
@echo " Install with: make release-management-install"
@exit 1
endif
release --help
# Convenience aliases
.PHONY: release-upload
release-upload: release-upload-gitea ## Upload packages to default registry (gitea)
.PHONY: package
package: release-build ## Build packages (alias for release-build)
.PHONY: publish
publish: ## Publish release to default registry (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make publish VERSION=1.0.0"
@exit 1
endif
@make release-publish-gitea VERSION=$(VERSION)
# Meta information for capability discovery
.PHONY: capability-info
capability-info: ## Show capability information
@echo "Name: $(CAPABILITY_NAME)"
@echo "Description: $(CAPABILITY_DESCRIPTION)"
@echo "Targets:"
@$(MAKE) --no-print-directory help | grep "^ " | sed 's/^ / /'

View File

@@ -0,0 +1,334 @@
# Release Management Capability
A self-contained capability for version management, package building, and release publication with Git and package registry integration.
## Overview
The release-management capability provides comprehensive release automation for Python projects using setuptools-scm for version management and supporting multiple publication targets including Gitea package registries.
## Features
- **Automatic Version Management**: Git tag-based versioning with setuptools-scm
- **Package Building**: Wheel and source distribution generation
- **Release Automation**: Complete release workflow from validation to publication
- **Multi-Platform Publishing**: Support for Gitea, GitHub, and other package registries
- **Fallback Publishing**: Release assets when package registries unavailable
- **CLI Integration**: Command-line tools for release management
- **Makefile Integration**: Convenient targets for common release tasks
## Architecture
### Core Components
#### `ReleaseManager`
Main orchestrator for release workflows, handling:
- Release state validation
- Git tag creation and management
- Package building coordination
- Publication orchestration
#### `PackageBuilder`
Responsible for package generation:
- setuptools-scm integration
- Wheel and source distribution building
- Build artifact management
#### `PublishManager`
Handles package publication:
- Multiple registry support (Gitea, PyPI, etc.)
- Fallback mechanisms (release assets)
- Upload progress tracking
#### `GitManager`
Git operations for releases:
- Tag creation and validation
- Repository state checking
- Branch and commit management
### Package Registry Support
#### `GiteaRegistry`
Gitea-specific package registry client:
- PyPI-compatible registry uploads
- Release asset fallback
- Authentication handling
#### `RegistryFactory`
Factory for creating registry clients:
- Auto-detection of registry types
- Configuration management
- Extensible for new registries
## API Reference
### Core Classes
#### `ReleaseManager`
```python
from release_management import ReleaseManager
manager = ReleaseManager()
# Validate release readiness
is_valid, issues = manager.validate_release_state()
# Create complete release
success = manager.publish_release("0.8.0")
# Publish with specific registry
success = manager.publish_with_registry("0.8.0", registry_type="gitea")
```
#### `PackageBuilder`
```python
from release_management import PackageBuilder
builder = PackageBuilder()
# Build packages
builder.build_packages()
# Get current version
version = builder.get_current_version()
# Clean build artifacts
builder.clean_build()
```
#### `PublishManager`
```python
from release_management import PublishManager
publisher = PublishManager()
# Publish to registry
success = publisher.publish_packages("gitea", dry_run=True)
# Upload specific files
success = publisher.upload_file("dist/package.whl", "gitea")
```
### CLI Commands
#### `release`
Main release command with subcommands:
```bash
# Show release status
release status
# Validate release readiness
release validate
# Create git tag
release tag --version 0.8.0
# Build packages
release build
# Complete release workflow
release publish --version 0.8.0
# Publish to specific registry
release publish --version 0.8.0 --registry gitea
# Upload existing packages
release upload --registry gitea
# Show registry information
release registry-info --registry gitea
```
### Configuration
#### Release Configuration
Configure release behavior in `pyproject.toml`:
```toml
[tool.release-management]
# Default registry for publishing
default_registry = "gitea"
# Validation requirements
require_clean_tree = true
require_main_branch = true
# Package building
build_wheel = true
build_sdist = true
clean_before_build = true
# Registry configurations
[tool.release-management.registries.gitea]
url = "http://92.205.130.254:32166"
owner = "coulomb"
repo = "markitect_project"
auth_token_env = "GITEA_API_TOKEN"
[tool.release-management.registries.pypi]
url = "https://upload.pypi.org/legacy/"
auth_token_env = "PYPI_TOKEN"
```
## Installation
Install as an editable dependency:
```bash
pip install -e capabilities/release-management/
```
Or with development dependencies:
```bash
pip install -e "capabilities/release-management/[dev]"
```
## Development Setup
```bash
cd capabilities/release-management/
pip install -e ".[dev]"
pytest tests/
```
## Integration with Main Project
The main project integrates with this capability through:
### Makefile Integration
```makefile
# Include release management targets
include capabilities/release-management/release.mk
# Or call capability directly
release-status:
release status
release-publish:
release publish --version $(VERSION)
```
### Setup Configuration
In main project's `pyproject.toml`:
```toml
[tool.setuptools_scm]
write_to = "markitect/_version.py"
[tool.release-management]
default_registry = "gitea"
```
## Migration Plan
This capability consolidates the following existing components:
### Files to Move
1. **`release.py`** → `src/release_management/cli/main.py`
2. **`gitea/`** directory → `src/release_management/registries/gitea/`
3. **VERSION_MANAGEMENT.md**`docs/version_management.md`
4. **PACKAGE_PUBLISHING.md**`docs/package_publishing.md`
5. **Makefile release targets**`release.mk`
### New Structure
```
capabilities/release-management/
├── README.md
├── pyproject.toml
├── release.mk # Makefile integration
├── src/release_management/
│ ├── __init__.py # Main API exports
│ ├── core/
│ │ ├── __init__.py
│ │ ├── manager.py # ReleaseManager class
│ │ ├── builder.py # PackageBuilder class
│ │ └── publisher.py # PublishManager class
│ ├── git/
│ │ ├── __init__.py
│ │ └── manager.py # GitManager class
│ ├── registries/
│ │ ├── __init__.py
│ │ ├── factory.py # RegistryFactory
│ │ ├── base.py # Registry interface
│ │ ├── gitea/
│ │ │ ├── __init__.py
│ │ │ ├── registry.py # GiteaRegistry
│ │ │ ├── config.py # GiteaConfig
│ │ │ └── exceptions.py # GiteaError
│ │ └── pypi/
│ │ ├── __init__.py
│ │ └── registry.py # PyPIRegistry
│ ├── cli/
│ │ ├── __init__.py
│ │ ├── main.py # Main CLI entry point
│ │ ├── commands.py # CLI command implementations
│ │ └── utils.py # CLI utilities
│ └── utils/
│ ├── __init__.py
│ ├── version.py # Version management utilities
│ └── validation.py # Release validation utilities
├── tests/
│ ├── __init__.py
│ ├── test_manager.py
│ ├── test_builder.py
│ ├── test_publisher.py
│ ├── test_git_manager.py
│ ├── test_gitea_registry.py
│ └── fixtures/
│ └── sample_packages/
├── docs/
│ ├── version_management.md
│ ├── package_publishing.md
│ ├── api_reference.md
│ └── examples/
└── examples/
├── basic_release.py
├── custom_registry.py
└── ci_integration.py
```
## Benefits of Capability Structure
### Modularity
- **Self-contained**: Independent testing and development
- **Reusable**: Can be used in other projects
- **Focused**: Single responsibility for release management
### Maintainability
- **Clear boundaries**: Well-defined API surface
- **Extensible**: Easy to add new registries or features
- **Testable**: Comprehensive test suite in isolation
### Integration
- **CLI integration**: Direct command-line access
- **Makefile integration**: Convenient targets for workflows
- **Configuration**: Centralized in pyproject.toml
## Dependencies
### Core Dependencies
- `click>=8.0.0` - CLI framework
- `requests>=2.25.0` - HTTP client for registries
- `setuptools-scm>=8.0.0` - Version management
- `build>=0.8.0` - Package building
- `packaging>=21.0` - Version parsing and validation
### Development Dependencies
- `pytest>=7.0.0` - Testing framework
- `pytest-cov>=4.0.0` - Coverage reporting
- `responses>=0.20.0` - HTTP mocking for tests
- `black>=22.0.0` - Code formatting
- `flake8>=5.0.0` - Code linting
- `mypy>=1.0.0` - Type checking
## Compliance
This capability follows the ComposableRepositoryParadigm:
- ✅ Src layout (PEP 660 compliant)
- ✅ Unidirectional dependencies
- ✅ Self-contained with own tests
- ✅ Independent configuration
- ✅ Clean API boundaries
- ✅ Type safety with mypy
- ✅ Comprehensive documentation

View File

@@ -0,0 +1,236 @@
# Release Management Makefile Integration
# Include this file in your main Makefile to add release management capabilities
#
# Usage: include capabilities/release-management/release.mk
# Release Management Variables
RELEASE_MANAGEMENT_PATH := capabilities/release-management
RELEASE_CLI := release
# Check if release management capability is available
RELEASE_AVAILABLE := $(shell command -v $(RELEASE_CLI) 2> /dev/null)
# Release Status and Information
.PHONY: release-status
release-status: ## Show current release status and version information
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@echo " Install with: pip install -e $(RELEASE_MANAGEMENT_PATH)/"
@exit 1
endif
$(RELEASE_CLI) status
.PHONY: release-validate
release-validate: ## Validate repository state for release readiness
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) validate
.PHONY: release-registry-info
release-registry-info: ## Show package registry information and status
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) registry-info
# Git Tag Management
.PHONY: release-tag
release-tag: ## Create git tag for version (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-tag VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) tag --version $(VERSION)
# Package Building
.PHONY: release-build
release-build: ## Build release packages using setuptools-scm
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) build
.PHONY: release-clean
release-clean: ## Clean build artifacts and temporary files
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) clean
# Publishing Workflows
.PHONY: release-publish
release-publish: ## Complete release workflow: tag + build (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-publish VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) publish --version $(VERSION)
.PHONY: release-publish-gitea
release-publish-gitea: ## Complete release workflow + Gitea upload (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-publish-gitea VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) publish --version $(VERSION) --registry gitea
.PHONY: release-publish-pypi
release-publish-pypi: ## Complete release workflow + PyPI upload (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-publish-pypi VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) publish --version $(VERSION) --registry pypi
# Upload Existing Packages
.PHONY: release-upload-gitea
release-upload-gitea: ## Upload existing packages to Gitea registry
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) upload --registry gitea
.PHONY: release-upload-pypi
release-upload-pypi: ## Upload existing packages to PyPI
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) upload --registry pypi
.PHONY: release-upload-testpypi
release-upload-testpypi: ## Upload existing packages to Test PyPI
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) upload --registry testpypi
# Dry Run Options
.PHONY: release-publish-dry-run
release-publish-dry-run: ## Dry run of complete release workflow (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make release-publish-dry-run VERSION=1.0.0"
@exit 1
endif
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) publish --version $(VERSION) --dry-run
.PHONY: release-upload-dry-run
release-upload-dry-run: ## Dry run of package upload to default registry
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@exit 1
endif
$(RELEASE_CLI) upload --dry-run
# Development and Setup
.PHONY: release-management-install
release-management-install: ## Install release management capability
pip install -e $(RELEASE_MANAGEMENT_PATH)/
.PHONY: release-management-install-dev
release-management-install-dev: ## Install release management capability with dev dependencies
pip install -e "$(RELEASE_MANAGEMENT_PATH)/[dev]"
.PHONY: release-management-test
release-management-test: ## Run release management capability tests
cd $(RELEASE_MANAGEMENT_PATH) && pytest tests/
.PHONY: release-management-help
release-management-help: ## Show release management CLI help
ifndef RELEASE_AVAILABLE
@echo "❌ Release management capability not installed"
@echo " Install with: make release-management-install"
@exit 1
endif
$(RELEASE_CLI) --help
# Help target integration
.PHONY: help-release
help-release: ## Show release management specific help
@echo ""
@echo "📦 Release Management:"
@echo " release-status Show current release status and version information"
@echo " release-validate Validate repository state for release readiness"
@echo " release-registry-info Show package registry information and status"
@echo ""
@echo "🏷️ Git Tag Management:"
@echo " release-tag VERSION=x.y.z Create git tag for version"
@echo ""
@echo "🔨 Package Building:"
@echo " release-build Build release packages using setuptools-scm"
@echo " release-clean Clean build artifacts and temporary files"
@echo ""
@echo "🚀 Publishing Workflows:"
@echo " release-publish VERSION=x.y.z Complete release workflow (tag + build)"
@echo " release-publish-gitea VERSION=x.y.z Complete release + Gitea upload"
@echo " release-publish-pypi VERSION=x.y.z Complete release + PyPI upload"
@echo ""
@echo "📤 Upload Existing Packages:"
@echo " release-upload-gitea Upload existing packages to Gitea registry"
@echo " release-upload-pypi Upload existing packages to PyPI"
@echo " release-upload-testpypi Upload existing packages to Test PyPI"
@echo ""
@echo "🧪 Dry Run Options:"
@echo " release-publish-dry-run VERSION=x.y.z Dry run of release workflow"
@echo " release-upload-dry-run Dry run of package upload"
@echo ""
@echo "⚙️ Development and Setup:"
@echo " release-management-install Install release management capability"
@echo " release-management-install-dev Install with development dependencies"
@echo " release-management-test Run capability tests"
@echo " release-management-help Show CLI help"
@echo ""
# Default registry shortcuts (can be overridden)
RELEASE_DEFAULT_REGISTRY ?= gitea
.PHONY: release-upload
release-upload: release-upload-$(RELEASE_DEFAULT_REGISTRY) ## Upload packages to default registry ($(RELEASE_DEFAULT_REGISTRY))
# Integration with main project targets
# These can be overridden in main Makefile if different behavior is needed
.PHONY: package
package: release-build ## Build packages (alias for release-build)
.PHONY: publish
publish: ## Publish release to default registry (requires VERSION=x.y.z)
ifndef VERSION
@echo "❌ VERSION is required. Usage: make publish VERSION=1.0.0"
@exit 1
endif
@make release-publish-$(RELEASE_DEFAULT_REGISTRY) VERSION=$(VERSION)
# Legacy compatibility targets
.PHONY: release-status-legacy
release-status-legacy: release-status ## Legacy alias for release-status
.PHONY: package-upload
package-upload: release-upload ## Legacy alias for release-upload

View File

@@ -0,0 +1,65 @@
"""
Release Management Capability
A comprehensive release management system for Python projects providing:
- Automatic version management with setuptools-scm
- Package building and distribution
- Multi-platform publishing (Gitea, PyPI, etc.)
- Git tag-based release workflows
- CLI tools for release automation
Main Components:
- ReleaseManager: Orchestrates complete release workflows
- PackageBuilder: Handles package generation and building
- PublishManager: Manages package publication to registries
- GitManager: Git operations for releases
- Registry Support: Gitea, PyPI, and extensible registry system
Quick Start:
from release_management import ReleaseManager
manager = ReleaseManager()
success = manager.publish_release("1.0.0")
CLI Usage:
release status
release publish --version 1.0.0
release upload --registry gitea
"""
from .core.manager import ReleaseManager
from .core.builder import PackageBuilder
from .core.publisher import PublishManager
from .git.manager import GitManager
from .registries.factory import RegistryFactory
from .registries.gitea.registry import GiteaRegistry
from .utils.version import VersionManager
from .utils.validation import ReleaseValidator
# Version is managed in pyproject.toml
__version__ = "0.1.0"
__all__ = [
# Core classes
"ReleaseManager",
"PackageBuilder",
"PublishManager",
"GitManager",
# Registry support
"RegistryFactory",
"GiteaRegistry",
# Utilities
"VersionManager",
"ReleaseValidator",
# Version
"__version__",
]
# Package metadata
__title__ = "release-management"
__description__ = "Comprehensive release management capability for Python projects"
__author__ = "MarkiTect Project"
__license__ = "MIT"

View File

@@ -0,0 +1,9 @@
"""
Command-line interface for release management.
This module provides CLI commands for release operations.
"""
from .main import main
__all__ = ["main"]

View File

@@ -0,0 +1,252 @@
"""
Main CLI entry point for release management.
This module provides the main CLI interface adapted from the original release.py script.
"""
import click
import sys
from pathlib import Path
from typing import Optional
from ..core.manager import ReleaseManager
from ..utils.version import VersionManager
@click.group(invoke_without_command=True)
@click.option('--dry-run', is_flag=True, help='Show what would be done without making changes')
@click.option('--force', is_flag=True, help='Force operation even with warnings')
@click.option('--project-root', type=click.Path(exists=True, path_type=Path),
help='Project root directory')
@click.pass_context
def main(ctx, dry_run: bool, force: bool, project_root: Optional[Path]):
"""Release management CLI for Python projects."""
ctx.ensure_object(dict)
ctx.obj['dry_run'] = dry_run
ctx.obj['force'] = force
ctx.obj['project_root'] = project_root
# If no command specified, show status
if ctx.invoked_subcommand is None:
ctx.invoke(status)
@main.command()
@click.pass_context
def status(ctx):
"""Show current release status and version information."""
manager = ReleaseManager(
project_root=ctx.obj['project_root'],
dry_run=ctx.obj['dry_run'],
force=ctx.obj['force']
)
print("🔍 Release Status")
print("=" * 60)
status_info = manager.get_release_status()
# Version information
print(f"Current Version: {status_info['version']}")
# Git information
if status_info.get('is_repo'):
print(f"Git Branch: {status_info['branch']}")
print(f"Latest Commit: {status_info['latest_commit']}")
print(f"Latest Tag: {status_info['latest_tag'] or 'None'}")
print(f"Uncommitted Changes: {'Yes' if status_info['has_changes'] else 'No'}")
else:
print("Git Repository: Not available")
# Package information
packages = status_info['packages']
print(f"\\nBuilt Packages: {packages['total_count']} files")
if packages['wheels']:
print(" Wheels:")
for wheel in packages['wheels']:
print(f" - {wheel}")
if packages['sdists']:
print(" Source Distributions:")
for sdist in packages['sdists']:
print(f" - {sdist}")
# Validation status
validation = status_info['validation']
if validation['is_valid']:
print("\\n✅ Repository is ready for release")
else:
print("\\n❌ Release validation issues:")
for issue in validation['issues']:
print(f" - {issue}")
@main.command()
@click.pass_context
def validate(ctx):
"""Validate repository state for release readiness."""
manager = ReleaseManager(
project_root=ctx.obj['project_root'],
dry_run=ctx.obj['dry_run'],
force=ctx.obj['force']
)
is_valid, issues = manager.validate_release_state()
if is_valid:
print("✅ Repository is ready for release")
else:
print("❌ Release validation failed:")
for issue in issues:
print(f" - {issue}")
sys.exit(1)
@main.command()
@click.option('--version', required=True, help='Version to tag (e.g., 0.8.0)')
@click.option('--message', help='Tag message')
@click.pass_context
def tag(ctx, version: str, message: Optional[str]):
"""Create git tag for version."""
manager = ReleaseManager(
project_root=ctx.obj['project_root'],
dry_run=ctx.obj['dry_run'],
force=ctx.obj['force']
)
if manager.create_tag(version, message):
print(f"✅ Successfully created tag for version {version}")
else:
print(f"❌ Failed to create tag for version {version}")
sys.exit(1)
@main.command()
@click.pass_context
def build(ctx):
"""Build release packages using setuptools-scm."""
manager = ReleaseManager(
project_root=ctx.obj['project_root'],
dry_run=ctx.obj['dry_run'],
force=ctx.obj['force']
)
if manager.build_packages():
print("✅ Packages built successfully")
else:
print("❌ Package build failed")
sys.exit(1)
@main.command()
@click.option('--version', required=True, help='Version to publish (e.g., 0.8.0)')
@click.option('--registry', default='gitea', help='Registry type (gitea, pypi, etc.)')
@click.option('--skip-build', is_flag=True, help='Skip building and use existing packages')
@click.pass_context
def publish(ctx, version: str, registry: str, skip_build: bool):
"""Complete release workflow: tag, build, and publish."""
manager = ReleaseManager(
project_root=ctx.obj['project_root'],
dry_run=ctx.obj['dry_run'],
force=ctx.obj['force']
)
if manager.publish_with_fallback(version, registry, skip_build):
print(f"🎉 Release {version} published successfully!")
else:
print(f"❌ Release {version} failed")
sys.exit(1)
@main.command()
@click.option('--registry', default='gitea', help='Registry type (gitea, pypi, etc.)')
@click.pass_context
def upload(ctx, registry: str):
"""Upload existing packages to registry."""
manager = ReleaseManager(
project_root=ctx.obj['project_root'],
dry_run=ctx.obj['dry_run'],
force=ctx.obj['force']
)
if manager.upload_existing_packages(registry):
print(f"✅ Packages uploaded to {registry}")
else:
print(f"❌ Upload to {registry} failed")
sys.exit(1)
@main.command('registry-info')
@click.option('--registry', default='gitea', help='Registry type to show info for')
@click.pass_context
def registry_info(ctx, registry: str):
"""Show package registry information and status."""
manager = ReleaseManager(
project_root=ctx.obj['project_root'],
dry_run=ctx.obj['dry_run'],
force=ctx.obj['force']
)
info = manager.show_registry_info(registry)
print(f"📦 {registry.title()} Registry Information")
print("=" * 50)
if 'error' in info:
print(f"❌ Error: {info['error']}")
return
for key, value in info.items():
if isinstance(value, bool):
indicator = "" if value else ""
print(f"{key.replace('_', ' ').title()}: {indicator}")
else:
print(f"{key.replace('_', ' ').title()}: {value}")
@main.command()
@click.pass_context
def clean(ctx):
"""Clean build artifacts and temporary files."""
manager = ReleaseManager(
project_root=ctx.obj['project_root'],
dry_run=ctx.obj['dry_run'],
force=ctx.obj['force']
)
manager.clean_build_artifacts()
print("✅ Build artifacts cleaned")
@main.command('version-info')
@click.option('--suggest', is_flag=True, help='Suggest next version options')
@click.pass_context
def version_info(ctx, suggest: bool):
"""Show version information and suggestions."""
version_manager = VersionManager(ctx.obj['project_root'])
current = version_manager.get_current_version()
print(f"Current Version: {current}")
if suggest:
suggestions = version_manager.suggest_version(current)
if 'error' in suggestions:
print(f"{suggestions['error']}")
if 'suggestion' in suggestions:
print(f"💡 {suggestions['suggestion']}")
else:
print("\\nSuggested next versions:")
print(f" Patch: {suggestions['patch']}")
print(f" Minor: {suggestions['minor']}")
print(f" Major: {suggestions['major']}")
# Show version components
version_data = version_manager.parse_version(current)
if 'error' not in version_data:
print("\\nVersion Components:")
for key, value in version_data.items():
if value is not None:
print(f" {key.replace('_', ' ').title()}: {value}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,14 @@
"""
Core release management classes.
This module provides the main classes for orchestrating releases:
- ReleaseManager: Main coordinator for release workflows
- PackageBuilder: Package building and setuptools-scm integration
- PublishManager: Package publication to registries
"""
from .manager import ReleaseManager
from .builder import PackageBuilder
from .publisher import PublishManager
__all__ = ["ReleaseManager", "PackageBuilder", "PublishManager"]

View File

@@ -0,0 +1,166 @@
"""
Package building functionality for releases.
This module handles package building using setuptools-scm and the Python build module.
"""
import subprocess
import sys
from pathlib import Path
from typing import List, Optional, Dict, Any
from ..utils.version import VersionManager
class PackageBuilder:
"""Handles package building with setuptools-scm integration."""
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False):
"""Initialize the package builder.
Args:
project_root: Root directory of the project. Defaults to current directory.
dry_run: If True, show what would be done without executing.
"""
self.project_root = project_root or Path.cwd()
self.dry_run = dry_run
self.dist_dir = self.project_root / "dist"
def get_current_version(self) -> str:
"""Get current version using setuptools-scm.
Returns:
Current version string or "unknown" if unavailable.
"""
try:
result = self._run_command(
['python', '-m', 'setuptools_scm'],
capture=True,
skip_dry_run=True
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return "unknown"
def clean_build(self) -> None:
"""Clean previous build artifacts."""
print("🧹 Cleaning build artifacts...")
patterns = ['build', 'dist', '*.egg-info']
for pattern in patterns:
try:
if pattern == 'dist' and self.dist_dir.exists():
if self.dry_run:
print(f"[DRY RUN] Would remove: {self.dist_dir}")
else:
import shutil
shutil.rmtree(self.dist_dir)
print(f"✅ Removed: {self.dist_dir}")
elif pattern != 'dist':
self._run_command(['rm', '-rf', pattern])
except subprocess.CalledProcessError:
pass # Ignore if files don't exist
def build_packages(self) -> bool:
"""Build release packages using setuptools-scm.
Returns:
True if build successful, False otherwise.
"""
print(f"📦 Building packages (version auto-determined by setuptools-scm)")
# Clean previous builds
self.clean_build()
# Build packages
try:
print("Building packages...")
self._run_command(['python', '-m', 'build'], capture=False)
print("✅ Packages built successfully")
# Show package details
self._show_package_details()
return True
except subprocess.CalledProcessError as e:
print(f"❌ Package build failed: {e}")
return False
def get_built_packages(self) -> Dict[str, List[Path]]:
"""Get list of built packages by type.
Returns:
Dictionary with 'wheels' and 'sdists' keys containing file paths.
"""
if not self.dist_dir.exists():
return {"wheels": [], "sdists": []}
wheels = list(self.dist_dir.glob("*.whl"))
sdists = list(self.dist_dir.glob("*.tar.gz"))
return {"wheels": wheels, "sdists": sdists}
def validate_packages(self) -> bool:
"""Validate that expected packages were built.
Returns:
True if packages are valid, False otherwise.
"""
packages = self.get_built_packages()
if not packages["wheels"] and not packages["sdists"]:
print("❌ No packages found in dist/")
return False
# Check package sizes
for wheel in packages["wheels"]:
if wheel.stat().st_size < 1000: # Less than 1KB
print(f"⚠️ Warning: {wheel.name} is very small ({wheel.stat().st_size} bytes)")
for sdist in packages["sdists"]:
if sdist.stat().st_size < 1000: # Less than 1KB
print(f"⚠️ Warning: {sdist.name} is very small ({sdist.stat().st_size} bytes)")
return True
def _show_package_details(self) -> None:
"""Show details about built packages."""
packages = self.get_built_packages()
if packages["wheels"] or packages["sdists"]:
print(f"\n📦 Built packages in {self.dist_dir}:")
for wheel in packages["wheels"]:
size = wheel.stat().st_size
print(f" 🎯 {wheel.name} ({size:,} bytes)")
for sdist in packages["sdists"]:
size = sdist.stat().st_size
print(f" 📄 {sdist.name} ({size:,} bytes)")
else:
print("❌ No packages found")
def _run_command(self, cmd: List[str], capture: bool = True,
check: bool = True, skip_dry_run: bool = False) -> subprocess.CompletedProcess:
"""Run a command with optional dry-run support.
Args:
cmd: Command to execute
capture: Whether to capture output
check: Whether to raise on non-zero exit
skip_dry_run: Whether to skip dry-run check and always execute
Returns:
CompletedProcess result
"""
if self.dry_run and not skip_dry_run:
print(f"[DRY RUN] Would run: {' '.join(cmd)}")
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.run(
cmd,
capture_output=capture,
text=True,
check=check,
cwd=self.project_root
)

View File

@@ -0,0 +1,215 @@
"""
Main release manager orchestrating complete release workflows.
This module provides the primary ReleaseManager class that coordinates
all aspects of the release process.
"""
from pathlib import Path
from typing import Optional, List, Tuple, Dict, Any
from .builder import PackageBuilder
from .publisher import PublishManager
from ..git.manager import GitManager
from ..utils.validation import ReleaseValidator
class ReleaseManager:
"""Main orchestrator for release workflows."""
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False, force: bool = False):
"""Initialize the release manager.
Args:
project_root: Root directory of the project. Defaults to current directory.
dry_run: If True, show what would be done without executing.
force: If True, skip validation checks.
"""
self.project_root = project_root or Path.cwd()
self.dry_run = dry_run
self.force = force
# Initialize component managers
self.git_manager = GitManager(project_root, dry_run)
self.builder = PackageBuilder(project_root, dry_run)
self.publisher = PublishManager(project_root, dry_run)
self.validator = ReleaseValidator(project_root)
def get_release_status(self) -> Dict[str, Any]:
"""Get comprehensive release status information.
Returns:
Dictionary with release status details
"""
status = {}
# Version information
status['version'] = self.builder.get_current_version()
# Git status
git_status = self.git_manager.get_repository_status()
status.update(git_status)
# Package status
packages = self.builder.get_built_packages()
status['packages'] = {
'wheels': [p.name for p in packages['wheels']],
'sdists': [p.name for p in packages['sdists']],
'total_count': len(packages['wheels']) + len(packages['sdists'])
}
# Validation status
is_valid, issues = self.validate_release_state()
status['validation'] = {
'is_valid': is_valid,
'issues': issues
}
return status
def validate_release_state(self) -> Tuple[bool, List[str]]:
"""Validate that the repository is ready for release.
Returns:
Tuple of (is_valid, list_of_issues)
"""
return self.validator.validate_release_state(force=self.force)
def create_tag(self, version: str, message: Optional[str] = None) -> bool:
"""Create a git tag for the release.
Args:
version: Version to tag (e.g., "1.0.0")
message: Optional tag message
Returns:
True if tag created successfully, False otherwise
"""
# Validate release state first
is_valid, issues = self.validate_release_state()
if not is_valid and not self.force:
print("❌ Cannot create tag:")
for issue in issues:
print(f" - {issue}")
return False
return self.git_manager.create_tag(version, message)
def build_packages(self) -> bool:
"""Build release packages.
Returns:
True if build successful, False otherwise
"""
success = self.builder.build_packages()
if success:
success = self.builder.validate_packages()
return success
def publish_release(self, version: str, registry_type: str = 'gitea',
skip_build: bool = False) -> bool:
"""Complete release workflow: validate, tag, build, and publish.
Args:
version: Version to release
registry_type: Type of registry to publish to
skip_build: If True, skip building and use existing packages
Returns:
True if release successful, False otherwise
"""
print(f"🚀 Publishing release {version}")
# Validate state
is_valid, issues = self.validate_release_state()
if not is_valid and not self.force:
print("❌ Cannot publish release:")
for issue in issues:
print(f" - {issue}")
return False
# Create git tag (this determines the version for setuptools-scm)
if not self.git_manager.tag_exists(f"v{version}"):
if not self.create_tag(version):
return False
else:
print(f"✅ Tag v{version} already exists")
# Build packages (setuptools-scm will use the tag for version)
if not skip_build:
if not self.build_packages():
return False
else:
print("⏭️ Skipping build (using existing packages)")
# Publish packages
if not self.publisher.publish_packages(registry_type):
print("⚠️ Release completed but publishing failed")
return False
print(f"✅ Release {version} completed successfully!")
print(f"📦 Packages published to {registry_type}")
print(f"🏷️ Git tag v{version} created")
return True
def publish_with_fallback(self, version: str, registry_type: str = 'gitea',
skip_build: bool = False) -> bool:
"""Complete release workflow with fallback to release assets.
Args:
version: Version to release
registry_type: Type of registry to publish to
skip_build: If True, skip building and use existing packages
Returns:
True if release successful, False otherwise
"""
# Try normal publish first
if self.publish_release(version, registry_type, skip_build):
return True
# If that fails, try release assets fallback
print("🔄 Attempting release assets fallback...")
return self.publisher.publish_as_release_assets(version, registry_type)
def upload_existing_packages(self, registry_type: str = 'gitea') -> bool:
"""Upload existing packages without building or tagging.
Args:
registry_type: Type of registry to upload to
Returns:
True if upload successful, False otherwise
"""
print(f"📤 Uploading existing packages to {registry_type}")
packages = self.builder.get_built_packages()
if not packages["wheels"] and not packages["sdists"]:
print("❌ No packages found in dist/. Run build first.")
return False
all_packages = packages["wheels"] + packages["sdists"]
return self.publisher.upload_specific_packages(all_packages, registry_type)
def clean_build_artifacts(self) -> None:
"""Clean build artifacts and temporary files."""
self.builder.clean_build()
def show_registry_info(self, registry_type: str = 'gitea') -> Dict[str, Any]:
"""Show information about a registry.
Args:
registry_type: Type of registry
Returns:
Dictionary with registry information
"""
return self.publisher.get_registry_info(registry_type)
def get_commits_since_last_tag(self) -> List[str]:
"""Get commits since the last release tag.
Returns:
List of commit messages since last tag
"""
return self.git_manager.get_commits_since_tag()

View File

@@ -0,0 +1,248 @@
"""
Package publishing functionality for releases.
This module handles publishing packages to various registries.
"""
from pathlib import Path
from typing import Dict, List, Optional, Any
from ..registries.factory import RegistryFactory
from ..registries.base import RegistryInterface
from .builder import PackageBuilder
class PublishManager:
"""Handles package publication to registries."""
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False):
"""Initialize the publish manager.
Args:
project_root: Root directory of the project. Defaults to current directory.
dry_run: If True, show what would be done without executing.
"""
self.project_root = project_root or Path.cwd()
self.dry_run = dry_run
def publish_packages(self, registry_type: str = 'gitea',
registry_config: Optional[Dict[str, Any]] = None) -> bool:
"""Publish packages to specified registry.
Args:
registry_type: Type of registry to publish to
registry_config: Optional registry configuration
Returns:
True if publishing successful, False otherwise
"""
try:
# Get registry client
registry = self._get_registry(registry_type, registry_config)
# Get built packages
builder = PackageBuilder(self.project_root)
packages = builder.get_built_packages()
if not packages["wheels"] and not packages["sdists"]:
print("❌ No packages found in dist/. Run build first.")
return False
# Upload packages
success = True
for wheel in packages["wheels"]:
if not self._upload_package_with_fallback(registry, wheel):
success = False
for sdist in packages["sdists"]:
if not self._upload_package_with_fallback(registry, sdist):
success = False
return success
except Exception as e:
print(f"❌ Publishing failed: {e}")
return False
def upload_specific_packages(self, package_paths: List[Path],
registry_type: str = 'gitea',
registry_config: Optional[Dict[str, Any]] = None) -> bool:
"""Upload specific package files to registry.
Args:
package_paths: List of paths to package files
registry_type: Type of registry to publish to
registry_config: Optional registry configuration
Returns:
True if all uploads successful, False otherwise
"""
try:
registry = self._get_registry(registry_type, registry_config)
success = True
for package_path in package_paths:
if not package_path.exists():
print(f"❌ Package not found: {package_path}")
success = False
continue
if not self._upload_package_with_fallback(registry, package_path):
success = False
return success
except Exception as e:
print(f"❌ Upload failed: {e}")
return False
def publish_as_release_assets(self, version: str,
registry_type: str = 'gitea',
registry_config: Optional[Dict[str, Any]] = None) -> bool:
"""Publish packages as release assets (fallback method).
Args:
version: Version to publish as
registry_type: Type of registry (must support release assets)
registry_config: Optional registry configuration
Returns:
True if publishing successful, False otherwise
"""
try:
registry = self._get_registry(registry_type, registry_config)
# Check if registry supports release assets
if not hasattr(registry, 'upload_package_as_release_assets'):
print(f"❌ Registry type '{registry_type}' does not support release assets")
return False
# Get built packages
builder = PackageBuilder(self.project_root)
packages = builder.get_built_packages()
if not packages["wheels"] and not packages["sdists"]:
print("❌ No packages found in dist/. Run build first.")
return False
# Find wheel and corresponding source distribution
success = True
for wheel in packages["wheels"]:
# Find matching sdist
sdist = None
wheel_name_parts = wheel.stem.split('-')
package_name = wheel_name_parts[0] if wheel_name_parts else ""
for potential_sdist in packages["sdists"]:
if potential_sdist.stem.startswith(package_name):
sdist = potential_sdist
break
# Upload as release assets
if not registry.upload_package_as_release_assets(
version, wheel, sdist, dry_run=self.dry_run
):
success = False
return success
except Exception as e:
print(f"❌ Release asset publishing failed: {e}")
return False
def get_registry_info(self, registry_type: str = 'gitea',
registry_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Get information about a registry.
Args:
registry_type: Type of registry
registry_config: Optional registry configuration
Returns:
Dictionary with registry information
"""
try:
registry = self._get_registry(registry_type, registry_config)
return registry.get_registry_info()
except Exception as e:
return {"error": str(e)}
def _get_registry(self, registry_type: str,
registry_config: Optional[Dict[str, Any]] = None) -> RegistryInterface:
"""Get a registry client.
Args:
registry_type: Type of registry
registry_config: Optional registry configuration
Returns:
Registry client instance
"""
if registry_config:
return RegistryFactory.create(registry_type, registry_config)
else:
# Try to load from pyproject.toml first
try:
return RegistryFactory.create_from_pyproject_config(
self.project_root / "pyproject.toml",
registry_type
)
except (ValueError, FileNotFoundError):
# Fallback to auto-detection
return RegistryFactory.create(registry_type)
def _upload_package_with_fallback(self, registry: RegistryInterface,
package_path: Path) -> bool:
"""Upload a package with fallback to release assets if needed.
Args:
registry: Registry client
package_path: Path to package file
Returns:
True if upload successful, False otherwise
"""
try:
# Try normal package upload first
return registry.upload_package(package_path, dry_run=self.dry_run)
except Exception as e:
print(f"⚠️ Package upload failed: {e}")
# Check if registry supports release assets as fallback
if hasattr(registry, 'upload_package_as_release_assets'):
print("🔄 Trying release assets as fallback...")
# Extract version from package filename for release assets
version = self._extract_version_from_filename(package_path)
if version:
return registry.upload_package_as_release_assets(
version, package_path, dry_run=self.dry_run
)
return False
def _extract_version_from_filename(self, package_path: Path) -> Optional[str]:
"""Extract version from package filename.
Args:
package_path: Path to package file
Returns:
Version string or None if not found
"""
try:
if package_path.suffix == '.whl':
# Wheel format: package-version-python-abi-platform.whl
parts = package_path.stem.split('-')
if len(parts) >= 2:
return parts[1]
elif package_path.suffix == '.gz' and package_path.name.endswith('.tar.gz'):
# Source dist format: package-version.tar.gz
name_without_tar = package_path.name.replace('.tar.gz', '')
parts = name_without_tar.split('-')
if len(parts) >= 2:
return parts[1]
except Exception:
pass
return None

View File

@@ -0,0 +1,12 @@
"""
Git management for releases.
This module provides Git operations required for release workflows:
- Tag creation and management
- Repository state validation
- Branch and commit operations
"""
from .manager import GitManager
__all__ = ["GitManager"]

View File

@@ -0,0 +1,205 @@
"""
Git operations for release management.
This module handles all Git-related operations needed for releases.
"""
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional, List
class GitManager:
"""Manages Git operations for releases."""
def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False):
"""Initialize Git manager.
Args:
project_root: Root directory of the project
dry_run: If True, show what would be done without executing
"""
self.project_root = project_root or Path.cwd()
self.dry_run = dry_run
def get_repository_status(self) -> Dict[str, Any]:
"""Get current git repository status.
Returns:
Dictionary with repository status information
"""
try:
# Get current branch
branch_result = self._run_command(['git', 'branch', '--show-current'])
current_branch = branch_result.stdout.strip()
# Check for uncommitted changes
status_result = self._run_command(['git', 'status', '--porcelain'])
has_changes = bool(status_result.stdout.strip())
# Get latest commit
commit_result = self._run_command(['git', 'rev-parse', '--short', 'HEAD'])
latest_commit = commit_result.stdout.strip()
# Get latest tag
try:
tag_result = self._run_command(['git', 'describe', '--tags', '--abbrev=0'])
latest_tag = tag_result.stdout.strip()
except subprocess.CalledProcessError:
latest_tag = None
return {
'is_repo': True,
'branch': current_branch,
'has_changes': has_changes,
'latest_commit': latest_commit,
'latest_tag': latest_tag
}
except subprocess.CalledProcessError:
return {'is_repo': False}
def create_tag(self, version: str, message: Optional[str] = None) -> bool:
"""Create and push git tag.
Args:
version: Version to tag (e.g., "1.0.0")
message: Optional tag message
Returns:
True if successful, False otherwise
"""
if not version.startswith('v'):
tag_name = f"v{version}"
else:
tag_name = version
tag_message = message or f"Release {version.lstrip('v')}"
print(f"🏷️ Creating git tag {tag_name}")
try:
# Create annotated tag
self._run_command(['git', 'tag', '-a', tag_name, '-m', tag_message])
print(f"✅ Tag {tag_name} created")
# Push tag to origin
try:
print(f"📤 Pushing tag to origin...")
self._run_command(['git', 'push', 'origin', tag_name])
print(f"✅ Tag pushed to origin")
return True
except subprocess.CalledProcessError as e:
print(f"⚠️ Could not push tag to origin: {e}")
print(f"You can push it manually with: git push origin {tag_name}")
return True # Tag created successfully, push can be done manually
except subprocess.CalledProcessError as e:
print(f"❌ Failed to create tag: {e}")
return False
def validate_release_state(self, force: bool = False) -> tuple[bool, List[str]]:
"""Validate that repository is ready for release.
Args:
force: Skip validation checks if True
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
status = self.get_repository_status()
if not status['is_repo']:
issues.append("Not in a git repository")
else:
if status['has_changes'] and not force:
issues.append("Repository has uncommitted changes")
if status['branch'] != 'main' and not force:
issues.append(f"Not on main branch (currently on {status['branch']})")
return len(issues) == 0, issues
def get_commits_since_tag(self, tag_name: Optional[str] = None) -> List[str]:
"""Get list of commits since specified tag.
Args:
tag_name: Tag to compare against. If None, uses latest tag.
Returns:
List of commit messages since tag
"""
try:
if tag_name is None:
# Get latest tag
tag_result = self._run_command(['git', 'describe', '--tags', '--abbrev=0'])
tag_name = tag_result.stdout.strip()
# Get commits since tag
log_result = self._run_command([
'git', 'log', f'{tag_name}..HEAD', '--oneline', '--no-merges'
])
commits = []
for line in log_result.stdout.strip().split('\n'):
if line:
commits.append(line)
return commits
except subprocess.CalledProcessError:
return []
def tag_exists(self, tag_name: str) -> bool:
"""Check if a git tag exists.
Args:
tag_name: Tag name to check
Returns:
True if tag exists, False otherwise
"""
try:
self._run_command(['git', 'rev-parse', f'refs/tags/{tag_name}'])
return True
except subprocess.CalledProcessError:
return False
def get_remote_url(self, remote: str = 'origin') -> Optional[str]:
"""Get the URL of a git remote.
Args:
remote: Remote name (default: 'origin')
Returns:
Remote URL or None if not found
"""
try:
result = self._run_command(['git', 'remote', 'get-url', remote])
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def _run_command(self, cmd: List[str]) -> subprocess.CompletedProcess:
"""Run a git command.
Args:
cmd: Command to execute
Returns:
CompletedProcess result
Raises:
subprocess.CalledProcessError: If command fails
"""
if self.dry_run and not any(read_only in cmd for read_only in
['status', 'branch', 'rev-parse', 'describe',
'log', 'remote']):
print(f"[DRY RUN] Would run: {' '.join(cmd)}")
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
cwd=self.project_root
)

View File

@@ -0,0 +1,23 @@
"""
Package registry implementations.
This module provides registry clients for publishing packages to various platforms:
- Gitea package registries
- PyPI and Test PyPI
- Extensible factory for custom registries
"""
from .factory import RegistryFactory
from .base import RegistryInterface, RegistryConfig
from .gitea.registry import GiteaRegistry
from .gitea.config import GiteaConfig
from .gitea.exceptions import GiteaError
__all__ = [
"RegistryFactory",
"RegistryInterface",
"RegistryConfig",
"GiteaRegistry",
"GiteaConfig",
"GiteaError",
]

View File

@@ -0,0 +1,101 @@
"""
Base registry interface and configuration.
This module defines the common interface that all registry implementations must follow.
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
@dataclass
class RegistryConfig:
"""Base configuration for package registries."""
name: str
type: str
url: str
auth_token_env: Optional[str] = None
def get_auth_token(self) -> Optional[str]:
"""Get authentication token from environment variable."""
if self.auth_token_env:
import os
return os.getenv(self.auth_token_env)
return None
class RegistryInterface(ABC):
"""Abstract interface for package registries."""
def __init__(self, config: RegistryConfig):
"""Initialize the registry with configuration."""
self.config = config
@abstractmethod
def upload_package(self, package_path: Path, dry_run: bool = False) -> bool:
"""Upload a package to the registry.
Args:
package_path: Path to package file (.whl or .tar.gz)
dry_run: If True, show what would be done without uploading
Returns:
True if upload successful, False otherwise
"""
pass
@abstractmethod
def check_auth(self) -> bool:
"""Check if authentication is properly configured.
Returns:
True if authenticated, False otherwise
"""
pass
@abstractmethod
def list_packages(self) -> List[Dict[str, Any]]:
"""List packages in the registry.
Returns:
List of package information dictionaries
"""
pass
@abstractmethod
def get_package_info(self, package_name: str) -> Optional[Dict[str, Any]]:
"""Get information about a specific package.
Args:
package_name: Name of the package
Returns:
Package information dictionary or None if not found
"""
pass
@abstractmethod
def delete_package_version(self, package_name: str, version: str,
dry_run: bool = False) -> bool:
"""Delete a specific version of a package.
Args:
package_name: Name of the package
version: Version to delete
dry_run: If True, show what would be done without deleting
Returns:
True if deletion successful, False otherwise
"""
pass
@abstractmethod
def get_registry_info(self) -> Dict[str, Any]:
"""Get information about the registry configuration.
Returns:
Dictionary with registry information
"""
pass

View File

@@ -0,0 +1,159 @@
"""
Registry factory for creating registry clients.
This module provides a factory for creating appropriate registry clients
based on configuration or registry type.
"""
from typing import Dict, Type, Optional, Any
from pathlib import Path
from .base import RegistryInterface, RegistryConfig
from .gitea.registry import GiteaRegistry
from .gitea.config import GiteaConfig
class RegistryFactory:
"""Factory for creating registry clients."""
_registry_types: Dict[str, Type[RegistryInterface]] = {
'gitea': GiteaRegistry,
}
@classmethod
def create(cls, registry_type: str, config: Optional[Dict[str, Any]] = None) -> RegistryInterface:
"""Create a registry client of the specified type.
Args:
registry_type: Type of registry ('gitea', 'pypi', etc.)
config: Optional configuration dictionary
Returns:
Registry client instance
Raises:
ValueError: If registry type is not supported
"""
if registry_type not in cls._registry_types:
raise ValueError(f"Unsupported registry type: {registry_type}. "
f"Supported types: {list(cls._registry_types.keys())}")
registry_class = cls._registry_types[registry_type]
# Handle Gitea-specific configuration
if registry_type == 'gitea':
if config:
gitea_config = GiteaConfig(
gitea_url=config.get('url', ''),
repo_owner=config.get('owner', ''),
repo_name=config.get('repo', ''),
auth_token=config.get('auth_token')
)
return registry_class(gitea_config)
else:
# Auto-detect from git repository
return registry_class()
# For other registry types, create with generic config
if config:
registry_config = RegistryConfig(
name=config.get('name', registry_type),
type=registry_type,
url=config['url'],
auth_token_env=config.get('auth_token_env')
)
return registry_class(registry_config)
else:
raise ValueError(f"Configuration required for {registry_type} registry")
@classmethod
def create_from_pyproject_config(cls, pyproject_path: Optional[Path] = None,
registry_name: str = 'gitea') -> RegistryInterface:
"""Create a registry client from pyproject.toml configuration.
Args:
pyproject_path: Path to pyproject.toml file. If None, looks in current directory.
registry_name: Name of registry configuration to use
Returns:
Registry client instance
Raises:
ValueError: If configuration is invalid or registry not found
"""
if pyproject_path is None:
pyproject_path = Path.cwd() / "pyproject.toml"
if not pyproject_path.exists():
raise ValueError(f"pyproject.toml not found at {pyproject_path}")
try:
import tomllib
except ImportError:
try:
import tomli as tomllib # Fallback for Python < 3.11
except ImportError:
raise ImportError("tomllib or tomli required to read pyproject.toml")
with open(pyproject_path, 'rb') as f:
config = tomllib.load(f)
# Look for release-management configuration
release_config = config.get('tool', {}).get('release-management', {})
registries_config = release_config.get('registries', {})
if registry_name not in registries_config:
raise ValueError(f"Registry '{registry_name}' not found in pyproject.toml configuration")
registry_config = registries_config[registry_name]
registry_type = registry_config.get('type', registry_name)
# Add auth token from environment if specified
auth_token_env = registry_config.get('auth_token_env')
if auth_token_env:
import os
registry_config = registry_config.copy()
registry_config['auth_token'] = os.getenv(auth_token_env)
return cls.create(registry_type, registry_config)
@classmethod
def auto_detect(cls) -> RegistryInterface:
"""Auto-detect registry type from current environment.
Currently only supports Gitea auto-detection from git repository.
Returns:
Registry client instance
Raises:
ValueError: If no registry can be auto-detected
"""
# Try Gitea auto-detection first
try:
return cls.create('gitea')
except Exception:
pass
raise ValueError("Could not auto-detect registry type. "
"Ensure you're in a git repository with Gitea remote, "
"or provide explicit configuration.")
@classmethod
def register_registry_type(cls, registry_type: str, registry_class: Type[RegistryInterface]) -> None:
"""Register a new registry type.
Args:
registry_type: String identifier for the registry type
registry_class: Registry class that implements RegistryInterface
"""
cls._registry_types[registry_type] = registry_class
@classmethod
def list_supported_types(cls) -> list[str]:
"""List all supported registry types.
Returns:
List of supported registry type strings
"""
return list(cls._registry_types.keys())

View File

@@ -0,0 +1,14 @@
"""
Gitea package registry implementation.
This module provides Gitea-specific registry functionality including:
- Package registry uploads
- Release asset fallback
- Configuration and authentication
"""
from .registry import GiteaRegistry
from .config import GiteaConfig
from .exceptions import GiteaError, GiteaConfigError
__all__ = ["GiteaRegistry", "GiteaConfig", "GiteaError", "GiteaConfigError"]

View File

@@ -172,4 +172,4 @@ class GiteaConfig:
def requires_auth(self, operation: str = "read") -> bool:
"""Check if operation requires authentication."""
write_operations = {"create", "update", "delete", "write"}
return operation in write_operations and not self.auth_token
return operation in write_operations and not self.auth_token

View File

@@ -0,0 +1,23 @@
"""
Gitea-specific exceptions.
"""
class GiteaError(Exception):
"""Base class for Gitea-related errors."""
pass
class GiteaConfigError(GiteaError):
"""Configuration-related errors."""
pass
class GiteaApiError(GiteaError):
"""API-related errors."""
pass
class GiteaAuthError(GiteaError):
"""Authentication-related errors."""
pass

View File

@@ -6,15 +6,15 @@ Gitea supports multiple package registries including PyPI-compatible registries.
"""
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Optional, List, Dict
from typing import Optional, List, Dict, Any
from ..base import RegistryInterface
from .config import GiteaConfig
from .exceptions import GiteaError
class GiteaPackageRegistry:
class GiteaRegistry(RegistryInterface):
"""Client for publishing packages to Gitea package registry."""
def __init__(self, config: Optional[GiteaConfig] = None):
@@ -50,7 +50,7 @@ class GiteaPackageRegistry:
except Exception:
return False
def list_packages(self) -> List[Dict]:
def list_packages(self) -> List[Dict[str, Any]]:
"""List all packages for this repository owner.
Returns:
@@ -68,7 +68,7 @@ class GiteaPackageRegistry:
except Exception as e:
raise GiteaError(f"Failed to list packages: {e}")
def get_package_info(self, package_name: str) -> Optional[Dict]:
def get_package_info(self, package_name: str) -> Optional[Dict[str, Any]]:
"""Get information about a specific package.
Args:
@@ -86,13 +86,38 @@ class GiteaPackageRegistry:
except Exception:
return None
def upload_package(self,
wheel_path: Path,
sdist_path: Optional[Path] = None,
dry_run: bool = False) -> bool:
"""Upload package files to Gitea registry.
def upload_package(self, package_path: Path, dry_run: bool = False) -> bool:
"""Upload a package to Gitea registry.
Args:
package_path: Path to package file (.whl or .tar.gz)
dry_run: If True, show what would be done without uploading
Returns:
True if upload successful, False otherwise
"""
if not self.config.auth_token:
raise GiteaError("Authentication token required for package upload. Set GITEA_API_TOKEN environment variable.")
if not package_path.exists():
raise GiteaError(f"Package file not found: {package_path}")
if dry_run:
print(f"[DRY RUN] Would upload to: {self.pypi_registry_url}")
print(f"[DRY RUN] Would upload: {package_path}")
return True
return self._upload_file(package_path)
def upload_package_as_release_assets(self,
version: str,
wheel_path: Path,
sdist_path: Optional[Path] = None,
dry_run: bool = False) -> bool:
"""Upload packages as Gitea release assets (fallback when package registry unavailable).
Args:
version: Version tag (e.g., "v0.8.0")
wheel_path: Path to wheel (.whl) file
sdist_path: Optional path to source distribution (.tar.gz) file
dry_run: If True, show what would be done without uploading
@@ -101,7 +126,7 @@ class GiteaPackageRegistry:
True if upload successful, False otherwise
"""
if not self.config.auth_token:
raise GiteaError("Authentication token required for package upload. Set GITEA_API_TOKEN environment variable.")
raise GiteaError("Authentication token required for release upload. Set GITEA_API_TOKEN environment variable.")
if not wheel_path.exists():
raise GiteaError(f"Wheel file not found: {wheel_path}")
@@ -114,61 +139,27 @@ class GiteaPackageRegistry:
files_to_upload.append(sdist_path)
if dry_run:
print(f"[DRY RUN] Would upload to: {self.pypi_registry_url}")
print(f"[DRY RUN] Would upload release assets for {version}")
print(f"[DRY RUN] Release API: {self.config.repo_api_url}/releases")
for file_path in files_to_upload:
print(f"[DRY RUN] Would upload: {file_path}")
return True
# Create or get release
release_id = self._create_or_get_release(version)
if not release_id:
return False
# Upload each file as release asset
success = True
for file_path in files_to_upload:
if not self._upload_file(file_path):
if not self._upload_release_asset(release_id, file_path):
success = False
return success
def _upload_file(self, file_path: Path) -> bool:
"""Upload a single file to the registry.
Args:
file_path: Path to file to upload
Returns:
True if upload successful, False otherwise
"""
try:
import requests
# Upload using multipart form data (PyPI-compatible)
with open(file_path, 'rb') as f:
files = {
'content': (file_path.name, f, 'application/octet-stream')
}
headers = {
'Authorization': f'token {self.config.auth_token}'
}
upload_url = f"{self.pypi_registry_url}/simple/"
response = requests.post(
upload_url,
headers=headers,
files=files,
timeout=60
)
if response.status_code in [200, 201, 409]: # 409 = already exists
print(f"✅ Uploaded: {file_path.name}")
if response.status_code == 409:
print(f" (already exists)")
return True
else:
print(f"❌ Upload failed for {file_path.name}: {response.status_code} {response.text}")
return False
except Exception as e:
print(f"❌ Upload failed for {file_path.name}: {e}")
return False
def delete_package_version(self, package_name: str, version: str, dry_run: bool = False) -> bool:
def delete_package_version(self, package_name: str, version: str,
dry_run: bool = False) -> bool:
"""Delete a specific version of a package.
Args:
@@ -205,7 +196,7 @@ class GiteaPackageRegistry:
print(f"❌ Delete failed: {e}")
return False
def get_registry_info(self) -> Dict:
def get_registry_info(self) -> Dict[str, Any]:
"""Get information about the package registry configuration.
Returns:
@@ -221,51 +212,144 @@ class GiteaPackageRegistry:
"auth_valid": self.check_auth() if self.config.auth_token else False
}
def _upload_file(self, file_path: Path) -> bool:
"""Upload a single file to the registry.
def configure_pip_for_gitea(config: Optional[GiteaConfig] = None,
pip_conf_path: Optional[Path] = None) -> Path:
"""Configure pip to use Gitea package registry as additional index.
Args:
file_path: Path to file to upload
Args:
config: Gitea configuration
pip_conf_path: Custom path for pip.conf file
Returns:
True if upload successful, False otherwise
"""
try:
import requests
Returns:
Path to created/updated pip.conf file
"""
config = config or GiteaConfig.from_git_repository()
# Gitea PyPI upload API expects PUT with the file content as body
# URL format: /api/packages/{owner}/pypi/{filename}
upload_url = f"{self.config.gitea_url}/api/packages/{self.config.repo_owner}/pypi"
if pip_conf_path is None:
# Default pip config location
pip_conf_path = Path.home() / ".pip" / "pip.conf"
with open(file_path, 'rb') as f:
file_content = f.read()
pip_conf_path.parent.mkdir(parents=True, exist_ok=True)
headers = {
'Authorization': f'token {self.config.auth_token}',
'Content-Type': 'application/octet-stream'
}
registry = GiteaPackageRegistry(config)
gitea_index = f"{registry.pypi_registry_url}/simple/"
# Upload using PUT request with filename in URL
upload_endpoint = f"{upload_url}/{file_path.name}"
response = requests.put(
upload_endpoint,
headers=headers,
data=file_content,
timeout=60
)
# Read existing config or create new
config_content = ""
if pip_conf_path.exists():
config_content = pip_conf_path.read_text()
if response.status_code in [200, 201, 409]: # 409 = already exists
print(f"✅ Uploaded: {file_path.name}")
if response.status_code == 409:
print(f" (already exists)")
return True
else:
print(f"❌ Upload failed for {file_path.name}: {response.status_code}")
if response.text:
print(f" Error: {response.text}")
return False
# Add Gitea index if not already present
if "extra-index-url" not in config_content:
if "[global]" not in config_content:
config_content = "[global]\n" + config_content
except Exception as e:
print(f"❌ Upload failed for {file_path.name}: {e}")
return False
lines = config_content.split('\n')
global_section_idx = next(i for i, line in enumerate(lines) if line.strip() == "[global]")
lines.insert(global_section_idx + 1, f"extra-index-url = {gitea_index}")
config_content = '\n'.join(lines)
elif gitea_index not in config_content:
# Add to existing extra-index-url
lines = config_content.split('\n')
for i, line in enumerate(lines):
if line.startswith("extra-index-url"):
lines[i] = f"{line} {gitea_index}"
break
config_content = '\n'.join(lines)
def _create_or_get_release(self, version: str) -> Optional[int]:
"""Create a new release or get existing release ID.
pip_conf_path.write_text(config_content)
return pip_conf_path
Args:
version: Version tag (e.g., "v0.8.0")
Returns:
Release ID if successful, None otherwise
"""
try:
import requests
# Ensure version has 'v' prefix
tag_name = version if version.startswith('v') else f'v{version}'
headers = {"Authorization": f"token {self.config.auth_token}"}
# First, try to get existing release
releases_url = f"{self.config.repo_api_url}/releases"
response = requests.get(releases_url, headers=headers, timeout=10)
if response.status_code == 200:
releases = response.json()
for release in releases:
if release.get('tag_name') == tag_name:
print(f"✅ Found existing release: {tag_name}")
return release['id']
# Create new release
release_data = {
"tag_name": tag_name,
"name": f"MarkiTect {version.lstrip('v')}",
"body": f"Release {version.lstrip('v')}\\n\\nPython packages for MarkiTect.",
"draft": False,
"prerelease": False
}
response = requests.post(releases_url, headers=headers, json=release_data, timeout=10)
if response.status_code == 201:
release = response.json()
print(f"✅ Created release: {tag_name}")
return release['id']
else:
print(f"❌ Failed to create release: {response.status_code} {response.text}")
return None
except Exception as e:
print(f"❌ Error managing release: {e}")
return None
def _upload_release_asset(self, release_id: int, file_path: Path) -> bool:
"""Upload a file as a release asset.
Args:
release_id: Gitea release ID
file_path: Path to file to upload
Returns:
True if upload successful, False otherwise
"""
try:
import requests
# Upload asset to Gitea release
upload_url = f"{self.config.repo_api_url}/releases/{release_id}/assets"
headers = {
"Authorization": f"token {self.config.auth_token}"
}
with open(file_path, 'rb') as f:
files = {
'attachment': (file_path.name, f, 'application/octet-stream')
}
response = requests.post(
upload_url,
headers=headers,
files=files,
timeout=120 # Larger timeout for file uploads
)
if response.status_code == 201:
print(f"✅ Uploaded release asset: {file_path.name}")
return True
else:
print(f"❌ Failed to upload {file_path.name}: {response.status_code} {response.text}")
return False
except Exception as e:
print(f"❌ Upload failed for {file_path.name}: {e}")
return False

View File

@@ -0,0 +1,11 @@
"""
Utilities for release management.
This module provides utility functions for version management,
validation, and other common operations.
"""
from .version import VersionManager
from .validation import ReleaseValidator
__all__ = ["VersionManager", "ReleaseValidator"]

View File

@@ -0,0 +1,230 @@
"""
Release validation utilities.
This module provides validation functions for release readiness.
"""
from pathlib import Path
from typing import List, Tuple, Optional
from ..git.manager import GitManager
class ReleaseValidator:
"""Validates release readiness and requirements."""
def __init__(self, project_root: Optional[Path] = None):
"""Initialize release validator.
Args:
project_root: Root directory of the project
"""
self.project_root = project_root or Path.cwd()
self.git_manager = GitManager(project_root)
def validate_release_state(self, force: bool = False) -> Tuple[bool, List[str]]:
"""Validate that repository is ready for release.
Args:
force: Skip validation checks if True
Returns:
Tuple of (is_valid, list_of_issues)
"""
if force:
return True, []
issues = []
# Git repository validation
git_issues = self._validate_git_state()
issues.extend(git_issues)
# Project structure validation
structure_issues = self._validate_project_structure()
issues.extend(structure_issues)
# Configuration validation
config_issues = self._validate_configuration()
issues.extend(config_issues)
return len(issues) == 0, issues
def _validate_git_state(self) -> List[str]:
"""Validate git repository state.
Returns:
List of git-related issues
"""
issues = []
status = self.git_manager.get_repository_status()
if not status['is_repo']:
issues.append("Not in a git repository")
return issues
if status['has_changes']:
issues.append("Repository has uncommitted changes")
if status['branch'] != 'main':
issues.append(f"Not on main branch (currently on {status['branch']})")
# Check if remote exists
remote_url = self.git_manager.get_remote_url()
if not remote_url:
issues.append("No git remote 'origin' configured")
return issues
def _validate_project_structure(self) -> List[str]:
"""Validate project structure for releases.
Returns:
List of project structure issues
"""
issues = []
# Check for required files
required_files = ['pyproject.toml']
for file_name in required_files:
file_path = self.project_root / file_name
if not file_path.exists():
issues.append(f"Missing required file: {file_name}")
# Check for setuptools-scm configuration
pyproject_path = self.project_root / 'pyproject.toml'
if pyproject_path.exists():
try:
import tomllib
except ImportError:
try:
import tomli as tomllib
except ImportError:
issues.append("Cannot read pyproject.toml (tomllib/tomli not available)")
return issues
try:
with open(pyproject_path, 'rb') as f:
config = tomllib.load(f)
# Check for setuptools-scm configuration
build_system = config.get('build-system', {})
if 'setuptools-scm' not in str(build_system.get('requires', [])):
issues.append("setuptools-scm not found in build-system.requires")
# Check for dynamic version
project_config = config.get('project', {})
if 'version' in project_config:
issues.append("Static version found in project config. Use dynamic versioning with setuptools-scm.")
dynamic = project_config.get('dynamic', [])
if 'version' not in dynamic:
issues.append("'version' not in project.dynamic. Add it for setuptools-scm.")
except Exception as e:
issues.append(f"Error reading pyproject.toml: {e}")
return issues
def _validate_configuration(self) -> List[str]:
"""Validate release configuration.
Returns:
List of configuration issues
"""
issues = []
# Check for environment variables that might be needed
import os
# Check for common auth tokens (warn, don't fail)
auth_vars = ['GITEA_API_TOKEN', 'PYPI_TOKEN', 'GITHUB_TOKEN']
available_auth = [var for var in auth_vars if os.getenv(var)]
if not available_auth:
issues.append("No authentication tokens found in environment. "
"Consider setting GITEA_API_TOKEN, PYPI_TOKEN, or GITHUB_TOKEN "
"for package publishing.")
return issues
def validate_version_string(self, version_string: str) -> Tuple[bool, List[str]]:
"""Validate a version string for release.
Args:
version_string: Version string to validate
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
if not version_string:
issues.append("Version string cannot be empty")
return False, issues
# Check basic format
if not version_string.replace('.', '').replace('-', '').replace('+', '').replace('a', '').replace('b', '').replace('rc', '').isalnum():
issues.append("Version string contains invalid characters")
# Check for development markers in release
dev_markers = ['dev', '.dev', '+dev']
if any(marker in version_string.lower() for marker in dev_markers):
issues.append("Development versions should not be released")
# Check for reasonable version format (semantic versioning)
try:
from packaging import version
version.Version(version_string)
except Exception:
issues.append("Version string is not valid according to PEP 440")
# Check if version already exists as git tag
tag_name = version_string if version_string.startswith('v') else f'v{version_string}'
if self.git_manager.tag_exists(tag_name):
issues.append(f"Git tag {tag_name} already exists")
return len(issues) == 0, issues
def get_validation_summary(self) -> dict:
"""Get a comprehensive validation summary.
Returns:
Dictionary with validation results
"""
is_valid, issues = self.validate_release_state()
return {
'is_valid': is_valid,
'issues': issues,
'git_status': self.git_manager.get_repository_status(),
'recommendations': self._get_recommendations(issues)
}
def _get_recommendations(self, issues: List[str]) -> List[str]:
"""Get recommendations based on validation issues.
Args:
issues: List of validation issues
Returns:
List of recommendations
"""
recommendations = []
if any('uncommitted changes' in issue for issue in issues):
recommendations.append("Commit or stash your changes before releasing")
if any('not on main branch' in issue for issue in issues):
recommendations.append("Switch to main branch: git checkout main")
if any('setuptools-scm' in issue for issue in issues):
recommendations.append("Configure setuptools-scm in pyproject.toml")
if any('authentication' in issue.lower() for issue in issues):
recommendations.append("Set up authentication tokens for package publishing")
if not issues:
recommendations.append("Repository is ready for release!")
return recommendations

View File

@@ -0,0 +1,191 @@
"""
Version management utilities.
This module provides utilities for working with versions and setuptools-scm.
"""
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any
from packaging import version
class VersionManager:
"""Utilities for version management with setuptools-scm."""
def __init__(self, project_root: Optional[Path] = None):
"""Initialize version manager.
Args:
project_root: Root directory of the project
"""
self.project_root = project_root or Path.cwd()
def get_current_version(self) -> str:
"""Get current version using setuptools-scm.
Returns:
Current version string or "unknown" if unavailable
"""
try:
result = subprocess.run(
['python', '-m', 'setuptools_scm'],
capture_output=True,
text=True,
check=True,
cwd=self.project_root
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return "unknown"
def parse_version(self, version_string: str) -> Dict[str, Any]:
"""Parse a version string and return components.
Args:
version_string: Version string to parse
Returns:
Dictionary with version components
"""
try:
v = version.Version(version_string)
return {
'major': v.major,
'minor': v.minor,
'micro': v.micro,
'is_prerelease': v.is_prerelease,
'is_devrelease': v.is_devrelease,
'local': v.local,
'public': v.public,
'base_version': v.base_version,
}
except version.InvalidVersion:
return {'error': f'Invalid version: {version_string}'}
def is_development_version(self, version_string: Optional[str] = None) -> bool:
"""Check if version is a development version.
Args:
version_string: Version to check. If None, uses current version.
Returns:
True if development version, False otherwise
"""
if version_string is None:
version_string = self.get_current_version()
try:
v = version.Version(version_string)
return v.is_devrelease or 'dev' in version_string.lower()
except version.InvalidVersion:
return True # Assume unknown versions are dev
def compare_versions(self, version1: str, version2: str) -> int:
"""Compare two version strings.
Args:
version1: First version to compare
version2: Second version to compare
Returns:
-1 if version1 < version2, 0 if equal, 1 if version1 > version2
"""
try:
v1 = version.Version(version1)
v2 = version.Version(version2)
if v1 < v2:
return -1
elif v1 > v2:
return 1
else:
return 0
except version.InvalidVersion:
# Fallback to string comparison
if version1 < version2:
return -1
elif version1 > version2:
return 1
else:
return 0
def get_next_version(self, current_version: str, bump_type: str = 'patch') -> str:
"""Get the next version based on bump type.
Args:
current_version: Current version string
bump_type: Type of bump ('major', 'minor', 'patch')
Returns:
Next version string
Raises:
ValueError: If bump_type is invalid
"""
try:
v = version.Version(current_version)
major, minor, micro = v.major, v.minor, v.micro
if bump_type == 'major':
return f"{major + 1}.0.0"
elif bump_type == 'minor':
return f"{major}.{minor + 1}.0"
elif bump_type == 'patch':
return f"{major}.{minor}.{micro + 1}"
else:
raise ValueError(f"Invalid bump type: {bump_type}")
except version.InvalidVersion:
raise ValueError(f"Cannot parse version: {current_version}")
def suggest_version(self, current_version: Optional[str] = None) -> Dict[str, str]:
"""Suggest next version options.
Args:
current_version: Current version. If None, gets from setuptools-scm.
Returns:
Dictionary with version suggestions
"""
if current_version is None:
current_version = self.get_current_version()
if current_version == "unknown":
return {
'error': 'Cannot determine current version',
'suggestion': 'Consider creating an initial tag like v0.1.0'
}
try:
# Strip development version info to get base
v = version.Version(current_version)
base_version = v.base_version
return {
'current': current_version,
'base': base_version,
'patch': self.get_next_version(base_version, 'patch'),
'minor': self.get_next_version(base_version, 'minor'),
'major': self.get_next_version(base_version, 'major'),
}
except Exception as e:
return {
'error': str(e),
'current': current_version
}
def validate_version_format(self, version_string: str) -> bool:
"""Validate if a version string follows semantic versioning.
Args:
version_string: Version string to validate
Returns:
True if valid semantic version, False otherwise
"""
try:
version.Version(version_string)
return True
except version.InvalidVersion:
return False

View File

@@ -1,35 +0,0 @@
"""
Gitea API facade - Clean interface for Gitea repository operations.
This package provides a clean, well-structured interface to Gitea API operations,
following the facade pattern to decouple application logic from specific API
implementation details.
Structure:
- client: Main GiteaClient facade
- models: Domain models (Issue, Milestone, Label, etc.)
- config: Gitea-specific configuration
- exceptions: Gitea-specific exceptions
Usage:
from gitea import GiteaClient
client = GiteaClient()
issues = client.issues.list()
issue = client.issues.get(42)
client.issues.create("Bug fix", "Description")
"""
from .client import GiteaClient
from .models import Issue, Milestone, Label, ProjectState, Priority
from .config import GiteaConfig
from .exceptions import GiteaError, GiteaAuthError, GiteaNotFoundError
from .package_registry import GiteaPackageRegistry
__all__ = [
'GiteaClient',
'Issue', 'Milestone', 'Label', 'ProjectState', 'Priority',
'GiteaConfig',
'GiteaError', 'GiteaAuthError', 'GiteaNotFoundError',
'GiteaPackageRegistry'
]

View File

@@ -1,241 +0,0 @@
"""
High-level API client that converts between API responses and domain models.
"""
from datetime import datetime
from typing import List, Optional, Dict, Any
from .http_client import GiteaHttpClient
from .models import Issue, Milestone, Label, User, IssueCreateData, IssueUpdateData, MilestoneCreateData, LabelCreateData
from .config import GiteaConfig
from .exceptions import GiteaNotFoundError, GiteaError
class GiteaApiClient:
"""High-level API client with domain model conversion."""
def __init__(self, config: GiteaConfig):
self.config = config
self.http = GiteaHttpClient(config)
# Issue operations
def get_issue(self, issue_number: int) -> Issue:
"""Get a specific issue by number."""
try:
url = f"{self.config.issues_api_url}/{issue_number}"
data = self.http.get(url)
return self._parse_issue(data)
except GiteaError as e:
if "not found" in str(e).lower():
raise GiteaNotFoundError(f"Issue #{issue_number} not found")
raise
def list_issues(self, state: str = "all", page: int = 1, per_page: int = 50) -> List[Issue]:
"""List issues with optional filtering."""
params = {"page": str(page), "limit": str(per_page)}
if state != "all":
params["state"] = state
data = self.http.get(self.config.issues_api_url, params)
if not isinstance(data, list):
raise GiteaError("Invalid response format: expected list of issues")
return [self._parse_issue(issue_data) for issue_data in data]
def create_issue(self, issue_data: IssueCreateData) -> Issue:
"""Create a new issue."""
payload = {
"title": issue_data.title,
"body": issue_data.body,
}
if issue_data.assignees:
payload["assignees"] = issue_data.assignees
if issue_data.milestone:
payload["milestone"] = issue_data.milestone
if issue_data.labels:
# Convert label names to label IDs
payload["labels"] = self._resolve_label_ids(issue_data.labels)
data = self.http.post(self.config.issues_api_url, payload)
return self._parse_issue(data)
def update_issue(self, issue_number: int, update_data: IssueUpdateData) -> Issue:
"""Update an existing issue."""
payload = {}
if update_data.title is not None:
payload["title"] = update_data.title
if update_data.body is not None:
payload["body"] = update_data.body
if update_data.state is not None:
payload["state"] = update_data.state
if update_data.assignees is not None:
payload["assignees"] = update_data.assignees
if update_data.milestone is not None:
payload["milestone"] = update_data.milestone
if update_data.labels is not None:
payload["labels"] = update_data.labels
url = f"{self.config.issues_api_url}/{issue_number}"
data = self.http.patch(url, payload)
return self._parse_issue(data)
# Milestone operations
def list_milestones(self, state: str = "all") -> List[Milestone]:
"""List repository milestones."""
params = {}
if state != "all":
params["state"] = state
data = self.http.get(self.config.milestones_api_url, params)
if not isinstance(data, list):
raise GiteaError("Invalid response format: expected list of milestones")
return [self._parse_milestone(milestone_data) for milestone_data in data]
def create_milestone(self, milestone_data: MilestoneCreateData) -> Milestone:
"""Create a new milestone."""
payload = {
"title": milestone_data.title,
"description": milestone_data.description,
}
if milestone_data.due_on:
payload["due_on"] = milestone_data.due_on
data = self.http.post(self.config.milestones_api_url, payload)
return self._parse_milestone(data)
# Label operations
def list_labels(self) -> List[Label]:
"""List repository labels."""
data = self.http.get(self.config.labels_api_url)
if not isinstance(data, list):
raise GiteaError("Invalid response format: expected list of labels")
return [self._parse_label(label_data) for label_data in data]
def create_label(self, label_data: LabelCreateData) -> Label:
"""Create a new label."""
payload = {
"name": label_data.name,
"color": label_data.color,
"description": label_data.description,
}
data = self.http.post(self.config.labels_api_url, payload)
return self._parse_label(data)
# Parsing methods
def _parse_issue(self, data: Dict[str, Any]) -> Issue:
"""Parse issue data from API response."""
try:
# Parse labels
labels = []
if data.get('labels'):
labels = [self._parse_label(label_data) for label_data in data['labels']]
# Parse assignee
assignee = None
if data.get('assignee'):
assignee = self._parse_user(data['assignee'])
# Parse milestone
milestone = None
if data.get('milestone'):
milestone = self._parse_milestone(data['milestone'])
# Check if this is an error response
if 'message' in data and 'url' in data and 'number' not in data and 'id' not in data:
raise GiteaError(f"API Error: {data.get('message', 'Unknown error')} (URL: {data.get('url', 'N/A')})")
# Handle both 'number' and 'id' fields (Gitea API might use either)
issue_number = data.get('number') or data.get('id')
if issue_number is None:
raise GiteaError(f"Issue response missing both 'number' and 'id' fields. Available fields: {list(data.keys())}")
return Issue(
number=issue_number,
title=data['title'],
body=data.get('body', ''),
state=data['state'],
created_at=self._parse_datetime(data['created_at']),
updated_at=self._parse_datetime(data['updated_at']),
html_url=data['html_url'],
assignee=assignee,
labels=labels,
milestone=milestone
)
except (KeyError, ValueError) as e:
raise GiteaError(f"Failed to parse issue data: {e}")
def _parse_milestone(self, data: Dict[str, Any]) -> Milestone:
"""Parse milestone data from API response."""
return Milestone(
id=data['id'],
title=data['title'],
description=data.get('description', ''),
state=data['state'],
open_issues=data.get('open_issues', 0),
closed_issues=data.get('closed_issues', 0),
due_on=data.get('due_on'),
created_at=self._parse_datetime(data.get('created_at')) if data.get('created_at') else None,
updated_at=self._parse_datetime(data.get('updated_at')) if data.get('updated_at') else None
)
def _parse_label(self, data: Dict[str, Any]) -> Label:
"""Parse label data from API response."""
return Label(
id=data['id'],
name=data['name'],
color=data['color'],
description=data.get('description', '')
)
def _parse_user(self, data: Dict[str, Any]) -> User:
"""Parse user data from API response."""
return User(
id=data['id'],
login=data['login'],
full_name=data.get('full_name', ''),
email=data.get('email', ''),
avatar_url=data.get('avatar_url', '')
)
def _parse_datetime(self, date_str: str) -> datetime:
"""Parse datetime from API response."""
# Remove Z and microseconds for consistent parsing
date_str = date_str.replace('Z', '').split('.')[0]
return datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S')
def _resolve_label_ids(self, label_names: List[str]) -> List[int]:
"""Convert label names to label IDs for API calls."""
try:
# Get all labels for the repository
labels_data = self.http.get(self.config.labels_api_url)
if not isinstance(labels_data, list):
raise GiteaError("Invalid labels response format")
# Create name-to-ID mapping
label_map = {label_data['name']: label_data['id'] for label_data in labels_data}
# Resolve names to IDs
label_ids = []
for name in label_names:
if name in label_map:
label_ids.append(label_map[name])
else:
# If label doesn't exist, we could create it or skip it
# For now, let's skip non-existent labels
print(f"Warning: Label '{name}' not found, skipping")
return label_ids
except Exception as e:
# If label resolution fails, proceed without labels rather than failing entirely
print(f"Warning: Could not resolve labels: {e}")
return []

View File

@@ -1,237 +0,0 @@
"""
Main Gitea client facade.
This provides a clean, organized interface for all Gitea operations,
following the facade pattern to hide complexity and provide a stable API.
"""
from typing import List, Optional, Dict, Any
from .config import GiteaConfig
from .api_client import GiteaApiClient
from .models import Issue, Milestone, Label, IssueCreateData, IssueUpdateData, MilestoneCreateData, LabelCreateData, ProjectState, Priority
class IssuesClient:
"""Client for issue operations."""
def __init__(self, api_client: GiteaApiClient):
self._api = api_client
def get(self, issue_number: int) -> Issue:
"""Get a specific issue by number."""
return self._api.get_issue(issue_number)
def list(self, state: str = "all", page: int = 1, per_page: int = 50) -> List[Issue]:
"""List issues with optional filtering."""
return self._api.list_issues(state, page, per_page)
def list_open(self) -> List[Issue]:
"""List only open issues."""
return self._api.list_issues("open")
def list_closed(self) -> List[Issue]:
"""List only closed issues."""
return self._api.list_issues("closed")
def create(self, title: str, body: str = "", **kwargs) -> Issue:
"""Create a new issue."""
issue_data = IssueCreateData(
title=title,
body=body,
assignees=kwargs.get('assignees', []),
milestone=kwargs.get('milestone'),
labels=kwargs.get('labels', [])
)
return self._api.create_issue(issue_data)
def update(self, issue_number: int, **kwargs) -> Issue:
"""Update an existing issue."""
update_data = IssueUpdateData(
title=kwargs.get('title'),
body=kwargs.get('body'),
state=kwargs.get('state'),
assignees=kwargs.get('assignees'),
milestone=kwargs.get('milestone'),
labels=kwargs.get('labels')
)
return self._api.update_issue(issue_number, update_data)
def close(self, issue_number: int) -> Issue:
"""Close an issue."""
return self.update(issue_number, state="closed")
def reopen(self, issue_number: int) -> Issue:
"""Reopen an issue."""
return self.update(issue_number, state="open")
def add_labels(self, issue_number: int, labels: List[str]) -> Issue:
"""Add labels to an issue."""
issue = self.get(issue_number)
existing_labels = [label.name for label in issue.labels]
new_labels = list(set(existing_labels + labels))
return self.update(issue_number, labels=new_labels)
def remove_labels(self, issue_number: int, labels: List[str]) -> Issue:
"""Remove labels from an issue."""
issue = self.get(issue_number)
existing_labels = [label.name for label in issue.labels]
new_labels = [label for label in existing_labels if label not in labels]
return self.update(issue_number, labels=new_labels)
def set_priority(self, issue_number: int, priority: Priority) -> Issue:
"""Set issue priority."""
issue = self.get(issue_number)
labels = [label.name for label in issue.labels if not label.name.startswith('priority:')]
labels.append(priority.value)
return self.update(issue_number, labels=labels)
def set_status(self, issue_number: int, status: ProjectState) -> Issue:
"""Set issue status."""
issue = self.get(issue_number)
labels = [label.name for label in issue.labels if not label.name.startswith('status:')]
labels.append(status.value)
return self.update(issue_number, labels=labels)
def assign_to_milestone(self, issue_number: int, milestone_id: int) -> Issue:
"""Assign issue to a milestone."""
return self.update(issue_number, milestone=milestone_id)
def remove_from_milestone(self, issue_number: int) -> Issue:
"""Remove issue from milestone."""
return self.update(issue_number, milestone=None)
def set_labels(self, issue_number: int, labels: List[str]) -> Issue:
"""Replace all labels on an issue."""
return self.update(issue_number, labels=labels)
def update_title(self, issue_number: int, title: str) -> Issue:
"""Update only the title of an issue."""
return self.update(issue_number, title=title)
def update_body(self, issue_number: int, body: str) -> Issue:
"""Update only the body of an issue."""
return self.update(issue_number, body=body)
def to_dict(self, issue: Issue) -> Dict[str, Any]:
"""Convert Issue object to dictionary format for backward 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': {
'id': issue.milestone.id,
'title': issue.milestone.title
} if issue.milestone else None
}
class MilestonesClient:
"""Client for milestone operations."""
def __init__(self, api_client: GiteaApiClient):
self._api = api_client
def list(self, state: str = "all") -> List[Milestone]:
"""List milestones."""
return self._api.list_milestones(state)
def list_open(self) -> List[Milestone]:
"""List open milestones."""
return self._api.list_milestones("open")
def list_closed(self) -> List[Milestone]:
"""List closed milestones."""
return self._api.list_milestones("closed")
def create(self, title: str, description: str = "", due_on: str = None) -> Milestone:
"""Create a new milestone."""
milestone_data = MilestoneCreateData(
title=title,
description=description,
due_on=due_on
)
return self._api.create_milestone(milestone_data)
class LabelsClient:
"""Client for label operations."""
def __init__(self, api_client: GiteaApiClient):
self._api = api_client
def list(self) -> List[Label]:
"""List all labels."""
return self._api.list_labels()
def create(self, name: str, color: str, description: str = "") -> Label:
"""Create a new label."""
label_data = LabelCreateData(
name=name,
color=color,
description=description
)
return self._api.create_label(label_data)
def ensure_project_labels(self) -> None:
"""Ensure all standard project management labels exist."""
existing_labels = [label.name for label in self.list()]
# Define standard project labels
standard_labels = [
("status:todo", "d73a4a", "Ready to work on"),
("status:active", "0075ca", "Currently being worked on"),
("status:review", "fbca04", "Ready for review"),
("status:done", "0e8a16", "Completed work"),
("status:blocked", "b60205", "Blocked by dependencies"),
("priority:low", "c5def5", "Low priority"),
("priority:medium", "a2eeef", "Medium priority"),
("priority:high", "fef2c0", "High priority"),
("priority:critical", "d93f0b", "Critical priority"),
]
for name, color, description in standard_labels:
if name not in existing_labels:
self.create(name, color, description)
class GiteaClient:
"""Main Gitea client facade."""
def __init__(self, config: Optional[GiteaConfig] = None):
"""Initialize Gitea client.
Args:
config: GiteaConfig instance. If None, auto-detects from git repository.
"""
if config is None:
try:
config = GiteaConfig.from_git_repository()
except Exception:
# Fallback to environment-based config if git detection fails
config = GiteaConfig.from_environment()
config.validate()
self.config = config
self._api = GiteaApiClient(config)
# Initialize sub-clients
self.issues = IssuesClient(self._api)
self.milestones = MilestonesClient(self._api)
self.labels = LabelsClient(self._api)
@classmethod
def from_tddai_config(cls, tddai_config) -> 'GiteaClient':
"""Create client from legacy TddaiConfig for backwards compatibility."""
gitea_config = GiteaConfig.from_tddai_config(tddai_config)
return cls(gitea_config)
def setup_project_management(self) -> None:
"""Setup standard project management labels and structure."""
self.labels.ensure_project_labels()

View File

@@ -1,31 +0,0 @@
"""
Gitea-specific exceptions.
"""
class GiteaError(Exception):
"""Base exception for Gitea API operations."""
pass
class GiteaAuthError(GiteaError):
"""Raised when authentication fails or token is missing."""
pass
class GiteaNotFoundError(GiteaError):
"""Raised when requested resource is not found."""
pass
class GiteaApiError(GiteaError):
"""Raised when API returns an error response."""
def __init__(self, message: str, status_code: int = None):
super().__init__(message)
self.status_code = status_code
class GiteaConfigError(GiteaError):
"""Raised when Gitea configuration is invalid or missing."""
pass

View File

@@ -1,98 +0,0 @@
"""
Low-level HTTP client for Gitea API operations.
This module handles the actual HTTP requests to Gitea API using subprocess + curl
for maximum compatibility and minimal dependencies.
"""
import json
import subprocess
from subprocess import PIPE
from typing import Dict, Any, Optional, List
from .exceptions import GiteaError, GiteaApiError, GiteaAuthError
from .config import GiteaConfig
class GiteaHttpClient:
"""Low-level HTTP client for Gitea API."""
def __init__(self, config: GiteaConfig):
self.config = config
def get(self, url: str, params: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Make GET request to Gitea API."""
if params:
param_string = '&'.join(f"{k}={v}" for k, v in params.items())
url = f"{url}?{param_string}"
return self._make_request('GET', url)
def post(self, url: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Make POST request to Gitea API."""
self._require_auth()
return self._make_request('POST', url, data)
def patch(self, url: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Make PATCH request to Gitea API."""
self._require_auth()
return self._make_request('PATCH', url, data)
def delete(self, url: str) -> Dict[str, Any]:
"""Make DELETE request to Gitea API."""
self._require_auth()
return self._make_request('DELETE', url)
def _make_request(self, method: str, url: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Make HTTP request using curl."""
cmd = ['curl', '-s', '-X', method]
# Add authentication if available
if self.config.auth_token:
cmd.extend(['-H', f'Authorization: token {self.config.auth_token}'])
# Add content type for requests with data
if data is not None:
cmd.extend(['-H', 'Content-Type: application/json'])
cmd.extend(['-d', json.dumps(data)])
cmd.append(url)
try:
result = subprocess.run(
cmd,
stdout=PIPE,
stderr=PIPE,
universal_newlines=True,
check=True
)
if result.returncode != 0:
raise GiteaApiError(f"HTTP request failed: {result.stderr}")
# Handle empty responses
if not result.stdout.strip():
return {}
response_data = json.loads(result.stdout)
# Check for API error responses
if isinstance(response_data, dict):
if 'message' in response_data:
# This could be an error or just a response with a message field
# We need to distinguish based on context or HTTP status
if any(error_word in response_data['message'].lower()
for error_word in ['error', 'not found', 'forbidden', 'unauthorized']):
raise GiteaApiError(response_data['message'])
return response_data
except subprocess.CalledProcessError as e:
raise GiteaApiError(f"HTTP request failed: {e.stderr}")
except json.JSONDecodeError as e:
raise GiteaError(f"Failed to parse API response: {e}")
def _require_auth(self):
"""Ensure authentication token is available."""
if not self.config.auth_token:
raise GiteaAuthError("Authentication token required for this operation")

View File

@@ -1,151 +0,0 @@
"""
Gitea domain models.
These models represent the core entities in Gitea and provide a clean interface
independent of the underlying API representation.
"""
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import List, Optional, Dict, Any
class ProjectState(Enum):
"""Standard project states using labels."""
TODO = "status:todo"
ACTIVE = "status:active"
REVIEW = "status:review"
DONE = "status:done"
BLOCKED = "status:blocked"
class Priority(Enum):
"""Priority levels using labels."""
LOW = "priority:low"
MEDIUM = "priority:medium"
HIGH = "priority:high"
CRITICAL = "priority:critical"
@dataclass
class Label:
"""Represents a Gitea issue label."""
id: int
name: str
color: str
description: str = ""
@dataclass
class User:
"""Represents a Gitea user."""
id: int
login: str
full_name: str = ""
email: str = ""
avatar_url: str = ""
@dataclass
class Milestone:
"""Represents a Gitea milestone (used as projects)."""
id: int
title: str
description: str
state: str # 'open' or 'closed'
open_issues: int
closed_issues: int
due_on: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@dataclass
class Issue:
"""Represents a Gitea issue."""
number: int
title: str
body: str
state: str # 'open' or 'closed'
created_at: datetime
updated_at: datetime
html_url: str
assignee: Optional[User] = None
labels: List[Label] = None
milestone: Optional[Milestone] = None
def __post_init__(self):
if self.labels is None:
self.labels = []
@property
def priority(self) -> Optional[str]:
"""Get issue priority from labels."""
for label in self.labels:
if label.name.startswith('priority:'):
return label.name.replace('priority:', '')
return None
@property
def status(self) -> Optional[str]:
"""Get issue status from labels."""
for label in self.labels:
if label.name.startswith('status:'):
return label.name.replace('status:', '')
return None
def has_label(self, label_name: str) -> bool:
"""Check if issue has a specific label."""
return any(label.name == label_name for label in self.labels)
def has_priority(self, priority: Priority) -> bool:
"""Check if issue has a specific priority."""
return self.has_label(priority.value)
def has_status(self, status: ProjectState) -> bool:
"""Check if issue has a specific status."""
return self.has_label(status.value)
@dataclass
class IssueCreateData:
"""Data for creating a new issue."""
title: str
body: str = ""
assignees: List[str] = None
milestone: Optional[int] = None
labels: List[str] = None
def __post_init__(self):
if self.assignees is None:
self.assignees = []
if self.labels is None:
self.labels = []
@dataclass
class IssueUpdateData:
"""Data for updating an existing issue."""
title: Optional[str] = None
body: Optional[str] = None
state: Optional[str] = None
assignees: Optional[List[str]] = None
milestone: Optional[int] = None
labels: Optional[List[str]] = None
@dataclass
class MilestoneCreateData:
"""Data for creating a new milestone."""
title: str
description: str = ""
due_on: Optional[str] = None
@dataclass
class LabelCreateData:
"""Data for creating a new label."""
name: str
color: str
description: str = ""

View File

@@ -8,7 +8,16 @@ dynamic = ["version"]
description = "Advanced Markdown engine for structured content"
readme = "README.md"
requires-python = ">=3.8"
dependencies = ["markdown-it-py", "PyYAML", "click>=8.0.0", "tabulate>=0.9.0", "jsonpath-ng>=1.5.0", "aiohttp>=3.8.0", "toml"]
dependencies = [
"markdown-it-py",
"PyYAML",
"click>=8.0.0",
"tabulate>=0.9.0",
"jsonpath-ng>=1.5.0",
"aiohttp>=3.8.0",
"toml",
"release-management @ file:./capabilities/release-management"
]
[project.optional-dependencies]
capabilities = [

View File

@@ -1,377 +0,0 @@
#!/usr/bin/env python3
"""
MarkiTect Release Management Tool (setuptools-scm version)
This simplified script works with setuptools-scm for automatic version management.
Versions are automatically derived from git tags - no manual version bumping needed.
Usage:
python release.py [command] [options]
Commands:
status Show current release status
validate Validate current state for release
tag Create git tag for version (e.g., v0.8.0)
build Build release packages
publish Complete release workflow (tag + build + distribute)
upload Upload packages to Gitea registry
registry Show Gitea package registry information
Options:
--version VERSION Git tag version (e.g., 0.8.0, 1.0.0-rc1)
--dry-run Show what would be done without making changes
--force Force operation even with warnings
--to-gitea Upload to Gitea package registry
"""
import subprocess
import argparse
import sys
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple
try:
from gitea.package_registry import GiteaPackageRegistry
GITEA_AVAILABLE = True
except ImportError:
GITEA_AVAILABLE = False
class SimpleReleaseManager:
"""Simplified release manager using setuptools-scm."""
def __init__(self, dry_run=False, force=False):
self.dry_run = dry_run
self.force = force
self.project_root = Path(__file__).parent.absolute()
def run_command(self, cmd: List[str], capture=True, check=True, skip_dry_run=False) -> subprocess.CompletedProcess:
"""Run a command with optional dry-run support."""
if self.dry_run and not skip_dry_run:
print(f"[DRY RUN] Would run: {' '.join(cmd)}")
return subprocess.CompletedProcess(cmd, 0, "", "")
return subprocess.run(cmd, capture_output=capture, text=True, check=check, cwd=self.project_root)
def get_current_version_from_scm(self) -> str:
"""Get current version using setuptools-scm."""
try:
result = self.run_command(['python', '-m', 'setuptools_scm'], skip_dry_run=True)
return result.stdout.strip()
except subprocess.CalledProcessError:
return "unknown"
def get_git_status(self) -> Dict[str, any]:
"""Get current git repository status."""
try:
# Get current branch
branch_result = self.run_command(['git', 'branch', '--show-current'], skip_dry_run=True)
current_branch = branch_result.stdout.strip()
# Check for uncommitted changes
status_result = self.run_command(['git', 'status', '--porcelain'], skip_dry_run=True)
has_changes = bool(status_result.stdout.strip())
# Get latest commit
commit_result = self.run_command(['git', 'rev-parse', '--short', 'HEAD'], skip_dry_run=True)
latest_commit = commit_result.stdout.strip()
# Get latest tag
try:
tag_result = self.run_command(['git', 'describe', '--tags', '--abbrev=0'], skip_dry_run=True)
latest_tag = tag_result.stdout.strip()
except subprocess.CalledProcessError:
latest_tag = None
return {
'is_repo': True,
'branch': current_branch,
'has_changes': has_changes,
'latest_commit': latest_commit,
'latest_tag': latest_tag
}
except subprocess.CalledProcessError:
return {'is_repo': False}
def validate_release_state(self) -> Tuple[bool, List[str]]:
"""Validate that the repository is ready for release."""
issues = []
git_status = self.get_git_status()
if not git_status['is_repo']:
issues.append("Not in a git repository")
else:
if git_status['has_changes'] and not self.force:
issues.append("Repository has uncommitted changes")
if git_status['branch'] != 'main' and not self.force:
issues.append(f"Not on main branch (currently on {git_status['branch']})")
return len(issues) == 0, issues
def create_git_tag(self, version: str, message: str = None):
"""Create and push git tag."""
if not version.startswith('v'):
tag_name = f"v{version}"
else:
tag_name = version
tag_message = message or f"Release {version}"
print(f"🏷️ Creating git tag {tag_name}")
# Create annotated tag
self.run_command(['git', 'tag', '-a', tag_name, '-m', tag_message])
print(f"✅ Tag {tag_name} created")
# Optionally push tag (can be done manually)
try:
print(f"📤 Pushing tag to origin...")
self.run_command(['git', 'push', 'origin', tag_name])
print(f"✅ Tag pushed to origin")
except subprocess.CalledProcessError as e:
print(f"⚠️ Could not push tag to origin: {e}")
print("You can push it manually with: git push origin " + tag_name)
def build_packages(self):
"""Build release packages using setuptools-scm."""
print(f"📦 Building packages (version will be auto-determined by setuptools-scm)")
# Clean previous builds
for pattern in ['build', 'dist', '*.egg-info']:
try:
self.run_command(['rm', '-rf', pattern])
except subprocess.CalledProcessError:
pass
# Build source distribution and wheel
print("Building packages...")
self.run_command(['python', '-m', 'build'], capture=False)
print("✅ Packages built successfully")
def show_status(self):
"""Show current release status."""
print("🔍 MarkiTect Release Status (setuptools-scm)")
print("=" * 60)
# Get version from setuptools-scm
scm_version = self.get_current_version_from_scm()
print(f"Current Version (setuptools-scm): {scm_version}")
git_status = self.get_git_status()
if git_status['is_repo']:
print(f"Git Branch: {git_status['branch']}")
print(f"Latest Commit: {git_status['latest_commit']}")
print(f"Latest Tag: {git_status['latest_tag'] or 'None'}")
print(f"Uncommitted Changes: {'Yes' if git_status['has_changes'] else 'No'}")
else:
print("Git Repository: Not available")
# Check build tools
print("\nBuild Tools:")
try:
self.run_command(['python', '-m', 'build', '--help'], skip_dry_run=True)
print("✅ build module available")
except (subprocess.CalledProcessError, FileNotFoundError):
print("❌ build module not available (pip install build)")
try:
self.run_command(['python', '-m', 'setuptools_scm'], skip_dry_run=True)
print("✅ setuptools-scm available")
except (subprocess.CalledProcessError, FileNotFoundError):
print("❌ setuptools-scm not available")
# Check existing packages
dist_dir = self.project_root / "dist"
if dist_dir.exists():
packages = list(dist_dir.glob("*"))
print(f"\nExisting Packages: {len(packages)} files in dist/")
for pkg in packages[-5:]: # Show last 5
print(f" - {pkg.name}")
else:
print("\nExisting Packages: None")
def publish_release(self, version: str):
"""Complete release workflow."""
print(f"🚀 Publishing release {version}")
# Validate state
is_valid, issues = self.validate_release_state()
if not is_valid and not self.force:
print("❌ Cannot publish release:")
for issue in issues:
print(f" - {issue}")
return False
# Create git tag (this determines the version for setuptools-scm)
self.create_git_tag(version)
# Build packages (setuptools-scm will use the tag for version)
self.build_packages()
print(f"✅ Release {version} completed!")
print("📦 Packages available in dist/")
print(f"🏷️ Git tag v{version} created")
return True
def upload_to_gitea(self, dry_run: bool = False) -> bool:
"""Upload packages to Gitea package registry."""
if not GITEA_AVAILABLE:
print("❌ Gitea package registry not available (missing gitea module)")
return False
try:
registry = GiteaPackageRegistry()
print(f"📡 Uploading to Gitea registry: {registry.pypi_registry_url}")
# Find built packages
dist_dir = self.project_root / "dist"
if not dist_dir.exists():
print("❌ No dist/ directory found. Run 'build' command first.")
return False
wheel_files = list(dist_dir.glob("*.whl"))
sdist_files = list(dist_dir.glob("*.tar.gz"))
if not wheel_files and not sdist_files:
print("❌ No package files found in dist/")
return False
# Upload each package
success = True
for wheel_file in wheel_files:
# Find matching sdist
sdist_file = None
for sdist in sdist_files:
if wheel_file.stem.split('-')[0] == sdist.stem.split('-')[0]:
sdist_file = sdist
break
if not registry.upload_package(wheel_file, sdist_file, dry_run=dry_run):
success = False
# Upload any remaining sdists
uploaded_sdists = []
for wheel_file in wheel_files:
for sdist in sdist_files:
if wheel_file.stem.split('-')[0] == sdist.stem.split('-')[0]:
uploaded_sdists.append(sdist)
for sdist_file in sdist_files:
if sdist_file not in uploaded_sdists:
if not registry.upload_package(sdist_file, dry_run=dry_run):
success = False
return success
except Exception as e:
print(f"❌ Upload to Gitea failed: {e}")
return False
def show_gitea_registry_info(self):
"""Show Gitea package registry information."""
if not GITEA_AVAILABLE:
print("❌ Gitea package registry not available (missing gitea module)")
return
try:
registry = GiteaPackageRegistry()
info = registry.get_registry_info()
print("📦 Gitea Package Registry Information")
print("=" * 50)
print(f"Gitea URL: {info['gitea_url']}")
print(f"Repository: {info['repo_owner']}/{info['repo_name']}")
print(f"PyPI Registry URL: {info['pypi_registry_url']}")
print(f"Package List URL: {info['package_list_url']}")
print(f"Authentication Configured: {'' if info['auth_configured'] else ''}")
print(f"Authentication Valid: {'' if info['auth_valid'] else '' if info['auth_configured'] else 'N/A'}")
if info['auth_configured']:
try:
packages = registry.list_packages()
print(f"\nExisting Packages: {len(packages)}")
for package in packages[:5]: # Show first 5
print(f" - {package.get('name', 'unknown')} (type: {package.get('type', 'unknown')})")
except Exception as e:
print(f"\nError listing packages: {e}")
else:
print("\n Set GITEA_API_TOKEN environment variable for package management")
except Exception as e:
print(f"❌ Error getting registry info: {e}")
def publish_with_gitea(self, version: str, dry_run: bool = False) -> bool:
"""Complete release workflow including Gitea upload."""
if not self.publish_release(version):
return False
if not self.upload_to_gitea(dry_run=dry_run):
print("⚠️ Release completed but Gitea upload failed")
return False
print("🎉 Complete release with Gitea upload successful!")
return True
def main():
parser = argparse.ArgumentParser(
description="MarkiTect Release Management Tool (setuptools-scm)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split('\n\n')[1]
)
parser.add_argument('command', choices=['status', 'validate', 'tag', 'build', 'publish', 'upload', 'registry'],
help='Release command to execute')
parser.add_argument('--version', type=str, help='Target version for git tag (e.g., 0.8.0)')
parser.add_argument('--dry-run', action='store_true', help='Show what would be done')
parser.add_argument('--force', action='store_true', help='Force operation despite warnings')
parser.add_argument('--to-gitea', action='store_true', help='Include Gitea package registry upload')
args = parser.parse_args()
manager = SimpleReleaseManager(dry_run=args.dry_run, force=args.force)
try:
if args.command == 'status':
manager.show_status()
elif args.command == 'validate':
is_valid, issues = manager.validate_release_state()
if is_valid:
print("✅ Repository is ready for release")
else:
print("❌ Release validation failed:")
for issue in issues:
print(f" - {issue}")
sys.exit(1)
elif args.command == 'tag':
if not args.version:
print("❌ --version is required for tag command")
sys.exit(1)
manager.create_git_tag(args.version)
elif args.command == 'build':
manager.build_packages()
elif args.command == 'publish':
if not args.version:
print("❌ --version is required for publish command")
sys.exit(1)
if args.to_gitea:
manager.publish_with_gitea(args.version, args.dry_run)
else:
manager.publish_release(args.version)
elif args.command == 'upload':
manager.upload_to_gitea(args.dry_run)
elif args.command == 'registry':
manager.show_gitea_registry_info()
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,174 @@
# Capability Discovery System
# Automatically discovers and includes capability Makefiles
# Find all capability directories that contain Makefiles
CAPABILITY_DIRS := $(dir $(wildcard capabilities/*/Makefile))
CAPABILITY_MAKEFILES := $(foreach dir,$(CAPABILITY_DIRS),$(dir)Makefile)
# DO NOT include capability Makefiles directly to avoid target conflicts
# Instead, use delegation patterns below for accessing capability targets
# Capability discovery and delegation system
.PHONY: capabilities-list
capabilities-list: ## List all available capabilities
@echo "🧩 Available Capabilities:"
@echo "=========================="
@for dir in $(CAPABILITY_DIRS); do \
if [ -f "$$dir/Makefile" ]; then \
echo ""; \
capability=$$(basename $$dir); \
echo "📦 $$capability"; \
echo " Location: $$dir"; \
if [ -f "$$dir/README.md" ]; then \
desc=$$(head -5 "$$dir/README.md" | grep -E "^[A-Z].*" | head -1 || echo "No description"); \
echo " Description: $$desc"; \
fi; \
echo " Makefile: Available"; \
fi; \
done
@echo ""
@echo "Use 'make capabilities-help' to see all capability targets"
.PHONY: capabilities-help
capabilities-help: ## Show help for all capabilities
@echo "🧩 All Capability Targets:"
@echo "=========================="
@for dir in $(CAPABILITY_DIRS); do \
if [ -f "$$dir/Makefile" ]; then \
echo ""; \
capability=$$(basename $$dir); \
echo "📦 $$capability capability:"; \
echo ""; \
$(MAKE) -C "$$dir" help 2>/dev/null | grep -E "^ " || echo " No help available"; \
fi; \
done
.PHONY: capabilities-install
capabilities-install: ## Install all capabilities
@echo "🔧 Installing all capabilities..."
@for dir in $(CAPABILITY_DIRS); do \
if [ -f "$$dir/pyproject.toml" ]; then \
capability=$$(basename $$dir); \
echo "Installing $$capability..."; \
pip install -e "$$dir" || echo "Failed to install $$capability"; \
fi; \
done
.PHONY: capabilities-test
capabilities-test: ## Run tests for all capabilities
@echo "🧪 Testing all capabilities..."
@for dir in $(CAPABILITY_DIRS); do \
if [ -f "$$dir/Makefile" ]; then \
capability=$$(basename $$dir); \
echo ""; \
echo "Testing $$capability..."; \
$(MAKE) -C "$$dir" test 2>/dev/null || echo "No tests or test failed for $$capability"; \
fi; \
done
# Create delegation targets for common patterns
# This allows calling capability targets directly from main makefile
# Release management delegation (special handling since it's commonly used)
.PHONY: release-%
release-%:
@if [ -f "capabilities/release-management/Makefile" ]; then \
$(MAKE) -C capabilities/release-management $@; \
else \
echo "❌ Release management capability not found"; \
exit 1; \
fi
# Generic capability delegation pattern
# Format: capability-name-target-name
define CAPABILITY_DELEGATION
.PHONY: $(1)-%
$(1)-%:
@if [ -f "capabilities/$(1)/Makefile" ]; then \
target_name=$$$$(echo "$$@" | sed 's/$(1)-//'); \
$(MAKE) -C "capabilities/$(1)" "$$$$target_name" || \
$(MAKE) -C "capabilities/$(1)" "$(1)-$$$$target_name" 2>/dev/null || \
echo "❌ Target '$$$$target_name' not found in $(1) capability"; \
else \
echo "❌ Capability '$(1)' not found or has no Makefile"; \
exit 1; \
fi
endef
# Generate delegation targets for each capability
$(foreach cap,$(patsubst capabilities/%/,%,$(CAPABILITY_DIRS)),$(eval $(call CAPABILITY_DELEGATION,$(cap))))
# Show available capability targets
.PHONY: capabilities-targets
capabilities-targets: ## Show all available capability targets
@echo "🎯 Available Capability Targets:"
@echo "================================"
@for dir in $(CAPABILITY_DIRS); do \
if [ -f "$$dir/Makefile" ]; then \
capability=$$(basename $$dir); \
echo ""; \
echo "📦 $$capability:"; \
$(MAKE) -C "$$dir" -qp 2>/dev/null | \
grep -E "^[a-zA-Z][^:]*:.*##" | \
sed 's/:.*##//' | \
while read target; do \
echo " $$capability-$$target"; \
echo " $@"; \
done || echo " No documented targets"; \
fi; \
done
# Capability status check
.PHONY: capabilities-status
capabilities-status: ## Show status of all capabilities
@echo "📊 Capability Status:"
@echo "===================="
@for dir in $(CAPABILITY_DIRS); do \
capability=$$(basename $$dir); \
echo ""; \
echo "📦 $$capability:"; \
if [ -f "$$dir/Makefile" ]; then \
echo " ✅ Makefile: Available"; \
else \
echo " ❌ Makefile: Missing"; \
fi; \
if [ -f "$$dir/pyproject.toml" ]; then \
echo " ✅ Package: Available"; \
if pip show "$$(echo $$capability | tr '-' '_')" >/dev/null 2>&1 || \
pip show "$$capability" >/dev/null 2>&1; then \
echo " ✅ Installed: Yes"; \
else \
echo " ❌ Installed: No"; \
fi; \
else \
echo " ❌ Package: Not available"; \
fi; \
if [ -f "$$dir/README.md" ]; then \
echo " ✅ Documentation: Available"; \
else \
echo " ❌ Documentation: Missing"; \
fi; \
done
# Auto-complete help integration
.PHONY: help-capabilities
help-capabilities: ## Show capability system help
@echo ""
@echo "🧩 Capability System:"
@echo " capabilities-list List all available capabilities"
@echo " capabilities-help Show help for all capabilities"
@echo " capabilities-status Show status of all capabilities"
@echo " capabilities-install Install all capabilities"
@echo " capabilities-test Test all capabilities"
@echo " capabilities-targets Show all available capability targets"
@echo ""
@echo "🎯 Capability Delegation:"
@echo " Use 'capability-name-target' to call targets from capabilities"
@echo " Example: 'make release-management-status' calls status from release-management"
@echo " Special: release-* targets are delegated to release-management capability"
@echo ""
# Export capability information for use by main Makefile
CAPABILITIES_FOUND := $(words $(CAPABILITY_DIRS))
export CAPABILITIES_FOUND
export CAPABILITY_DIRS