diff --git a/capabilities/release-management/src/release_management/cli/main.py b/capabilities/release-management/src/release_management/cli/main.py index 2fda88e0..051f60cd 100644 --- a/capabilities/release-management/src/release_management/cli/main.py +++ b/capabilities/release-management/src/release_management/cli/main.py @@ -55,6 +55,15 @@ def status(ctx): 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") diff --git a/capabilities/release-management/src/release_management/git/manager.py b/capabilities/release-management/src/release_management/git/manager.py index 59fa162e..515873e4 100644 --- a/capabilities/release-management/src/release_management/git/manager.py +++ b/capabilities/release-management/src/release_management/git/manager.py @@ -48,12 +48,16 @@ class GitManager: except subprocess.CalledProcessError: latest_tag = None + # Get unpushed tags + unpushed_tags = self.get_unpushed_tags() + return { 'is_repo': True, 'branch': current_branch, 'has_changes': has_changes, 'latest_commit': latest_commit, - 'latest_tag': latest_tag + 'latest_tag': latest_tag, + 'unpushed_tags': unpushed_tags } except subprocess.CalledProcessError: return {'is_repo': False} @@ -178,6 +182,47 @@ class GitManager: except subprocess.CalledProcessError: return None + def get_unpushed_tags(self, remote: str = 'origin') -> List[str]: + """Get list of tags that exist locally but not on remote. + + Args: + remote: Remote name to compare against (default: 'origin') + + Returns: + List of unpushed tag names + """ + try: + # Get local tags + local_result = self._run_command(['git', 'tag', '-l']) + local_tags = set(tag.strip() for tag in local_result.stdout.strip().split('\n') if tag.strip()) + + # Get remote tags + try: + remote_result = self._run_command(['git', 'ls-remote', '--tags', remote]) + remote_lines = remote_result.stdout.strip().split('\n') + + # Parse remote tags (format: "hash refs/tags/tagname") + remote_tags = set() + for line in remote_lines: + if not line: + continue + parts = line.split('refs/tags/') + if len(parts) > 1: + # Remove ^{} suffix for annotated tags + tag_name = parts[1].replace('^{}', '') + remote_tags.add(tag_name) + + # Find tags that are local but not remote + unpushed = sorted(local_tags - remote_tags) + return unpushed + + except subprocess.CalledProcessError: + # Remote not available, assume all tags are unpushed + return sorted(local_tags) + + except subprocess.CalledProcessError: + return [] + def _run_command(self, cmd: List[str]) -> subprocess.CompletedProcess: """Run a git command. diff --git a/roadmap/260106-release-management-optimization/IMPLEMENTATION_PLAN.md b/roadmap/260106-release-management-optimization/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..24bdabdb --- /dev/null +++ b/roadmap/260106-release-management-optimization/IMPLEMENTATION_PLAN.md @@ -0,0 +1,587 @@ +# Release Management Optimization Implementation Plan + +**Date**: 2026-01-06 +**Status**: Ready to implement +**Total Optimizations**: 9 + +--- + +## Implementation Order + +### Phase 1: High Priority (Critical Issues) - 5 hours +1. Git status enhancement for unpushed tags (1 hour) +2. Automated tag pushing (1 hour) +3. CHANGELOG validation in release flow (2 hours) +4. Version-tag consistency check (1 hour) + +### Phase 2: Medium Priority (UX & Automation) - 5.5 hours +5. CHANGELOG section generation (3 hours) +6. Explicit version command (30 minutes) +7. Release summary auto-generation (2 hours) + +### Phase 3: Low Priority (Nice to Have) - 3 hours +8. Schema auto-ingestion (1 hour) +9. Release notes from CHANGELOG (2 hours) + +**Total Estimated Time**: 13.5 hours + +--- + +## Optimization #1: Git Status Enhancement for Unpushed Tags + +**Priority**: HIGH +**Estimated**: 1 hour +**Files**: `capabilities/release-management/src/release_management/core/status.py` + +### Implementation Approach + +**Option 1**: Enhance `release status` command (RECOMMENDED) +- Add unpushed tag detection to ReleaseStatus class +- Compare local tags with remote tags +- Display unpushed tags prominently + +**Option 2**: Git post-commit hook +- Create .git/hooks/post-commit script +- Automatic check after each commit +- Less portable (per-clone setup) + +**Option 3**: Git alias +- Add custom git alias in .gitconfig +- User needs to remember to use it + +### Implementation Details + +```python +# In ReleaseStatus class +def get_unpushed_tags(self) -> List[str]: + """Get list of tags not pushed to origin.""" + # Get local tags + local_tags = subprocess.run( + ['git', 'tag', '-l'], + capture_output=True, text=True + ).stdout.strip().split('\n') + + # Get remote tags + remote_tags = subprocess.run( + ['git', 'ls-remote', '--tags', 'origin'], + capture_output=True, text=True + ).stdout + + remote_tag_names = [ + line.split('refs/tags/')[1] + for line in remote_tags.split('\n') + if 'refs/tags/' in line + ] + + return [tag for tag in local_tags if tag and tag not in remote_tag_names] +``` + +### Success Criteria +- āœ… `release status` shows unpushed tags +- āœ… Clear warning when tags haven't been pushed +- āœ… Works with multiple remotes + +--- + +## Optimization #2: Automated Tag Pushing + +**Priority**: HIGH +**Estimated**: 1 hour +**Files**: `capabilities/release-management/src/release_management/cli/main.py` + +### Implementation + +Add `--push` flag to `release tag` command: + +```python +@click.command() +@click.argument('version') +@click.option('--push/--no-push', default=False, + help='Automatically push tag to origin after creating') +@click.option('--message', '-m', help='Tag annotation message') +def tag(version, push, message): + """Create git tag for version.""" + # Existing tag creation logic + + if push: + click.echo(f"Pushing tag {tag_name} to origin...") + git_manager.push_tag(tag_name) + click.echo("āœ… Tag pushed successfully") +``` + +### Success Criteria +- āœ… `release tag v0.11.0 --push` creates AND pushes tag +- āœ… Works with existing tag logic +- āœ… Error handling for push failures + +--- + +## Optimization #3: CHANGELOG Validation in Release Flow + +**Priority**: HIGH +**Estimated**: 2 hours +**Files**: +- `capabilities/release-management/src/release_management/validators/changelog_validator.py` (new) +- `capabilities/release-management/src/release_management/cli/main.py` + +### Implementation + +Create ChangelogValidator class: + +```python +class ChangelogValidator: + """Validates CHANGELOG.md using changelog schema.""" + + def __init__(self, changelog_path: Path = Path("CHANGELOG.md")): + self.changelog_path = changelog_path + self.schema_path = Path("markitect/schemas/changelog-schema-v1.0.md") + + def validate(self) -> ValidationResult: + """Validate CHANGELOG with schema.""" + # Use markitect validate command + result = subprocess.run([ + 'markitect', 'validate', str(self.changelog_path), + '--schema', str(self.schema_path), + '--semantic' + ], capture_output=True, text=True) + + return ValidationResult.from_output(result.stdout, result.returncode) + + def check_version_exists(self, version: str) -> bool: + """Check if version section exists in CHANGELOG.""" + with open(self.changelog_path) as f: + content = f.read() + return f"## [{version}]" in content +``` + +Integrate into `release validate` command: + +```python +@click.command() +def validate(): + """Validate repository state for release readiness.""" + # Existing validations... + + # Add CHANGELOG validation + changelog_validator = ChangelogValidator() + result = changelog_validator.validate() + + if not result.is_valid: + click.echo("āŒ CHANGELOG validation failed:") + for error in result.errors: + click.echo(f" - {error}") + sys.exit(1) + + click.echo("āœ… CHANGELOG is valid") +``` + +### Success Criteria +- āœ… `release validate` checks CHANGELOG.md +- āœ… Validates using changelog-schema-v1.0.md +- āœ… Reports errors clearly +- āœ… Prevents release if invalid + +--- + +## Optimization #4: Version-Tag Consistency Check + +**Priority**: HIGH +**Estimated**: 1 hour +**Files**: `capabilities/release-management/src/release_management/validators/changelog_validator.py` + +### Implementation + +Add to ChangelogValidator: + +```python +def check_version_tag_consistency(self, target_version: str) -> ConsistencyResult: + """Check CHANGELOG version matches git describe.""" + # Check CHANGELOG has section + if not self.check_version_exists(target_version): + return ConsistencyResult( + is_consistent=False, + message=f"CHANGELOG missing section for {target_version}" + ) + + # Check git tag exists + tags = subprocess.run( + ['git', 'tag', '-l', f'v{target_version}'], + capture_output=True, text=True + ).stdout.strip() + + if not tags: + return ConsistencyResult( + is_consistent=False, + message=f"Git tag v{target_version} doesn't exist" + ) + + # Check Unreleased section exists + with open(self.changelog_path) as f: + if "## [Unreleased]" not in f.read(): + return ConsistencyResult( + is_consistent=False, + message="CHANGELOG missing [Unreleased] section" + ) + + return ConsistencyResult(is_consistent=True) +``` + +### Success Criteria +- āœ… Detects CHANGELOG/tag mismatches +- āœ… Ensures Unreleased section exists +- āœ… Integrated into `release validate` + +--- + +## Optimization #5: CHANGELOG Section Generation + +**Priority**: MEDIUM +**Estimated**: 3 hours +**Files**: +- `capabilities/release-management/src/release_management/changelog/editor.py` (new) +- `capabilities/release-management/src/release_management/cli/main.py` + +### Implementation + +Create ChangelogEditor class: + +```python +class ChangelogEditor: + """Edit CHANGELOG.md programmatically.""" + + def create_version_section(self, version: str, date: str = None): + """Create new version section and move Unreleased content.""" + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + + with open(self.changelog_path) as f: + lines = f.readlines() + + # Find Unreleased section + unreleased_idx = None + for i, line in enumerate(lines): + if line.strip() == "## [Unreleased]": + unreleased_idx = i + break + + if unreleased_idx is None: + raise ValueError("No [Unreleased] section found") + + # Find next version section or end + next_section_idx = None + for i in range(unreleased_idx + 1, len(lines)): + if lines[i].startswith("## ["): + next_section_idx = i + break + + # Extract Unreleased content + if next_section_idx: + unreleased_content = lines[unreleased_idx+1:next_section_idx] + else: + unreleased_content = lines[unreleased_idx+1:] + + # Create new version section + new_section = [ + f"## [{version}] - {date}\n", + "\n" + ] + unreleased_content + ["\n"] + + # Insert after Unreleased + new_lines = ( + lines[:unreleased_idx+2] + # Keep Unreleased header + blank line + new_section + + (lines[next_section_idx:] if next_section_idx else []) + ) + + # Write back + with open(self.changelog_path, 'w') as f: + f.writelines(new_lines) +``` + +Add `release prepare` command: + +```python +@click.command() +@click.argument('version') +@click.option('--date', default=None, help='Release date (YYYY-MM-DD)') +def prepare(version, date): + """Prepare CHANGELOG for new version release.""" + editor = ChangelogEditor() + editor.create_version_section(version, date) + + # Validate result + validator = ChangelogValidator() + result = validator.validate() + + if result.is_valid: + click.echo(f"āœ… Created [{version}] section in CHANGELOG.md") + else: + click.echo("āš ļø CHANGELOG validation failed after edit") +``` + +### Success Criteria +- āœ… `release prepare v0.11.0` creates section +- āœ… Moves Unreleased content to new section +- āœ… Validates result +- āœ… Preserves formatting + +--- + +## Optimization #6: Explicit Version Command + +**Priority**: MEDIUM +**Estimated**: 30 minutes +**Files**: `markitect/cli.py` + +### Implementation + +Add version subcommand to markitect CLI: + +```python +@cli.command() +def version(): + """Show detailed version information.""" + from markitect.__version__ import get_version_info + + info = get_version_info() + + click.echo(f"MarkiTect version: {info['version']}") + click.echo(f"Latest git tag: {info.get('latest_tag', 'N/A')}") + click.echo(f"Commits since tag: {info.get('commits_since_tag', 'N/A')}") + click.echo(f"Working tree: {'clean' if info.get('clean', False) else 'dirty'}") + click.echo(f"Current commit: {info.get('commit_hash', 'N/A')}") +``` + +### Success Criteria +- āœ… `markitect version` works +- āœ… Shows more detail than `--version` +- āœ… Backwards compatible with `--version` + +--- + +## Optimization #7: Release Summary Auto-Generation + +**Priority**: MEDIUM +**Estimated**: 2 hours +**Files**: +- `capabilities/release-management/src/release_management/summary/generator.py` (new) +- `capabilities/release-management/src/release_management/cli/main.py` + +### Implementation + +Create SummaryGenerator: + +```python +class SummaryGenerator: + """Generate release summary from CHANGELOG and git metadata.""" + + def generate(self, version: str) -> str: + """Generate RELEASE_SUMMARY.md content.""" + # Extract CHANGELOG section + changelog_section = self.extract_changelog_section(version) + + # Get git statistics + stats = self.get_git_statistics(version) + + # Build summary + template = f"""# MarkiTect {version} Release Summary + +**Release Date**: {stats['release_date']} +**Tag**: v{version} + +## Changes + +{changelog_section} + +## Git Statistics + +- **Commits**: {stats['commit_count']} +- **Files Changed**: {stats['files_changed']} +- **Insertions**: +{stats['insertions']} +- **Deletions**: -{stats['deletions']} + +## Build Artifacts + +{self.list_build_artifacts()} + +## Validation + +{self.get_validation_results()} +""" + return template +``` + +Add `release summary` command: + +```python +@click.command() +@click.argument('version') +@click.option('--output', '-o', default='RELEASE_SUMMARY.md', + help='Output file path') +def summary(version, output): + """Generate release summary document.""" + generator = SummaryGenerator() + content = generator.generate(version) + + Path(output).write_text(content) + click.echo(f"āœ… Generated {output}") +``` + +### Success Criteria +- āœ… Extracts CHANGELOG section +- āœ… Includes git statistics +- āœ… Lists build artifacts +- āœ… Saves to file + +--- + +## Optimization #8: Schema Auto-Ingestion + +**Priority**: LOW +**Estimated**: 1 hour +**Files**: `markitect/schema_loader.py` + +### Implementation + +Add auto-ingestion on build/install: + +```python +def auto_ingest_schemas(): + """Automatically ingest schemas from markitect/schemas/.""" + schema_dir = Path(__file__).parent / "schemas" + + for schema_file in schema_dir.glob("*-schema-v*.md"): + # Check if already ingested + if not is_schema_ingested(schema_file): + ingest_schema(schema_file) +``` + +Call from setup.py or as post-install hook. + +### Success Criteria +- āœ… New schemas auto-ingested on install +- āœ… Doesn't re-ingest existing schemas +- āœ… Works in development mode + +--- + +## Optimization #9: Release Notes from CHANGELOG + +**Priority**: LOW +**Estimated**: 2 hours +**Files**: `capabilities/release-management/src/release_management/changelog/parser.py` (new) + +### Implementation + +Create ChangelogParser: + +```python +class ChangelogParser: + """Parse CHANGELOG.md and extract sections.""" + + def extract_version_section(self, version: str) -> str: + """Extract content for specific version.""" + # Parse CHANGELOG + # Find version section + # Extract content until next version + # Return formatted for release notes +``` + +Add `release notes` command: + +```python +@click.command() +@click.argument('version') +@click.option('--format', type=click.Choice(['markdown', 'plain', 'html']), + default='markdown') +def notes(version, format): + """Extract release notes from CHANGELOG.""" + parser = ChangelogParser() + content = parser.extract_version_section(version) + + if format == 'html': + # Convert to HTML + pass + + click.echo(content) +``` + +### Success Criteria +- āœ… Extracts version section +- āœ… Multiple output formats +- āœ… Can pipe to gh release or gitea + +--- + +## Testing Strategy + +### Per-Optimization Testing +1. Unit tests for each new class/function +2. Integration tests for CLI commands +3. Manual testing with real scenarios + +### End-to-End Testing +1. Test full release workflow: prepare → validate → tag → build → summary +2. Test error cases (invalid CHANGELOG, missing tags, etc.) +3. Test with v0.11.0 as real-world scenario + +### Regression Testing +- Ensure existing release commands still work +- Backward compatibility with current workflows +- No breaking changes to public APIs + +--- + +## Rollout Plan + +### Phase 1: Foundation (Day 1, 5 hours) +Implement high-priority items that prevent errors: +1. Git status enhancement +2. Automated tag pushing +3. CHANGELOG validation +4. Version-tag consistency + +**Deliverable**: Robust validation preventing v0.10.0-style issues + +### Phase 2: Automation (Day 2, 5.5 hours) +Implement medium-priority UX improvements: +5. CHANGELOG section generation +6. Explicit version command +7. Release summary auto-generation + +**Deliverable**: Streamlined release workflow + +### Phase 3: Polish (Day 3, 3 hours) +Implement low-priority nice-to-haves: +8. Schema auto-ingestion +9. Release notes extraction + +**Deliverable**: Complete automated release toolchain + +--- + +## Success Metrics + +### Before Optimizations (v0.10.0) +- Manual steps: 8 +- Errors: 2 (forgotten tags, version detection) +- Time: ~3 hours +- Documentation: Manual + +### After Optimizations (Target) +- Manual steps: 2-3 (review, approve) +- Errors: 0 (automated validation) +- Time: ~1.5 hours (50% reduction) +- Documentation: Auto-generated + +### Quality Improvements +- āœ… No forgotten tag pushes (status + auto-push) +- āœ… CHANGELOG always valid (schema validation) +- āœ… Version consistency guaranteed (automated checks) +- āœ… Consistent documentation (auto-generation) + +--- + +**Plan Created**: 2026-01-06 +**Estimated Total Time**: 13.5 hours (3 days @ 4-5 hours/day) +**Next Step**: Begin Phase 1 implementation