144 lines
4.7 KiB
Python
144 lines
4.7 KiB
Python
#!/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()
|