feat: implement optimization #1 - unpushed tags detection

Added unpushed tag detection to release status command to prevent
forgotten tag pushes (the critical issue from v0.10.0 release).

**Implementation**:
- Added `get_unpushed_tags()` method to GitManager
- Compares local tags with remote tags (git ls-remote)
- Handles annotated tags correctly (strips ^{} suffix)
- Added unpushed_tags to repository status dict

**CLI Enhancement**:
- `release status` now shows unpushed tags with warning emoji
- Lists all unpushed tags
- Provides helpful command to push them

**Output Example**:
```
⚠️  Unpushed Tags: 2 tag(s) not pushed to origin
    - v0.9.0
    - v0.10.0

💡 Push tags with: git push origin v0.9.0 v0.10.0
   Or push all tags: git push --tags
```

**Testing**: Verified with current repo (no unpushed tags after push)

**Files Modified**:
- capabilities/release-management/src/release_management/git/manager.py
- capabilities/release-management/src/release_management/cli/main.py

**Documentation**: Added comprehensive IMPLEMENTATION_PLAN.md with
all 9 optimizations detailed (13.5 hours total estimated)

This solves the #1 critical issue from OPTIMIZATION_ASSESSMENT.md.
This commit is contained in:
2026-01-06 17:26:09 +01:00
parent bf4767d06b
commit 587d2f5889
3 changed files with 642 additions and 1 deletions

View File

@@ -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")

View File

@@ -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.

View File

@@ -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