* feat: independent versioning for integrations - Add per-integration changelog pages at /changelog/integrations/<name> - Move main changelog to changelog/index.md (URL unchanged) - Add --integration flag to generate-changelog for LLM-based per-integration changelog generation - Add scripts/release-integration.sh <name> <version> for cutting integration releases - Add .github/workflows/release-integration.yml to publish on integrations/** tags - Remove integrations from main release.sh and release.yml cycle * fix: add agno and hermes integration docs to version-0.4 for production build * chore: apply ruff formatting to generate_changelog.py
565 lines
18 KiB
Python
565 lines
18 KiB
Python
#!/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" / "src" / "pages" / "changelog" / "index.md"
|
|
INTEGRATION_CHANGELOG_DIR = REPO_PATH / "hindsight-docs" / "src" / "pages" / "changelog" / "integrations"
|
|
|
|
VALID_INTEGRATIONS = ["litellm", "pydantic-ai", "crewai", "ai-sdk", "chat", "openclaw"]
|
|
|
|
|
|
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 get_integration_tags(integration: str) -> list[str]:
|
|
"""Get all tags for a specific integration, sorted by semver (newest first)."""
|
|
prefix = f"integrations/{integration}/v"
|
|
result = subprocess.run(
|
|
["git", "tag", "-l", f"{prefix}*"],
|
|
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:
|
|
version_part = tag.removeprefix(prefix)
|
|
try:
|
|
parse_semver(version_part)
|
|
valid_tags.append(tag)
|
|
except ValueError:
|
|
continue
|
|
|
|
valid_tags.sort(key=lambda t: parse_semver(t.removeprefix(prefix)), 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 find_previous_integration_tag(new_version: str, existing_tags: list[str], integration: str) -> str | None:
|
|
"""Find the previous integration tag based on semver rules."""
|
|
prefix = f"integrations/{integration}/v"
|
|
new_major, new_minor, new_patch = parse_semver(new_version)
|
|
|
|
candidates = []
|
|
for tag in existing_tags:
|
|
version_part = tag.removeprefix(prefix)
|
|
try:
|
|
major, minor, patch = parse_semver(version_part)
|
|
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, path_filter: str | None = None) -> 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]
|
|
|
|
if path_filter:
|
|
cmd += ["--", path_filter]
|
|
|
|
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, path_filter: str | None = None) -> 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}"]
|
|
|
|
if path_filter:
|
|
cmd += ["--", path_filter]
|
|
|
|
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,
|
|
integration: str | None = None,
|
|
) -> 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)
|
|
|
|
subject = f"the {integration} integration for Hindsight" if integration else f"release {version} of Hindsight"
|
|
|
|
prompt = f"""Analyze the following git commits for {subject} (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],
|
|
integration: str | None = None,
|
|
) -> str:
|
|
"""Build markdown changelog from structured entries."""
|
|
tag_url = (
|
|
f"https://github.com/{GITHUB_REPO}/releases/tag/{tag}"
|
|
if not integration
|
|
else f"https://github.com/{GITHUB_REPO}/tree/{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}]({tag_url})", ""]
|
|
|
|
has_entries = False
|
|
for cat_key in ["breaking", "feature", "improvement", "bugfix", "other"]:
|
|
cat_name, cat_entries = categories[cat_key]
|
|
if cat_entries:
|
|
has_entries = True
|
|
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("")
|
|
|
|
if not has_entries:
|
|
lines.append("*This release contains internal maintenance and infrastructure changes only.*")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def read_existing_changelog(path: Path, default_header: str) -> tuple[str, str]:
|
|
"""Read existing changelog and split into header and content."""
|
|
if not path.exists():
|
|
return default_header, ""
|
|
|
|
content = 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(path: Path, header: str, new_entry: str, existing_releases: str) -> None:
|
|
"""Write changelog with new entry prepended."""
|
|
content = header + new_entry + "\n" + existing_releases
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
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("[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("[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)
|
|
|
|
default_header = """---
|
|
hide_table_of_contents: true
|
|
---
|
|
|
|
# Changelog
|
|
|
|
This changelog highlights user-facing changes only. Internal maintenance, CI/CD, and infrastructure updates are omitted.
|
|
|
|
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
|
|
|
|
"""
|
|
header, existing_releases = read_existing_changelog(CHANGELOG_PATH, default_header)
|
|
|
|
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(CHANGELOG_PATH, 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 generate_integration_changelog_entry(
|
|
integration: str,
|
|
version: str,
|
|
llm_model: str = "gpt-5.2",
|
|
) -> None:
|
|
"""Generate changelog entry for a specific integration version."""
|
|
if integration not in VALID_INTEGRATIONS:
|
|
console.print(f"[red]Error: Unknown integration '{integration}'. Valid: {', '.join(VALID_INTEGRATIONS)}[/red]")
|
|
sys.exit(1)
|
|
|
|
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)
|
|
|
|
display_version = version.lstrip("v")
|
|
path_filter = f"hindsight-integrations/{integration}/"
|
|
changelog_path = INTEGRATION_CHANGELOG_DIR / f"{integration}.md"
|
|
|
|
console.print(f"[blue]Fetching integration tags for {integration}...[/blue]")
|
|
existing_tags = get_integration_tags(integration)
|
|
|
|
previous_tag = find_previous_integration_tag(display_version, existing_tags, integration)
|
|
|
|
if previous_tag:
|
|
console.print(f"[green]Found previous tag: {previous_tag}[/green]")
|
|
else:
|
|
console.print("[yellow]No previous tag found, will include all commits touching this integration[/yellow]")
|
|
|
|
console.print(f"[blue]Getting commits for {path_filter}...[/blue]")
|
|
commits = get_commits(previous_tag, "HEAD", path_filter=path_filter)
|
|
file_diff = get_detailed_diff(previous_tag, "HEAD", path_filter=path_filter)
|
|
|
|
if not commits:
|
|
console.print("[yellow]Warning: No commits found touching this integration path[/yellow]")
|
|
entries = []
|
|
else:
|
|
console.print(f"[blue]Found {len(commits)} commits[/blue]")
|
|
|
|
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, integration=integration
|
|
)
|
|
|
|
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})")
|
|
|
|
integration_tag = f"integrations/{integration}/v{display_version}"
|
|
new_entry = build_changelog_markdown(display_version, integration_tag, entries, integration=integration)
|
|
|
|
package_name = _get_package_name(integration)
|
|
default_header = f"""---
|
|
hide_table_of_contents: true
|
|
---
|
|
|
|
# {_integration_display_name(integration)} Integration Changelog
|
|
|
|
Changelog for [`{package_name}`]({_package_url(integration, package_name)}).
|
|
|
|
For the source code, see [`hindsight-integrations/{integration}`](https://github.com/{GITHUB_REPO}/tree/main/hindsight-integrations/{integration}).
|
|
|
|
← [Back to main changelog](/changelog)
|
|
|
|
"""
|
|
header, existing_releases = read_existing_changelog(changelog_path, default_header)
|
|
|
|
if f"## [{display_version}]" in existing_releases:
|
|
console.print(f"[red]Error: Version {display_version} already exists in integration changelog[/red]")
|
|
sys.exit(1)
|
|
|
|
write_changelog(changelog_path, header, new_entry, existing_releases)
|
|
|
|
console.print(f"\n[green]Integration changelog updated: {changelog_path}[/green]")
|
|
console.print(f"\n[bold]New entry:[/bold]\n{new_entry}")
|
|
|
|
|
|
def _get_package_name(integration: str) -> str:
|
|
packages = {
|
|
"litellm": "hindsight-litellm",
|
|
"pydantic-ai": "hindsight-pydantic-ai",
|
|
"crewai": "hindsight-crewai",
|
|
"ai-sdk": "@vectorize-io/hindsight-ai-sdk",
|
|
"chat": "@vectorize-io/hindsight-chat",
|
|
"openclaw": "@vectorize-io/hindsight-openclaw",
|
|
}
|
|
return packages[integration]
|
|
|
|
|
|
def _package_url(integration: str, package_name: str) -> str:
|
|
if package_name.startswith("@"):
|
|
return f"https://www.npmjs.com/package/{package_name}"
|
|
return f"https://pypi.org/project/{package_name}/"
|
|
|
|
|
|
def _integration_display_name(integration: str) -> str:
|
|
names = {
|
|
"litellm": "LiteLLM",
|
|
"pydantic-ai": "Pydantic AI",
|
|
"crewai": "CrewAI",
|
|
"ai-sdk": "AI SDK",
|
|
"chat": "Chat SDK",
|
|
"openclaw": "OpenClaw",
|
|
}
|
|
return names.get(integration, integration)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate changelog entry for a release",
|
|
usage="generate-changelog VERSION [--model MODEL] [--integration NAME]",
|
|
)
|
|
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)",
|
|
)
|
|
parser.add_argument(
|
|
"--integration",
|
|
default=None,
|
|
help=f"Generate changelog for a specific integration. Valid: {', '.join(VALID_INTEGRATIONS)}",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.integration:
|
|
generate_integration_changelog_entry(
|
|
integration=args.integration,
|
|
version=args.version,
|
|
llm_model=args.model,
|
|
)
|
|
else:
|
|
generate_changelog_entry(
|
|
version=args.version,
|
|
llm_model=args.model,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|