Files
markitect-main/capabilities/release-management/src/release_management/cli/main.py
tegwick 843f579305 feat: implement optimization #9 - release notes from CHANGELOG
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>
2026-01-06 21:49:09 +01:00

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()