#!/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()