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:
@@ -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"]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user