Files
topoteretes--cognee/tools/generate_release_notes.py
wehub-resource-sync c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:02:24 +08:00

589 lines
21 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Generate human-friendly release notes by analyzing git diff with LLM.
This script compares changes between two branches (typically main and dev)
and uses an LLM to generate readable, user-focused release notes.
Uses litellm + instructor directly to avoid cognee's dotenv.load_dotenv(override=True)
which can overwrite CI environment variables with .env file placeholders.
"""
import argparse
import asyncio
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
def run_git_command(command: list[str]) -> str:
"""Execute a git command and return output."""
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Error running git command: {e}", file=sys.stderr)
print(f"Command output: {e.stderr}", file=sys.stderr)
sys.exit(1)
def get_latest_release_tag() -> str | None:
"""Get the latest release tag."""
try:
# Get the latest tag that matches version pattern (vX.Y.Z)
command = ["git", "tag", "--sort=-version:refname", "--list", "v*"]
tags = run_git_command(command)
if tags:
return tags.split("\n")[0]
return None
except Exception:
return None
def get_git_diff(base_ref: str, target_ref: str, include_stats: bool = True) -> str:
"""Get git diff between two refs (branches, tags, commits)."""
# Get diff with file stats
diff_command = ["git", "diff", f"{base_ref}...{target_ref}"]
if include_stats:
diff_command.append("--stat")
diff_output = run_git_command(diff_command)
# Get detailed diff (limited to avoid token overflow)
detailed_diff_command = [
"git",
"diff",
f"{base_ref}...{target_ref}",
"--unified=3", # 3 lines of context
"--no-color",
]
detailed_diff = run_git_command(detailed_diff_command)
return f"STATISTICS:\n{diff_output}\n\nDETAILED CHANGES:\n{detailed_diff}"
def get_commit_history(base_ref: str, target_ref: str) -> str:
"""Get commit messages between two refs (branches, tags, commits)."""
command = [
"git",
"log",
f"{base_ref}..{target_ref}",
"--pretty=format:- %s (%h) by %an",
"--no-merges",
]
return run_git_command(command)
def get_pr_list(base_ref: str, target_ref: str) -> str:
"""Get list of PR numbers from commit messages."""
command = [
"git",
"log",
f"{base_ref}..{target_ref}",
"--pretty=format:%s",
"--no-merges",
]
commits = run_git_command(command)
# Extract PR numbers (matches #1234 pattern)
pr_numbers = set(re.findall(r"#(\d+)", commits))
return ", ".join(f"#{pr}" for pr in sorted(pr_numbers))
def _parse_dependencies(pyproject_text: str) -> dict[str, str]:
"""Parse direct project dependencies into a {package: specifier} map.
Uses tomllib when available (Python 3.11+), falling back to a line-based
regex parse so the script also works on Python 3.10.
"""
deps: dict[str, str] = {}
def _split(req: str) -> tuple[str, str]:
# Strip environment markers (after ';') and extras ('pkg[extra]').
req = req.split(";")[0].strip()
match = re.match(r"^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(.*)$", req)
if not match:
return req, ""
return match.group(1).lower(), match.group(2).strip()
try:
import tomllib # type: ignore[import-not-found]
data = tomllib.loads(pyproject_text)
for req in data.get("project", {}).get("dependencies", []):
name, spec = _split(req)
if name:
deps[name] = spec
return deps
except Exception:
pass
# Fallback: extract the [project].dependencies = [ ... ] array textually.
match = re.search(r"^dependencies\s*=\s*\[(.*?)\]", pyproject_text, re.DOTALL | re.MULTILINE)
if not match:
return deps
for raw in re.findall(r"""["']([^"']+)["']""", match.group(1)):
name, spec = _split(raw)
if name:
deps[name] = spec
return deps
def get_dependency_changes(base_ref: str, target_ref: str) -> dict[str, list[str]]:
"""Compare direct dependencies in pyproject.toml between two refs."""
changes: dict[str, list[str]] = {"added": [], "removed": [], "changed": []}
try:
base_text = run_git_command(["git", "show", f"{base_ref}:pyproject.toml"])
target_text = run_git_command(["git", "show", f"{target_ref}:pyproject.toml"])
except SystemExit:
return changes
except Exception:
return changes
base_deps = _parse_dependencies(base_text)
target_deps = _parse_dependencies(target_text)
for name, spec in sorted(target_deps.items()):
if name not in base_deps:
changes["added"].append(f"{name} {spec}".strip())
elif base_deps[name] != spec:
changes["changed"].append(f"{name}: {base_deps[name] or '*'}{spec or '*'}")
for name, spec in sorted(base_deps.items()):
if name not in target_deps:
changes["removed"].append(f"{name} {spec}".strip())
return changes
def get_compatibility_info(target_ref: str) -> dict[str, str]:
"""Read compatibility-relevant metadata from pyproject.toml at the target ref."""
info: dict[str, str] = {}
try:
text = run_git_command(["git", "show", f"{target_ref}:pyproject.toml"])
except Exception:
try:
text = (Path(__file__).parent.parent / "pyproject.toml").read_text()
except Exception:
return info
py_match = re.search(r'^requires-python\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
if py_match:
info["python"] = py_match.group(1)
# Surface a few core runtime dependencies users commonly pin against.
deps = _parse_dependencies(text)
for pkg in ("pydantic", "litellm", "fastapi", "sqlalchemy", "lancedb", "ladybug"):
if pkg in deps:
info[pkg] = deps[pkg] or "*"
return info
async def generate_release_notes_with_llm(
diff: str,
commits: str,
base_ref: str,
target_ref: str,
version: str | None = None,
) -> Any:
"""Use LLM to generate human-friendly release notes.
Uses litellm + instructor directly instead of cognee's LLM client
to avoid cognee's dotenv.load_dotenv(override=True) overwriting
CI environment variables.
"""
try:
import instructor
import litellm
from pydantic import BaseModel, Field
except ImportError as e:
print(f"Error: Required dependencies not available: {e}", file=sys.stderr)
print(
"Please ensure litellm, instructor, and pydantic are installed.",
file=sys.stderr,
)
return None
api_key = os.environ.get("LLM_API_KEY")
model = os.environ.get("LLM_MODEL", "openai/gpt-4o-mini")
if not api_key:
print("Warning: LLM_API_KEY not set, skipping LLM generation.", file=sys.stderr)
return None
class ReleaseNotes(BaseModel):
"""Structured release notes."""
title: str = Field(
description=(
"A short, descriptive release title naming the single most important theme "
"of this release, formatted as 'v{version}{Theme}' "
"(e.g. 'v0.1.9 — Memory Graph Improvements'). "
"If no single theme dominates, summarize the largest change area. "
"Keep it under 60 characters and never leave it empty."
)
)
summary: str = Field(description="A brief, engaging summary of the release (2-3 sentences)")
highlights: list[str] = Field(description="3-5 key highlights that users care about most")
features: list[str] = Field(
description=(
"New features added in this release. Each entry must be understandable to a "
"first-time reader: name the feature, say in plain words what it does, and why "
"it matters. Do not use undefined jargon."
)
)
improvements: list[str] = Field(
description="Enhancements and improvements to existing functionality"
)
performance: list[str] = Field(
description=(
"Performance changes: speedups, reduced memory/latency, throughput gains. "
"State the practical impact (e.g. 'faster ingestion of large files')."
)
)
security: list[str] = Field(
description=(
"Security-relevant changes: hardening, auth fixes, vulnerability patches. "
"Do NOT invent CVE identifiers — only mention a CVE if it appears in the diff "
"or commit messages."
)
)
bug_fixes: list[str] = Field(description="Bug fixes and issue resolutions")
breaking_changes: list[str] = Field(description="Breaking changes that require user action")
technical_changes: list[str] = Field(
description="Technical changes, refactoring, and internal improvements"
)
# Truncate diff if too large (keep first 15000 chars)
max_diff_size = 15000
if len(diff) > max_diff_size:
diff = diff[:max_diff_size] + f"\n... (truncated, showing first {max_diff_size} characters)"
system_prompt = """You are a technical writer creating release notes for Cognee, an AI memory platform.
Analyze the git diff and commit history to create clear, user-friendly release notes.
WRITING FOR A FIRST-TIME READER (most important rule):
- Assume the reader has never seen this feature before and does not know Cognee's internal vocabulary.
- Every entry must answer, in plain words: WHAT it is, WHAT it does, and WHY it matters.
- Do NOT use undefined jargon. If a term like "summary layer", "dataset", "graph completion",
or "semantic bucket" is unavoidable, explain it in the same sentence the first time it appears.
- Bad: "An optional summary layer that builds dataset-level orientation through semantic buckets."
Good: "A new optional index that groups a dataset (a collection of documents you've added) into
topic clusters and a short overview, so search has broader context and returns better answers."
- Prefer concrete outcomes ("search returns more relevant results") over abstract descriptions.
Other guidelines:
- Choose ONE dominant theme for the title; if none dominates, name the largest change area.
- Focus on user-facing changes and their benefits.
- Group related changes together and categorize each change correctly
(feature / improvement / performance / security / bug fix / breaking change / technical).
- Highlight breaking changes prominently.
- Be concise but informative; emphasize the "why" and "what it means", not just "what changed".
- Mention specific file names only if critical to understanding.
- Never invent CVE numbers, deprecation dates, or version-support promises that are not in the input.
"""
text_input = f"""Generate release notes for Cognee version {version or "TBD"}.
Base reference: {base_ref}
Target reference: {target_ref}
COMMIT HISTORY:
{commits[:5000]}
GIT DIFF:
{diff}
Create engaging release notes that help users understand what's new and improved.
"""
try:
client = instructor.from_litellm(litellm.acompletion)
response = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": text_input},
],
response_model=ReleaseNotes,
api_key=api_key,
max_retries=2,
)
return response
except Exception as e:
print(f"Warning: LLM generation failed: {e}", file=sys.stderr)
return None
def generate_fallback_notes(
commits: str,
version: str,
) -> Any:
"""Generate basic release notes from commit messages when LLM is unavailable."""
from dataclasses import dataclass, field
@dataclass
class FallbackNotes:
title: str = ""
summary: str = ""
highlights: list[str] = field(default_factory=list)
features: list[str] = field(default_factory=list)
improvements: list[str] = field(default_factory=list)
performance: list[str] = field(default_factory=list)
security: list[str] = field(default_factory=list)
bug_fixes: list[str] = field(default_factory=list)
breaking_changes: list[str] = field(default_factory=list)
technical_changes: list[str] = field(default_factory=list)
notes = FallbackNotes()
for line in commits.strip().split("\n"):
line = line.strip()
if not line or not line.startswith("- "):
continue
msg = line[2:] # strip "- " prefix
lower = msg.lower()
if lower.startswith("feat"):
notes.features.append(msg)
elif lower.startswith("fix"):
notes.bug_fixes.append(msg)
elif lower.startswith("perf"):
notes.performance.append(msg)
elif lower.startswith(("security", "sec(")):
notes.security.append(msg)
elif lower.startswith("breaking"):
notes.breaking_changes.append(msg)
elif any(lower.startswith(p) for p in ("refactor", "chore", "ci", "build", "test")):
notes.technical_changes.append(msg)
else:
notes.improvements.append(msg)
notes.title = f"v{version}{len(notes.features)} features, {len(notes.bug_fixes)} fixes"
notes.summary = f"Cognee v{version} includes {len(notes.features)} new features, {len(notes.bug_fixes)} bug fixes, and {len(notes.improvements)} improvements."
notes.highlights = (notes.features + notes.improvements)[:5]
return notes
def get_release_title(notes: Any, version: str) -> str:
"""Resolve the release title, guaranteeing a non-empty, version-prefixed value."""
# Collapse any whitespace/newlines so the title is safe as a single-line
# GitHub Actions output (RELEASE_TITLE=...).
title = " ".join((getattr(notes, "title", "") or "").split())
if not title:
return f"v{version}"
# Ensure the version prefix is present so releases sort/scan consistently.
if not re.match(rf"^v?{re.escape(version)}\b", title):
return f"v{version}{title}"
return title if title.startswith("v") else f"v{title}"
def format_release_notes(
notes: Any,
version: str,
base_ref: str,
target_ref: str,
pr_list: str,
dep_changes: dict[str, list[str]] | None = None,
compat_info: dict[str, str] | None = None,
) -> str:
"""Format structured release notes into markdown."""
date_str = datetime.now().strftime("%Y-%m-%d")
md = f"# {get_release_title(notes, version)}\n\n"
md += f"**Release Date:** {date_str}\n"
md += f"**Changes:** {base_ref}{target_ref}\n\n"
if pr_list:
md += f"**Pull Requests:** {pr_list}\n\n"
md += "---\n\n"
# Summary
md += f"## Summary\n\n{notes.summary}\n\n"
# Highlights
if notes.highlights:
md += "## Highlights\n\n"
for highlight in notes.highlights:
md += f"- {highlight}\n"
md += "\n"
def _section(heading: str, items: list[str]) -> str:
if not items:
return ""
block = f"## {heading}\n\n"
for item in items:
block += f"- {item}\n"
return block + "\n"
# Ordered by impact.
md += _section("Breaking Changes", notes.breaking_changes)
md += _section("New Features", notes.features)
md += _section("Improvements", notes.improvements)
md += _section("Performance", getattr(notes, "performance", []))
md += _section("Security", getattr(notes, "security", []))
md += _section("Bug Fixes", notes.bug_fixes)
md += _section("Technical Changes", notes.technical_changes)
# Dependency updates (deterministic, parsed from pyproject.toml diff).
if dep_changes and any(dep_changes.values()):
md += "## Dependency Updates\n\n"
if dep_changes.get("added"):
md += "**Added:**\n"
for dep in dep_changes["added"]:
md += f"- {dep}\n"
md += "\n"
if dep_changes.get("changed"):
md += "**Updated:**\n"
for dep in dep_changes["changed"]:
md += f"- {dep}\n"
md += "\n"
if dep_changes.get("removed"):
md += "**Removed:**\n"
for dep in dep_changes["removed"]:
md += f"- {dep}\n"
md += "\n"
# Compatibility matrix (deterministic, read from pyproject.toml).
if compat_info:
md += "## Compatibility\n\n"
md += "| Component | Supported / Required |\n"
md += "| --- | --- |\n"
if compat_info.get("python"):
md += f"| Python | `{compat_info['python']}` |\n"
for pkg, spec in compat_info.items():
if pkg == "python":
continue
md += f"| {pkg} | `{spec}` |\n"
md += "\n"
# Footer
md += "---\n\n"
md += f"*— The Cognee Team · {date_str}*\n"
return md
async def main():
"""Main entry point."""
parser = argparse.ArgumentParser(description="Generate AI-powered release notes from git diff")
parser.add_argument(
"--base",
help="Base ref to compare against (tag, branch, or commit). If not provided, uses latest release tag",
)
parser.add_argument(
"--target",
help="Target ref with new changes (tag, branch, or commit). Defaults to current branch",
)
parser.add_argument(
"--version",
help="Release version number (if not provided, will be extracted from pyproject.toml)",
)
parser.add_argument(
"--output",
help="Output file path (default: stdout)",
)
parser.add_argument(
"--github-output",
action="store_true",
help="Format output for GitHub Actions (sets RELEASE_NOTES environment variable)",
)
args = parser.parse_args()
# Get version from pyproject.toml if not provided
version = args.version
if not version:
try:
# Read version directly from pyproject.toml
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
content = pyproject_path.read_text()
match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE)
if match:
version = match.group(1)
else:
version = "unknown"
except Exception as e:
print(f"Warning: Could not extract version: {e}", file=sys.stderr)
version = "unknown"
# Determine base ref (what to compare against)
base_ref = args.base
if not base_ref:
# Use latest release tag as base
latest_tag = get_latest_release_tag()
if latest_tag:
base_ref = latest_tag
print(f"Using latest release tag as base: {base_ref}", file=sys.stderr)
else:
print("Warning: No release tags found, comparing against main branch", file=sys.stderr)
base_ref = "main"
# Determine target ref (what we're releasing)
target_ref = args.target
if not target_ref:
# Use current branch
target_ref = run_git_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
print(f"Using current branch as target: {target_ref}", file=sys.stderr)
print(f"Generating release notes for version {version}...", file=sys.stderr)
print(f"Comparing {base_ref}...{target_ref}", file=sys.stderr)
# Get git data
diff = get_git_diff(base_ref, target_ref)
commits = get_commit_history(base_ref, target_ref)
pr_list = get_pr_list(base_ref, target_ref)
dep_changes = get_dependency_changes(base_ref, target_ref)
compat_info = get_compatibility_info(target_ref)
# Generate release notes with LLM, fall back to commit-based notes
notes = await generate_release_notes_with_llm(diff, commits, base_ref, target_ref, version)
if notes is None:
print("Falling back to commit-based release notes.", file=sys.stderr)
notes = generate_fallback_notes(commits, version)
# Format as markdown
title = get_release_title(notes, version)
markdown = format_release_notes(
notes, version, base_ref, target_ref, pr_list, dep_changes, compat_info
)
# Output results
if args.github_output:
# GitHub Actions multi-line output
delimiter = "EOF"
output_var = f"RELEASE_NOTES<<{delimiter}\n{markdown}\n{delimiter}\n"
output_var += f"RELEASE_TITLE={title}\n"
# Write to GITHUB_OUTPUT file
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as f:
f.write(output_var)
print("Release notes written to GITHUB_OUTPUT", file=sys.stderr)
else:
print(output_var)
elif args.output:
with open(args.output, "w") as f:
f.write(markdown)
print(f"Release notes written to {args.output}", file=sys.stderr)
else:
print(markdown)
print("\nRelease notes generated successfully!", file=sys.stderr)
if __name__ == "__main__":
asyncio.run(main())