359 lines
11 KiB
Python
359 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import re
|
|
import subprocess
|
|
import tomllib
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def get_project_root() -> Path:
|
|
return Path(__file__).parent.parent
|
|
|
|
|
|
def get_pyproject() -> dict[str, Any]:
|
|
root = get_project_root()
|
|
with open(root / "pyproject.toml", "rb") as f:
|
|
return tomllib.load(f)
|
|
|
|
|
|
def get_latest_commits(count: int = 10) -> list[dict[str, str]]:
|
|
result = subprocess.run(
|
|
["git", "log", "--format=%H|%s|%ad|%an", "--date=short", f"-n{count}"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=get_project_root(),
|
|
)
|
|
|
|
commits = []
|
|
for line in result.stdout.strip().split("\n"):
|
|
if line:
|
|
parts = line.split("|")
|
|
if len(parts) >= 4:
|
|
commits.append(
|
|
{
|
|
"hash": parts[0][:7],
|
|
"message": parts[1],
|
|
"date": parts[2],
|
|
"author": parts[3],
|
|
}
|
|
)
|
|
return commits
|
|
|
|
|
|
def get_last_tag() -> str | None:
|
|
result = subprocess.run(
|
|
["git", "describe", "--tags", "--abbrev=0"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=get_project_root(),
|
|
)
|
|
return result.stdout.strip() if result.returncode == 0 else None
|
|
|
|
|
|
def get_ignored_files() -> set[str]:
|
|
gitignore_path = get_project_root() / ".gitignore"
|
|
ignored = set()
|
|
if gitignore_path.exists():
|
|
for line in gitignore_path.read_text().splitlines():
|
|
line = line.strip()
|
|
if line and not line.startswith("#"):
|
|
ignored.add(line.rstrip("/"))
|
|
return ignored
|
|
|
|
|
|
def commit_has_tracked_changes(commit_hash: str) -> bool:
|
|
result = subprocess.run(
|
|
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=get_project_root(),
|
|
)
|
|
if not result.stdout.strip():
|
|
return False
|
|
|
|
ignored = get_ignored_files()
|
|
for file_path in result.stdout.strip().split("\n"):
|
|
if not file_path:
|
|
continue
|
|
parts = file_path.split("/")
|
|
is_ignored = False
|
|
for i in range(len(parts)):
|
|
path_part = "/".join(parts[: i + 1])
|
|
for pattern in ignored:
|
|
if pattern.endswith("*"):
|
|
if path_part.startswith(pattern[:-1]):
|
|
is_ignored = True
|
|
break
|
|
elif path_part == pattern or parts[-1] == pattern:
|
|
is_ignored = True
|
|
break
|
|
if is_ignored:
|
|
break
|
|
if not is_ignored:
|
|
return True
|
|
return False
|
|
|
|
|
|
def commit_has_skip_ci_message(commit_hash: str) -> bool:
|
|
result = subprocess.run(
|
|
["git", "log", "-1", "--format=%s", commit_hash],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=get_project_root(),
|
|
)
|
|
msg = result.stdout.strip().lower()
|
|
return "[skip ci]" in msg or "[skip-ci]" in msg or "[ci skip]" in msg
|
|
|
|
|
|
def commit_only_changes_readme(commit_hash: str) -> bool:
|
|
result = subprocess.run(
|
|
["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=get_project_root(),
|
|
)
|
|
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
|
return files == ["README.md"]
|
|
|
|
|
|
def get_commits_since_tag(tag: str | None) -> list[dict[str, str]]:
|
|
if tag:
|
|
result = subprocess.run(
|
|
["git", "log", "--format=%H|%s|%ad|%an", "--date=short", f"{tag}..HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=get_project_root(),
|
|
)
|
|
else:
|
|
result = subprocess.run(
|
|
["git", "log", "--format=%H|%s|%ad|%an", "--date=short", "-n10"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=get_project_root(),
|
|
)
|
|
|
|
commits = []
|
|
for line in result.stdout.strip().split("\n"):
|
|
if line:
|
|
parts = line.split("|")
|
|
if len(parts) >= 4:
|
|
commit_hash = parts[0]
|
|
if commit_has_skip_ci_message(commit_hash):
|
|
continue
|
|
if commit_only_changes_readme(commit_hash):
|
|
continue
|
|
if not commit_has_tracked_changes(commit_hash):
|
|
continue
|
|
commits.append(
|
|
{
|
|
"hash": commit_hash[:7],
|
|
"message": parts[1],
|
|
"date": parts[2],
|
|
"author": parts[3],
|
|
}
|
|
)
|
|
return commits
|
|
|
|
|
|
def categorize_commits(commits: list[dict[str, str]]) -> dict[str, list[str]]:
|
|
categories: dict[str, list[str]] = {
|
|
"Added": [],
|
|
"Changed": [],
|
|
"Fixed": [],
|
|
"Removed": [],
|
|
"Other": [],
|
|
}
|
|
|
|
for commit in commits:
|
|
msg = commit["message"].lower()
|
|
entry = f"- {commit['message']} ({commit['hash']})"
|
|
|
|
if msg.startswith("feat") or "add" in msg:
|
|
categories["Added"].append(entry)
|
|
elif msg.startswith("fix") or "fix" in msg:
|
|
categories["Fixed"].append(entry)
|
|
elif msg.startswith("change") or "update" in msg:
|
|
categories["Changed"].append(entry)
|
|
elif msg.startswith("remove") or "delete" in msg:
|
|
categories["Removed"].append(entry)
|
|
else:
|
|
categories["Other"].append(entry)
|
|
|
|
return categories
|
|
|
|
|
|
def format_changelog(commits: list[dict[str, str]], version: str = "v0.1.0") -> str:
|
|
categorized = categorize_commits(commits)
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
|
|
lines = [f"### [{version}] - {today}"]
|
|
|
|
for section, entries in categorized.items():
|
|
if entries:
|
|
lines.append(f"\n#### {section}")
|
|
lines.extend(entries)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def get_dependencies(pyproject: dict[str, Any]) -> dict[str, list[str]]:
|
|
deps: dict[str, list[str]] = {
|
|
"runtime": [],
|
|
"tests": [],
|
|
"lints": [],
|
|
"types": [],
|
|
"docs": [],
|
|
}
|
|
|
|
for dep in pyproject.get("project", {}).get("dependencies", []):
|
|
deps["runtime"].append(dep)
|
|
|
|
dep_groups = pyproject.get("dependency-groups", {})
|
|
|
|
if "tests" in dep_groups:
|
|
for dep in dep_groups["tests"]:
|
|
if isinstance(dep, str):
|
|
deps["tests"].append(dep)
|
|
|
|
if "lints" in dep_groups:
|
|
for dep in dep_groups["lints"]:
|
|
if isinstance(dep, str):
|
|
deps["lints"].append(dep)
|
|
|
|
if "types" in dep_groups:
|
|
for dep in dep_groups["types"]:
|
|
if isinstance(dep, str):
|
|
deps["types"].append(dep)
|
|
|
|
if "docs" in dep_groups:
|
|
for dep in dep_groups["docs"]:
|
|
if isinstance(dep, str):
|
|
deps["docs"].append(dep)
|
|
|
|
return deps
|
|
|
|
|
|
def get_available_commands() -> list[dict[str, str]]:
|
|
commands = [
|
|
{"cmd": "uv sync", "desc": "Install dependencies"},
|
|
{"cmd": "uv run python -m app.main", "desc": "Start development server"},
|
|
{
|
|
"cmd": "uv run pytest --cov=app --cov-fail-under=70",
|
|
"desc": "Run tests with coverage",
|
|
},
|
|
{"cmd": "uv run ruff check . --fix", "desc": "Run linters"},
|
|
{"cmd": "uv run ruff format .", "desc": "Format code"},
|
|
{
|
|
"cmd": "uv run isort . --profile black --filter-files",
|
|
"desc": "Sort imports",
|
|
},
|
|
{"cmd": "uv run mypy .", "desc": "Type checking"},
|
|
{"cmd": "uv run mkdocs build", "desc": "Build documentation"},
|
|
{"cmd": "uv run mkdocs serve", "desc": "Serve documentation locally"},
|
|
]
|
|
return commands
|
|
|
|
|
|
def update_dependencies_section(content: str, deps: dict[str, list[str]]) -> str:
|
|
section_pattern = r"(## Dependencies\n.*?)(\n## |\Z)"
|
|
|
|
deps_text = "## Dependencies\n\n"
|
|
|
|
if deps["runtime"]:
|
|
deps_text += "### Runtime\n"
|
|
for dep in sorted(deps["runtime"]):
|
|
deps_text += f"- {dep}\n"
|
|
deps_text += "\n"
|
|
|
|
if deps["tests"]:
|
|
deps_text += "### Development\n"
|
|
deps_text += "- **Tests**: " + ", ".join(sorted(deps["tests"])) + "\n"
|
|
if deps["lints"]:
|
|
deps_text += "- **Lint**: " + ", ".join(sorted(deps["lints"])) + "\n"
|
|
if deps["types"]:
|
|
deps_text += "- **Types**: " + ", ".join(sorted(deps["types"])) + "\n"
|
|
if deps["docs"]:
|
|
deps_text += "- **Docs**: " + ", ".join(sorted(deps["docs"])) + "\n"
|
|
|
|
deps_text += "\n"
|
|
|
|
replacement = f"{deps_text}\\2"
|
|
return re.sub(section_pattern, replacement, content, flags=re.DOTALL)
|
|
|
|
|
|
def update_commands_section(content: str, commands: list[dict[str, str]]) -> str:
|
|
section_pattern = r"(## Available Commands\n.*?\|.*?\n\|---\|.*?\n)(.*?)(\n## |\Z)"
|
|
|
|
commands_table = "| Command | Description |\n|---------|-------------|\n"
|
|
for cmd in commands:
|
|
commands_table += f"| `{cmd['cmd']}` | {cmd['desc']} |\n"
|
|
|
|
commands_table += "\n"
|
|
|
|
replacement = f"\\1{commands_table}\\3"
|
|
return re.sub(section_pattern, replacement, content, flags=re.DOTALL)
|
|
|
|
|
|
def update_changelog_section(content: str, changelog: str) -> str:
|
|
section_pattern = r"(## Changelog\n)(.*?)(\Z)"
|
|
|
|
replacement = f"\\1\n{changelog}\n\\3"
|
|
return re.sub(section_pattern, replacement, content, flags=re.DOTALL)
|
|
|
|
|
|
def update_readme(check_only: bool = False) -> bool:
|
|
readme_path = get_project_root() / "README.md"
|
|
|
|
if not readme_path.exists():
|
|
print("README.md not found")
|
|
return False
|
|
|
|
content = readme_path.read_text()
|
|
original_content = content
|
|
|
|
pyproject = get_pyproject()
|
|
commits = get_commits_since_tag(get_last_tag())
|
|
deps = get_dependencies(pyproject)
|
|
commands = get_available_commands()
|
|
|
|
version = get_last_tag() or "v0.1.0"
|
|
changelog = format_changelog(commits, version)
|
|
|
|
content = update_changelog_section(content, changelog)
|
|
content = update_dependencies_section(content, deps)
|
|
content = update_commands_section(content, commands)
|
|
|
|
if check_only:
|
|
needs_update = content != original_content
|
|
if needs_update:
|
|
print("README.md needs update")
|
|
else:
|
|
print("README.md is up to date")
|
|
return needs_update
|
|
|
|
if content != original_content:
|
|
readme_path.write_text(content)
|
|
print("README.md updated successfully")
|
|
return True
|
|
else:
|
|
print("No changes needed")
|
|
return False
|
|
|
|
|
|
def main() -> None:
|
|
import sys
|
|
|
|
check_only = "--check" in sys.argv
|
|
|
|
updated = update_readme(check_only=check_only)
|
|
|
|
if check_only and updated:
|
|
sys.exit(1)
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|