changelog generator
This commit is contained in:
parent
ae26a8603b
commit
f4bc8443b3
6 changed files with 431 additions and 8 deletions
359
hindsight-dev/hindsight_dev/generate_changelog.py
Normal file
359
hindsight-dev/hindsight_dev/generate_changelog.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate changelog entry for a new release.
|
||||
|
||||
This script fetches the commit diff between releases, uses an LLM to summarize,
|
||||
and prepends the entry to the changelog page.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from openai import OpenAI
|
||||
from pydantic import BaseModel
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
GITHUB_REPO = "vectorize-io/hindsight"
|
||||
GITHUB_RELEASES_URL = f"https://github.com/{GITHUB_REPO}/releases"
|
||||
GITHUB_COMMIT_URL = f"https://github.com/{GITHUB_REPO}/commit"
|
||||
REPO_PATH = Path(__file__).parent.parent.parent
|
||||
CHANGELOG_PATH = REPO_PATH / "hindsight-docs" / "docs" / "changelog" / "index.md"
|
||||
|
||||
|
||||
class ChangelogEntry(BaseModel):
|
||||
"""A single changelog entry."""
|
||||
category: str # "feature", "improvement", "bugfix", "breaking", "other"
|
||||
summary: str # Brief description of the change
|
||||
commit_id: str # Short commit hash
|
||||
|
||||
|
||||
class ChangelogResponse(BaseModel):
|
||||
"""Structured response from LLM."""
|
||||
entries: list[ChangelogEntry]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Commit:
|
||||
"""Parsed commit from git log."""
|
||||
hash: str
|
||||
message: str
|
||||
|
||||
|
||||
def parse_semver(version: str) -> tuple[int, int, int]:
|
||||
"""Parse a semver string into (major, minor, patch)."""
|
||||
version = version.lstrip("v")
|
||||
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid semver: {version}")
|
||||
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
|
||||
def get_git_tags() -> list[str]:
|
||||
"""Get all git tags sorted by semver (newest first)."""
|
||||
result = subprocess.run(
|
||||
["git", "tag"],
|
||||
cwd=REPO_PATH,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
||||
|
||||
valid_tags = []
|
||||
for tag in tags:
|
||||
try:
|
||||
parse_semver(tag)
|
||||
valid_tags.append(tag)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
valid_tags.sort(key=lambda t: parse_semver(t), reverse=True)
|
||||
return valid_tags
|
||||
|
||||
|
||||
def find_previous_version(new_version: str, existing_tags: list[str]) -> str | None:
|
||||
"""Find the previous version based on semver rules."""
|
||||
new_major, new_minor, new_patch = parse_semver(new_version)
|
||||
|
||||
candidates = []
|
||||
for tag in existing_tags:
|
||||
try:
|
||||
major, minor, patch = parse_semver(tag)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if (major, minor, patch) >= (new_major, new_minor, new_patch):
|
||||
continue
|
||||
|
||||
candidates.append((tag, (major, minor, patch)))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
return candidates[0][0]
|
||||
|
||||
|
||||
def get_commits(from_ref: str | None, to_ref: str) -> list[Commit]:
|
||||
"""Get commits between two refs as structured data."""
|
||||
if from_ref:
|
||||
cmd = ["git", "log", "--format=%h|%s", "--no-merges", f"{from_ref}..{to_ref}"]
|
||||
else:
|
||||
cmd = ["git", "log", "--format=%h|%s", "--no-merges", to_ref]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=REPO_PATH,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
commits = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("|", 1)
|
||||
if len(parts) == 2:
|
||||
commits.append(Commit(hash=parts[0], message=parts[1]))
|
||||
|
||||
return commits
|
||||
|
||||
|
||||
def get_detailed_diff(from_ref: str | None, to_ref: str) -> str:
|
||||
"""Get file change stats between two refs."""
|
||||
if from_ref:
|
||||
cmd = ["git", "diff", "--stat", f"{from_ref}..{to_ref}"]
|
||||
else:
|
||||
cmd = ["git", "diff", "--stat", f"{to_ref}^..{to_ref}"]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=REPO_PATH,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def analyze_commits_with_llm(
|
||||
client: OpenAI,
|
||||
model: str,
|
||||
version: str,
|
||||
commits: list[Commit],
|
||||
file_diff: str,
|
||||
) -> list[ChangelogEntry]:
|
||||
"""Use LLM to analyze commits and return structured changelog entries."""
|
||||
commits_json = json.dumps(
|
||||
[{"commit_id": c.hash, "message": c.message} for c in commits],
|
||||
indent=2
|
||||
)
|
||||
|
||||
prompt = f"""Analyze the following git commits for release {version} of Hindsight (an AI memory system).
|
||||
|
||||
For each meaningful change, create a changelog entry with:
|
||||
- category: one of "feature", "improvement", "bugfix", "breaking", "other"
|
||||
- summary: brief one-line description of the change (user-facing, not technical)
|
||||
- commit_id: the commit hash from the input
|
||||
|
||||
Rules:
|
||||
- Group related commits into a single entry if they're part of the same change
|
||||
- Skip trivial changes (typo fixes, formatting, internal refactoring)
|
||||
- Skip repository-only changes: README updates, CI/GitHub Actions, release scripts, changelog updates, version bumps
|
||||
- Focus on user-facing changes that affect the product functionality
|
||||
- Use the exact commit_id from the input (pick the most relevant one if grouping)
|
||||
- If no meaningful changes remain after filtering, return an empty list
|
||||
|
||||
Commits:
|
||||
{commits_json}
|
||||
|
||||
Files changed summary:
|
||||
{file_diff[:4000]}"""
|
||||
|
||||
response = client.beta.chat.completions.parse(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format=ChangelogResponse,
|
||||
max_completion_tokens=16000,
|
||||
)
|
||||
|
||||
return response.choices[0].message.parsed.entries
|
||||
|
||||
|
||||
def build_changelog_markdown(
|
||||
version: str,
|
||||
tag: str,
|
||||
entries: list[ChangelogEntry],
|
||||
) -> str:
|
||||
"""Build markdown changelog from structured entries."""
|
||||
release_url = f"{GITHUB_RELEASES_URL}/tag/{tag}"
|
||||
|
||||
# Group entries by category
|
||||
categories = {
|
||||
"breaking": ("Breaking Changes", []),
|
||||
"feature": ("Features", []),
|
||||
"improvement": ("Improvements", []),
|
||||
"bugfix": ("Bug Fixes", []),
|
||||
"other": ("Other", []),
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
cat = entry.category.lower()
|
||||
if cat in categories:
|
||||
categories[cat][1].append(entry)
|
||||
else:
|
||||
categories["other"][1].append(entry)
|
||||
|
||||
# Build markdown
|
||||
lines = [f"## [{version}]({release_url})", ""]
|
||||
|
||||
for cat_key in ["breaking", "feature", "improvement", "bugfix", "other"]:
|
||||
cat_name, cat_entries = categories[cat_key]
|
||||
if cat_entries:
|
||||
lines.append(f"**{cat_name}**")
|
||||
lines.append("")
|
||||
for entry in cat_entries:
|
||||
commit_url = f"{GITHUB_COMMIT_URL}/{entry.commit_id}"
|
||||
lines.append(f"- {entry.summary} ([`{entry.commit_id}`]({commit_url}))")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def read_existing_changelog() -> tuple[str, str]:
|
||||
"""Read existing changelog and split into header and content."""
|
||||
if not CHANGELOG_PATH.exists():
|
||||
header = """---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
||||
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
|
||||
"""
|
||||
return header, ""
|
||||
|
||||
content = CHANGELOG_PATH.read_text()
|
||||
|
||||
match = re.search(r"^## ", content, re.MULTILINE)
|
||||
if match:
|
||||
header = content[:match.start()].rstrip() + "\n\n"
|
||||
releases = content[match.start():]
|
||||
else:
|
||||
header = content.rstrip() + "\n\n"
|
||||
releases = ""
|
||||
|
||||
return header, releases
|
||||
|
||||
|
||||
def write_changelog(header: str, new_entry: str, existing_releases: str) -> None:
|
||||
"""Write changelog with new entry prepended."""
|
||||
content = header + new_entry + "\n" + existing_releases
|
||||
CHANGELOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CHANGELOG_PATH.write_text(content.rstrip() + "\n")
|
||||
|
||||
|
||||
def generate_changelog_entry(
|
||||
version: str,
|
||||
llm_model: str = "gpt-5.2",
|
||||
) -> None:
|
||||
"""Generate changelog entry for a specific version."""
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
console.print("[red]Error: OPENAI_API_KEY environment variable not set[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
client = OpenAI(api_key=api_key)
|
||||
|
||||
tag = version if version.startswith("v") else f"v{version}"
|
||||
display_version = version.lstrip("v")
|
||||
|
||||
console.print(f"[blue]Fetching tags from repository...[/blue]")
|
||||
existing_tags = get_git_tags()
|
||||
|
||||
if tag not in existing_tags and display_version not in existing_tags:
|
||||
console.print(f"[red]Error: Tag {tag} not found in repository[/red]")
|
||||
console.print("[red]Create the tag first before generating changelog[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
actual_tag = tag if tag in existing_tags else display_version
|
||||
|
||||
previous_tag = find_previous_version(display_version, existing_tags)
|
||||
|
||||
if previous_tag:
|
||||
console.print(f"[green]Found previous version: {previous_tag}[/green]")
|
||||
else:
|
||||
console.print("[yellow]No previous version found, will include all commits[/yellow]")
|
||||
|
||||
console.print(f"[blue]Getting commits...[/blue]")
|
||||
commits = get_commits(previous_tag, actual_tag)
|
||||
file_diff = get_detailed_diff(previous_tag, actual_tag)
|
||||
|
||||
if not commits:
|
||||
console.print("[red]Error: No commits found for this release[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
console.print(f"[blue]Found {len(commits)} commits[/blue]")
|
||||
|
||||
# Log commits
|
||||
console.print("\n[bold]Commits:[/bold]")
|
||||
for c in commits:
|
||||
console.print(f" {c.hash} {c.message}")
|
||||
|
||||
console.print("\n[bold]Files changed:[/bold]")
|
||||
console.print(file_diff[:4000] if len(file_diff) > 4000 else file_diff)
|
||||
console.print("")
|
||||
|
||||
console.print(f"[blue]Analyzing commits with LLM ({llm_model})...[/blue]")
|
||||
entries = analyze_commits_with_llm(client, llm_model, display_version, commits, file_diff)
|
||||
|
||||
console.print(f"\n[bold]LLM identified {len(entries)} changelog entries:[/bold]")
|
||||
for entry in entries:
|
||||
console.print(f" [{entry.category}] {entry.summary} ({entry.commit_id})")
|
||||
|
||||
new_entry = build_changelog_markdown(display_version, tag, entries)
|
||||
|
||||
header, existing_releases = read_existing_changelog()
|
||||
|
||||
if f"## [{display_version}]" in existing_releases:
|
||||
console.print(f"[red]Error: Version {display_version} already exists in changelog[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
write_changelog(header, new_entry, existing_releases)
|
||||
|
||||
console.print(f"\n[green]Changelog updated: {CHANGELOG_PATH}[/green]")
|
||||
console.print(f"\n[bold]New entry:[/bold]\n{new_entry}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate changelog entry for a release",
|
||||
usage="generate-changelog VERSION [--model MODEL]",
|
||||
)
|
||||
parser.add_argument(
|
||||
"version",
|
||||
help="Version to generate changelog for (e.g., 1.0.5, v1.0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="gpt-5.2",
|
||||
help="OpenAI model to use (default: gpt-5.2)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
generate_changelog_entry(
|
||||
version=args.version,
|
||||
llm_model=args.model,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -24,3 +24,4 @@ hindsight-api = { workspace = true }
|
|||
|
||||
[project.scripts]
|
||||
generate-openapi = "hindsight_dev.generate_openapi:generate_openapi_spec"
|
||||
generate-changelog = "hindsight_dev.generate_changelog:main"
|
||||
|
|
|
|||
|
|
@ -4,4 +4,35 @@ sidebar_position: 1
|
|||
|
||||
# Changelog
|
||||
|
||||
Coming soon.
|
||||
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
|
||||
|
||||
## [0.1.5](https://github.com/vectorize-io/hindsight/releases/tag/v0.1.5)
|
||||
|
||||
**Features**
|
||||
|
||||
- Added LiteLLM integration so Hindsight can capture and manage memories from LiteLLM-based LLM calls. ([`dfccbf2`](https://github.com/vectorize-io/hindsight/commit/dfccbf2))
|
||||
- Added an optional graph-based retriever (MPFP) to improve recall by leveraging relationships between memories. ([`7445cef`](https://github.com/vectorize-io/hindsight/commit/7445cef))
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Switched the embedded Postgres layer to pg0-embedded for a smoother local/standalone experience. ([`94c2b85`](https://github.com/vectorize-io/hindsight/commit/94c2b85))
|
||||
|
||||
**Bug Fixes**
|
||||
|
||||
- Fixed repeated retries on 400 errors from the LLM, preventing unnecessary request loops and failures. ([`70983f5`](https://github.com/vectorize-io/hindsight/commit/70983f5))
|
||||
- Fixed recall trace visualization in the control plane so search/recall debugging displays correctly. ([`922164e`](https://github.com/vectorize-io/hindsight/commit/922164e))
|
||||
- Fixed the CLI installer to make installation more reliable. ([`158a6aa`](https://github.com/vectorize-io/hindsight/commit/158a6aa))
|
||||
- Updated Next.js to patch security vulnerabilities (CVE-2025-55184, CVE-2025-55183). ([`f018cc5`](https://github.com/vectorize-io/hindsight/commit/f018cc5))
|
||||
|
||||
## [0.1.3](https://github.com/vectorize-io/hindsight/releases/tag/v0.1.3)
|
||||
|
||||
**Improvements**
|
||||
|
||||
- Improved CLI and UI branding/polish, including new banner/logo assets and updated interface styling. ([`fa554b8`](https://github.com/vectorize-io/hindsight/commit/fa554b8))
|
||||
|
||||
|
||||
## [0.1.2](https://github.com/vectorize-io/hindsight/releases/tag/v0.1.2)
|
||||
|
||||
**Bug Fixes**
|
||||
|
||||
- Fixed the standalone Docker image so it builds/runs correctly. ([`1056a20`](https://github.com/vectorize-io/hindsight/commit/1056a20))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
> Agent Memory that Works Like Human Memory
|
||||
|
||||
This file contains the complete Hindsight documentation for LLM consumption.
|
||||
Generated: 2025-12-15T10:18:45.836Z
|
||||
Generated: 2025-12-15T11:03:02.071Z
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -4081,7 +4081,7 @@ Known Solutions:
|
|||
|
||||
# Changelog
|
||||
|
||||
Coming soon.
|
||||
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
32
scripts/dev/generate-changelog.sh
Executable file
32
scripts/dev/generate-changelog.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 VERSION [--model MODEL]"
|
||||
echo ""
|
||||
echo "Generate changelog entry for a release."
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 1.0.5"
|
||||
echo " $0 v1.0.5"
|
||||
echo " $0 1.0.5 --model gpt-4o"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$OPENAI_API_KEY" ]; then
|
||||
ENV_FILE=".env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
echo "Loading environment from $ENV_FILE"
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
else
|
||||
echo "Error: OPENAI_API_KEY not set and no .env file found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cd hindsight-dev
|
||||
uv run generate-changelog "$@"
|
||||
10
uv.lock
10
uv.lock
|
|
@ -1141,7 +1141,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "hindsight-all"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = { editable = "hindsight" }
|
||||
dependencies = [
|
||||
{ name = "hindsight-api" },
|
||||
|
|
@ -1165,7 +1165,7 @@ provides-extras = ["test"]
|
|||
|
||||
[[package]]
|
||||
name = "hindsight-api"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = { editable = "hindsight-api" }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
|
@ -1223,7 +1223,7 @@ requires-dist = [
|
|||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||
{ name = "dateparser", specifier = ">=1.2.2" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" },
|
||||
{ name = "fastmcp", specifier = ">=2.0.0" },
|
||||
{ name = "fastmcp", specifier = ">=2.3.0" },
|
||||
{ name = "filelock", marker = "extra == 'test'", specifier = ">=3.0.0" },
|
||||
{ name = "google-genai", specifier = ">=1.0.0" },
|
||||
{ name = "greenlet", specifier = ">=3.2.4" },
|
||||
|
|
@ -1267,7 +1267,7 @@ dev = [
|
|||
|
||||
[[package]]
|
||||
name = "hindsight-client"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = { editable = "hindsight-clients/python" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
@ -1299,7 +1299,7 @@ provides-extras = ["test"]
|
|||
|
||||
[[package]]
|
||||
name = "hindsight-dev"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = { editable = "hindsight-dev" }
|
||||
dependencies = [
|
||||
{ name = "hindsight-api" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue