Skip to content

Commit ff32fad

Browse files
authored
Merge pull request #431 from lbedner/v0.5.1-rc3
v0.5.1-rc3
2 parents d828693 + ef50afd commit ff32fad

8 files changed

Lines changed: 246 additions & 57 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Each generated project includes:
3232

3333
## Installation
3434

35-
**Current Version**: v0.5.1-rc2
35+
**Current Version**: v0.5.1-rc3
3636

3737
```bash
3838
pip install aegis-stack

aegis/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Aegis Stack CLI - Component generation and project management tools.
33
"""
44

5-
__version__ = "0.5.1-rc2"
5+
__version__ = "0.5.1-rc3"

aegis/commands/update.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import os
9+
import re
910
import subprocess
1011
from pathlib import Path
1112

@@ -361,6 +362,19 @@ def update_command(
361362
target_path, include_migrations=include_migrations
362363
)
363364

365+
# Update __aegis_version__ directly (Copier doesn't re-render unchanged files)
366+
init_file = target_path / "app" / "__init__.py"
367+
if init_file.exists():
368+
content = init_file.read_text()
369+
updated_content = re.sub(
370+
r'__aegis_version__\s*=\s*(["\'])[^"\']*\1',
371+
f'__aegis_version__ = "{aegis_version}"',
372+
content,
373+
)
374+
if updated_content != content:
375+
init_file.write_text(updated_content)
376+
typer.echo(f" Updated __aegis_version__ to {aegis_version}")
377+
364378
# Show update result
365379
typer.echo("")
366380
if tasks_success:

aegis/core/copier_updater.py

Lines changed: 133 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,131 @@ def validate_clean_git_tree(project_path: Path) -> tuple[bool, str]:
480480
return False, f"Error checking git status: {e}"
481481

482482

483+
def _format_commits_as_changelog(
484+
commits: list[tuple[str, str]], github_url: str
485+
) -> str:
486+
"""
487+
Format a list of (hash, message) commits as a categorized changelog.
488+
489+
Args:
490+
commits: List of (commit_hash, message) tuples
491+
github_url: Base GitHub URL for commit links
492+
493+
Returns:
494+
Formatted changelog string
495+
"""
496+
if not commits:
497+
return "No changes"
498+
499+
features: list[tuple[str, str]] = []
500+
fixes: list[tuple[str, str]] = []
501+
breaking: list[tuple[str, str]] = []
502+
other: list[tuple[str, str]] = []
503+
504+
for commit_hash, message in commits:
505+
message_lower = message.lower()
506+
if "breaking:" in message_lower or "breaking change" in message_lower:
507+
breaking.append((commit_hash, message))
508+
elif message.startswith("feat:") or message.startswith("feature:"):
509+
features.append((commit_hash, message))
510+
elif message.startswith("fix:"):
511+
fixes.append((commit_hash, message))
512+
else:
513+
other.append((commit_hash, message))
514+
515+
def format_commit(commit_hash: str, message: str) -> str:
516+
"""Format commit with GitHub link."""
517+
if commit_hash:
518+
return f" • {message} ([{commit_hash}]({github_url}/commit/{commit_hash}))"
519+
return f" • {message}"
520+
521+
lines = []
522+
523+
if breaking:
524+
lines.append("Breaking Changes:")
525+
for commit_hash, message in breaking:
526+
lines.append(format_commit(commit_hash, message))
527+
lines.append("")
528+
529+
if features:
530+
lines.append("New Features:")
531+
for commit_hash, message in features:
532+
lines.append(format_commit(commit_hash, message))
533+
lines.append("")
534+
535+
if fixes:
536+
lines.append("Bug Fixes:")
537+
for commit_hash, message in fixes:
538+
lines.append(format_commit(commit_hash, message))
539+
lines.append("")
540+
541+
if other:
542+
lines.append("Other Changes:")
543+
for commit_hash, message in other:
544+
lines.append(format_commit(commit_hash, message))
545+
546+
return "\n".join(lines).strip()
547+
548+
549+
def _get_changelog_from_github(from_ref: str, to_ref: str) -> str:
550+
"""
551+
Fetch changelog from GitHub Compare API when not in a git repo.
552+
553+
Used when running from installed package (pip/uvx) instead of git checkout.
554+
555+
Args:
556+
from_ref: Starting git reference (commit hash)
557+
to_ref: Ending git reference (commit, tag, or "HEAD")
558+
559+
Returns:
560+
Formatted changelog string or error message with fallback link
561+
"""
562+
import json
563+
import urllib.request
564+
from urllib.parse import urlparse
565+
566+
github_url = GITHUB_REPO_URL
567+
568+
# Extract owner/repo from GITHUB_REPO_URL (e.g., "lbedner/aegis-stack")
569+
parsed = urlparse(github_url)
570+
repo_path = parsed.path.strip("/") # "lbedner/aegis-stack"
571+
572+
# Normalize refs (HEAD -> main for API)
573+
base = from_ref
574+
head = "main" if to_ref == "HEAD" else to_ref
575+
576+
api_url = f"https://api.github.com/repos/{repo_path}/compare/{base}...{head}"
577+
578+
try:
579+
req = urllib.request.Request(
580+
api_url,
581+
headers={
582+
"Accept": "application/vnd.github.v3+json",
583+
"User-Agent": "aegis-stack-cli",
584+
},
585+
)
586+
with urllib.request.urlopen(req, timeout=10) as response:
587+
data = json.loads(response.read().decode())
588+
589+
api_commits = data.get("commits", [])
590+
if not api_commits:
591+
return "No changes"
592+
593+
# Extract (hash, message) tuples from API response
594+
commits = [
595+
(c["sha"][:7], c["commit"]["message"].split("\n")[0]) for c in api_commits
596+
]
597+
598+
return _format_commits_as_changelog(commits, github_url)
599+
600+
except Exception as e:
601+
# Fallback to compare link
602+
return (
603+
f"Changelog not available: {e}\n"
604+
f"View changes: {github_url}/compare/{from_ref}...main"
605+
)
606+
607+
483608
def get_changelog(from_ref: str, to_ref: str, template_root: Path | None = None) -> str:
484609
"""
485610
Get changelog between two git references.
@@ -495,6 +620,10 @@ def get_changelog(from_ref: str, to_ref: str, template_root: Path | None = None)
495620
if template_root is None:
496621
template_root = get_template_root()
497622

623+
# Check if we're in a git repository (not installed package mode)
624+
if not (template_root / ".git").exists():
625+
return _get_changelog_from_github(from_ref, to_ref)
626+
498627
try:
499628
# Get commit log between refs (hash|message format for GitHub links)
500629
result = subprocess.run(
@@ -515,65 +644,17 @@ def get_changelog(from_ref: str, to_ref: str, template_root: Path | None = None)
515644
if not result.stdout.strip():
516645
return "No changes"
517646

518-
# GitHub repository URL for commit links
519-
github_url = GITHUB_REPO_URL
520-
521-
# Parse commits and categorize
647+
# Parse commits from git output
522648
raw_commits = result.stdout.strip().split("\n")
523-
features: list[tuple[str, str]] = [] # (hash, message)
524-
fixes: list[tuple[str, str]] = []
525-
breaking: list[tuple[str, str]] = []
526-
other: list[tuple[str, str]] = []
527-
649+
commits = []
528650
for line in raw_commits:
529651
if "|" in line:
530652
commit_hash, message = line.split("|", 1)
531653
else:
532654
commit_hash, message = "", line
655+
commits.append((commit_hash, message))
533656

534-
message_lower = message.lower()
535-
if "breaking:" in message_lower or "breaking change" in message_lower:
536-
breaking.append((commit_hash, message))
537-
elif message.startswith("feat:") or message.startswith("feature:"):
538-
features.append((commit_hash, message))
539-
elif message.startswith("fix:"):
540-
fixes.append((commit_hash, message))
541-
else:
542-
other.append((commit_hash, message))
543-
544-
def format_commit(commit_hash: str, message: str) -> str:
545-
"""Format commit with GitHub link."""
546-
if commit_hash:
547-
return f" • {message} ([{commit_hash}]({github_url}/commit/{commit_hash}))"
548-
return f" • {message}"
549-
550-
# Format changelog
551-
lines = []
552-
553-
if breaking:
554-
lines.append("Breaking Changes:")
555-
for commit_hash, message in breaking:
556-
lines.append(format_commit(commit_hash, message))
557-
lines.append("")
558-
559-
if features:
560-
lines.append("New Features:")
561-
for commit_hash, message in features:
562-
lines.append(format_commit(commit_hash, message))
563-
lines.append("")
564-
565-
if fixes:
566-
lines.append("Bug Fixes:")
567-
for commit_hash, message in fixes:
568-
lines.append(format_commit(commit_hash, message))
569-
lines.append("")
570-
571-
if other:
572-
lines.append("Other Changes:")
573-
for commit_hash, message in other:
574-
lines.append(format_commit(commit_hash, message))
575-
576-
return "\n".join(lines).strip()
657+
return _format_commits_as_changelog(commits, GITHUB_REPO_URL)
577658

578659
except Exception as e:
579660
return f"Error generating changelog: {e}"

copier.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# - Update support
77

88
_min_copier_version: "9.0.0"
9-
_version: "0.5.1-rc2"
9+
_version: "0.5.1-rc3"
1010

1111
# IMPORTANT: Template content is in subdirectory
1212
# This allows the template to be recognized as git-tracked (aegis-stack repo root has .git)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "aegis-stack"
3-
version = "0.5.1-rc2"
3+
version = "0.5.1-rc3"
44
description = "A production-ready Python foundation for builders who refuse to wait. Try: uvx aegis-stack init my-project"
55
readme = "README.md"
66
requires-python = ">=3.11,<3.15"

tests/core/test_copier_updater.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import pytest
1111

1212
from aegis.core.copier_updater import (
13+
_format_commits_as_changelog,
14+
_get_changelog_from_github,
1315
analyze_conflict_files,
1416
cleanup_backup_tag,
1517
create_backup_point,
@@ -258,3 +260,95 @@ def test_returns_empty_for_no_conflicts(self) -> None:
258260
report = format_conflict_report([])
259261

260262
assert report == ""
263+
264+
265+
class TestFormatCommitsAsChangelog:
266+
"""Tests for _format_commits_as_changelog function."""
267+
268+
def test_empty_commits_returns_no_changes(self) -> None:
269+
"""Test that empty commits list returns 'No changes'."""
270+
result = _format_commits_as_changelog([], "https://github.com/test/repo")
271+
assert result == "No changes"
272+
273+
def test_categorizes_breaking_changes(self) -> None:
274+
"""Test that breaking changes are categorized correctly."""
275+
commits = [
276+
("abc1234", "breaking: Remove deprecated API"),
277+
("def5678", "Other change"),
278+
]
279+
result = _format_commits_as_changelog(commits, "https://github.com/test/repo")
280+
281+
assert "Breaking Changes:" in result
282+
assert "Remove deprecated API" in result
283+
284+
def test_categorizes_features(self) -> None:
285+
"""Test that features are categorized correctly."""
286+
commits = [
287+
("abc1234", "feat: Add new feature"),
288+
("def5678", "feature: Another feature"),
289+
]
290+
result = _format_commits_as_changelog(commits, "https://github.com/test/repo")
291+
292+
assert "New Features:" in result
293+
assert "Add new feature" in result
294+
assert "Another feature" in result
295+
296+
def test_categorizes_fixes(self) -> None:
297+
"""Test that fixes are categorized correctly."""
298+
commits = [
299+
("abc1234", "fix: Fix a bug"),
300+
]
301+
result = _format_commits_as_changelog(commits, "https://github.com/test/repo")
302+
303+
assert "Bug Fixes:" in result
304+
assert "Fix a bug" in result
305+
306+
def test_categorizes_other_changes(self) -> None:
307+
"""Test that non-categorized commits go to Other Changes."""
308+
commits = [
309+
("abc1234", "Update documentation"),
310+
("def5678", "Refactor code"),
311+
]
312+
result = _format_commits_as_changelog(commits, "https://github.com/test/repo")
313+
314+
assert "Other Changes:" in result
315+
assert "Update documentation" in result
316+
assert "Refactor code" in result
317+
318+
def test_includes_github_links(self) -> None:
319+
"""Test that commit hashes are linked to GitHub."""
320+
commits = [("abc1234", "Some change")]
321+
github_url = "https://github.com/test/repo"
322+
result = _format_commits_as_changelog(commits, github_url)
323+
324+
assert f"[abc1234]({github_url}/commit/abc1234)" in result
325+
326+
def test_handles_empty_commit_hash(self) -> None:
327+
"""Test that commits without hash are formatted without links."""
328+
commits = [("", "Some change")]
329+
result = _format_commits_as_changelog(commits, "https://github.com/test/repo")
330+
331+
assert "• Some change" in result
332+
assert "[" not in result # No link brackets
333+
334+
335+
class TestGetChangelogFromGithub:
336+
"""Tests for _get_changelog_from_github function."""
337+
338+
def test_normalizes_head_to_main(self) -> None:
339+
"""Test that HEAD is normalized to main for API calls."""
340+
# This test verifies the URL construction logic
341+
# We can't easily test the actual API call without mocking
342+
# but we can verify the function handles the HEAD -> main conversion
343+
# by checking it doesn't crash and returns a string
344+
result = _get_changelog_from_github("abc1234", "HEAD")
345+
# Should return either changelog or fallback message
346+
assert isinstance(result, str)
347+
assert len(result) > 0
348+
349+
def test_returns_fallback_on_invalid_ref(self) -> None:
350+
"""Test that invalid refs return a fallback message."""
351+
result = _get_changelog_from_github("invalid_ref_123", "HEAD")
352+
# Should contain fallback message with "Changelog not available" or actual changelog
353+
assert isinstance(result, str)
354+
assert "Changelog not available" in result or "Changes:" in result

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)