Sanitized mirror from private repository - 2026-04-20 01:32:01 UTC
This commit is contained in:
143
scripts/changelog-generator.py
Normal file
143
scripts/changelog-generator.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate changelogs from git commits using Ollama LLM.
|
||||
|
||||
Reads recent git commits, groups them via LLM into Features/Fixes/Infrastructure/Documentation,
|
||||
and prepends the result to docs/CHANGELOG.md.
|
||||
|
||||
Cron: Monday 8am
|
||||
0 8 * * 1 cd /home/homelab/organized/repos/homelab && python3 scripts/changelog-generator.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from lib.ollama import ollama_generate, ollama_available, OllamaUnavailableError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REPO_DIR = Path("/home/homelab/organized/repos/homelab")
|
||||
CHANGELOG_PATH = REPO_DIR / "docs" / "CHANGELOG.md"
|
||||
DB_PATH = Path(__file__).parent / "changelog.db"
|
||||
|
||||
|
||||
def init_db(db_path: Path) -> sqlite3.Connection:
|
||||
"""Initialise the SQLite DB and return a connection."""
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS runs "
|
||||
"(id INTEGER PRIMARY KEY AUTOINCREMENT, last_commit TEXT, run_at TEXT)"
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def get_last_commit_date(conn: sqlite3.Connection) -> str | None:
|
||||
"""Return the run_at timestamp of the most recent run, or None."""
|
||||
row = conn.execute("SELECT run_at FROM runs ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def fetch_commits(since: str) -> list[str]:
|
||||
"""Return git log --oneline entries since *since* (ISO date string)."""
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(REPO_DIR), "log", "--oneline", f"--since={since}", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log.error("git log failed: %s", result.stderr.strip())
|
||||
return []
|
||||
lines = [line.strip() for line in result.stdout.strip().splitlines() if line.strip()]
|
||||
return lines
|
||||
|
||||
|
||||
def generate_changelog(commits: list[str]) -> str:
|
||||
"""Send commits to Ollama and return a formatted changelog."""
|
||||
prompt = (
|
||||
"Summarize these git commits into a changelog grouped by: "
|
||||
"Features, Fixes, Infrastructure, Documentation. "
|
||||
"Use bullet points. Be concise. Omit any empty groups.\n\n"
|
||||
+ "\n".join(commits)
|
||||
)
|
||||
return ollama_generate(prompt, num_predict=1500)
|
||||
|
||||
|
||||
def prepend_changelog(content: str, date_header: str) -> None:
|
||||
"""Prepend a dated changelog entry to CHANGELOG.md."""
|
||||
CHANGELOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = CHANGELOG_PATH.read_text() if CHANGELOG_PATH.exists() else ""
|
||||
entry = f"## {date_header}\n\n{content}\n\n---\n\n"
|
||||
CHANGELOG_PATH.write_text(entry + existing)
|
||||
log.info("Prepended changelog entry to %s", CHANGELOG_PATH)
|
||||
|
||||
|
||||
def record_run(conn: sqlite3.Connection, last_commit: str) -> None:
|
||||
"""Record the run in the DB."""
|
||||
conn.execute(
|
||||
"INSERT INTO runs (last_commit, run_at) VALUES (?, ?)",
|
||||
(last_commit, datetime.now().isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate changelog from git commits via LLM")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print changelog without writing")
|
||||
parser.add_argument("--since", type=str, help="Override start date (YYYY-MM-DD)")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
if not ollama_available():
|
||||
log.error("Ollama is not reachable — aborting")
|
||||
sys.exit(1)
|
||||
|
||||
conn = init_db(DB_PATH)
|
||||
|
||||
# Determine the since date
|
||||
if args.since:
|
||||
since = args.since
|
||||
else:
|
||||
last_run = get_last_commit_date(conn)
|
||||
if last_run:
|
||||
since = last_run[:10] # YYYY-MM-DD
|
||||
else:
|
||||
since = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
|
||||
log.info("Fetching commits since %s", since)
|
||||
commits = fetch_commits(since)
|
||||
|
||||
if not commits:
|
||||
log.info("No new commits since %s — nothing to do", since)
|
||||
return
|
||||
|
||||
log.info("Found %d commits, generating changelog...", len(commits))
|
||||
changelog = generate_changelog(commits)
|
||||
|
||||
date_header = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
if args.dry_run:
|
||||
print(f"## {date_header}\n")
|
||||
print(changelog)
|
||||
return
|
||||
|
||||
prepend_changelog(changelog, date_header)
|
||||
|
||||
# Record the first commit hash (most recent) in the DB
|
||||
first_hash = commits[0].split()[0]
|
||||
record_run(conn, first_hash)
|
||||
log.info("Done — recorded commit %s", first_hash)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user