Add release notes extraction from CHANGELOG for publishing: - Create ChangelogParser class to extract version sections from CHANGELOG - Support multiple output formats: markdown, plain text, HTML - Add 'release notes VERSION' CLI command to extract notes - Auto-detect latest version if not specified - Support piping to gh/gitea release commands - Save to file with --output option - Plain text format removes markdown formatting - HTML format converts markdown to HTML This streamlines creating release notes for GitHub/Gitea releases by extracting CHANGELOG content automatically. Usage: release notes 0.10.0 # Extract markdown notes release notes # Latest version release notes 0.10.0 --format plain # Plain text release notes 0.10.0 -o notes.md # Save to file release notes 0.10.0 | gh release create v0.10.0 -F - 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
416 lines
14 KiB
Python
416 lines
14 KiB
Python
"""
|
|
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
|
|
from ..changelog.editor import ChangelogEditor
|
|
from ..changelog.parser import ChangelogParser
|
|
from ..summary.generator import SummaryGenerator
|
|
|
|
|
|
@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'}")
|
|
|
|
# Show unpushed tags
|
|
unpushed_tags = status_info.get('unpushed_tags', [])
|
|
if unpushed_tags:
|
|
print(f"\n⚠️ Unpushed Tags: {len(unpushed_tags)} tag(s) not pushed to origin")
|
|
for tag in unpushed_tags:
|
|
print(f" - {tag}")
|
|
print(f"\n💡 Push tags with: git push origin {' '.join(unpushed_tags)}")
|
|
print(f" Or push all tags: git push --tags")
|
|
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.option('--push/--no-push', default=True,
|
|
help='Automatically push tag to origin (default: --push)')
|
|
@click.pass_context
|
|
def tag(ctx, version: str, message: Optional[str], push: bool):
|
|
"""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, push=push):
|
|
print(f"✅ Successfully created tag for version {version}")
|
|
if not push:
|
|
print(f"💡 Push tag with: git push origin v{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}")
|
|
|
|
|
|
@main.command('check-consistency')
|
|
@click.option('--version', required=True, help='Version to check (e.g., 0.10.0)')
|
|
@click.pass_context
|
|
def check_consistency(ctx, version: str):
|
|
"""Check consistency between CHANGELOG version and git tags."""
|
|
manager = ReleaseManager(
|
|
project_root=ctx.obj['project_root'],
|
|
dry_run=ctx.obj['dry_run'],
|
|
force=ctx.obj['force']
|
|
)
|
|
|
|
is_consistent, issues = manager.check_version_consistency(version)
|
|
|
|
if is_consistent:
|
|
print(f"✅ Version {version} is consistent:")
|
|
print(f" - CHANGELOG has section for {version}")
|
|
print(f" - Git tag v{version} exists")
|
|
print(f" - [Unreleased] section present")
|
|
else:
|
|
print(f"❌ Version {version} has consistency issues:")
|
|
for issue in issues:
|
|
print(f" - {issue}")
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command('prepare')
|
|
@click.argument('version')
|
|
@click.option('--date', default=None, help='Release date (YYYY-MM-DD, defaults to today)')
|
|
@click.pass_context
|
|
def prepare(ctx, version: str, date: Optional[str]):
|
|
"""Prepare CHANGELOG for new version release.
|
|
|
|
Creates a new version section in CHANGELOG.md and moves all content
|
|
from the [Unreleased] section to the new version section.
|
|
"""
|
|
project_root = ctx.obj['project_root'] or Path.cwd()
|
|
changelog_path = project_root / 'CHANGELOG.md'
|
|
|
|
editor = ChangelogEditor(changelog_path)
|
|
|
|
# Create version section
|
|
if editor.create_version_section(version, date):
|
|
# Validate result
|
|
manager = ReleaseManager(
|
|
project_root=ctx.obj['project_root'],
|
|
dry_run=ctx.obj['dry_run'],
|
|
force=ctx.obj['force']
|
|
)
|
|
|
|
# Check if CHANGELOG is valid after edit
|
|
is_valid, issues = manager.validate_release_state()
|
|
|
|
if is_valid:
|
|
print("\n✅ CHANGELOG validation passed")
|
|
else:
|
|
print("\n⚠️ CHANGELOG validation issues after edit:")
|
|
for issue in issues:
|
|
if 'CHANGELOG' in issue:
|
|
print(f" - {issue}")
|
|
else:
|
|
print(f"❌ Failed to prepare CHANGELOG for version {version}")
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command('summary')
|
|
@click.argument('version')
|
|
@click.option('--output', '-o', default=None, type=click.Path(path_type=Path),
|
|
help='Output file path (defaults to RELEASE_SUMMARY_vX.Y.Z.md)')
|
|
@click.pass_context
|
|
def summary(ctx, version: str, output: Optional[Path]):
|
|
"""Generate release summary document.
|
|
|
|
Extracts CHANGELOG content, git statistics, build artifacts, and
|
|
validation results to create a comprehensive release summary.
|
|
"""
|
|
project_root = ctx.obj['project_root'] or Path.cwd()
|
|
|
|
# Default output path
|
|
if output is None:
|
|
version_clean = version.lstrip('v')
|
|
output = project_root / f"RELEASE_SUMMARY_v{version_clean}.md"
|
|
elif not output.is_absolute():
|
|
output = project_root / output
|
|
|
|
generator = SummaryGenerator(project_root)
|
|
|
|
try:
|
|
content = generator.generate(version, output_path=output)
|
|
print(f"\n✅ Release summary generated successfully")
|
|
print(f"📄 Summary saved to: {output}")
|
|
except Exception as e:
|
|
print(f"❌ Failed to generate release summary: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
@main.command('notes')
|
|
@click.argument('version', required=False)
|
|
@click.option('--format', 'output_format', type=click.Choice(['markdown', 'plain', 'html']),
|
|
default='markdown', help='Output format (default: markdown)')
|
|
@click.option('--output', '-o', default=None, type=click.Path(path_type=Path),
|
|
help='Save to file instead of stdout')
|
|
@click.pass_context
|
|
def notes(ctx, version: Optional[str], output_format: str, output: Optional[Path]):
|
|
"""Extract release notes from CHANGELOG.md.
|
|
|
|
Extracts the CHANGELOG section for a specific version and outputs it
|
|
in various formats. Useful for creating GitHub/Gitea release notes.
|
|
|
|
If no version is specified, uses the latest version from CHANGELOG.
|
|
|
|
Examples:
|
|
release notes 0.10.0 # Extract v0.10.0 notes (markdown)
|
|
release notes # Extract latest version notes
|
|
release notes 0.10.0 --format plain # Plain text format
|
|
release notes 0.10.0 -o notes.md # Save to file
|
|
release notes 0.10.0 | gh release create v0.10.0 -F - # Pipe to gh
|
|
"""
|
|
project_root = ctx.obj['project_root'] or Path.cwd()
|
|
changelog_path = project_root / 'CHANGELOG.md'
|
|
|
|
parser = ChangelogParser(changelog_path)
|
|
|
|
# Get version (use latest if not specified)
|
|
if version is None:
|
|
version = parser.get_latest_version()
|
|
if version is None:
|
|
print("❌ Could not determine version from CHANGELOG.md")
|
|
sys.exit(1)
|
|
print(f"Using latest version: {version}", file=sys.stderr)
|
|
|
|
# Extract content
|
|
content = parser.extract_version_section(version, format=output_format)
|
|
|
|
# Check for errors
|
|
if content.startswith("Error:") or content.startswith("Warning:"):
|
|
print(content)
|
|
sys.exit(1 if content.startswith("Error:") else 0)
|
|
|
|
# Output
|
|
if output:
|
|
if not output.is_absolute():
|
|
output = project_root / output
|
|
output.write_text(content)
|
|
print(f"✅ Release notes written to {output}", file=sys.stderr)
|
|
else:
|
|
print(content)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |