Newsletter generator: automated narrative summaries of releases
Introduces a newsletter generator script that creates narrative-style summaries. Original PR: #1198 by @maphew Closes #1197
This commit is contained in:
765
scripts/generate-newsletter.py
Executable file
765
scripts/generate-newsletter.py
Executable file
@@ -0,0 +1,765 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "anthropic>=0.18.0",
|
||||
# "openai>=1.0.0",
|
||||
# "python-dotenv>=1.0.0",
|
||||
# "typer>=0.9.0",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
"""
|
||||
Generate a weekly Beads newsletter based on changelog, commits, and changes.
|
||||
|
||||
Usage:
|
||||
python generate-newsletter.py
|
||||
python generate-newsletter.py --model gpt-4o
|
||||
python generate-newsletter.py --since 2025-12-15
|
||||
python generate-newsletter.py --days 30
|
||||
python generate-newsletter.py --from-release v0.39 --to-release v0.48
|
||||
|
||||
Environment Variables:
|
||||
AI_MODEL - The AI model to use (default: "claude-opus-4-1-20250805", e.g., "gpt-4o")
|
||||
ANTHROPIC_API_KEY or OPENAI_API_KEY - API credentials
|
||||
AUTO_COMMIT - If "true", automatically commit and push the newsletter
|
||||
|
||||
Configuration File:
|
||||
.env - Optional dotenv file in project root for API keys
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
|
||||
# Load environment variables from .env file if it exists
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass # python-dotenv is optional, env vars can be set directly
|
||||
|
||||
# Try to import anthropic, fall back to openai if available
|
||||
try:
|
||||
from anthropic import Anthropic
|
||||
ANTHROPIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
ANTHROPIC_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
OPENAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
OPENAI_AVAILABLE = False
|
||||
|
||||
|
||||
# Newsletter prompt template
|
||||
NEWSLETTER_PROMPT_TEMPLATE = """Generate a Beads newsletter covering the period from {since_date} to {until_date}.
|
||||
|
||||
Reporting Period:
|
||||
- Date Range: {since_date} to {until_date}
|
||||
- {version_info}
|
||||
|
||||
## Recent Commits (last 50)
|
||||
{commit_summary}
|
||||
|
||||
## Changelog for {version}
|
||||
{changelog}
|
||||
|
||||
{new_commands_text}
|
||||
|
||||
{breaking_text}
|
||||
|
||||
Please write a newsletter that includes:
|
||||
1. **Header with release versions** - ALWAYS state the release versions AND beginning & end of reporting range at the top: "v0.47.0 - v0.48.0, 2026-Jan-10 to 2026-Jan-17"
|
||||
2. A brief intro about the release period
|
||||
3. **New Commands & Options** section - describe what each does, why users should care, and show brief example
|
||||
4. **Breaking Changes** section (if any) - explain what changed and why, migration path if applicable
|
||||
5. Major Features & Bug Fixes (3-5 most significant)
|
||||
6. Minor improvements/changes
|
||||
7. Getting started section
|
||||
8. Link to full changelog.md and GH release page
|
||||
|
||||
Default to writing in narrative paragraphs, use bullets sparingly.
|
||||
Mention dates, significant commit hashes, and link to the relevant docs adjacent to their sections.
|
||||
Keep it 500 to 1000 words.
|
||||
Use emoji where appropriate.
|
||||
Format in Markdown.
|
||||
"""
|
||||
|
||||
|
||||
def check_git_branch() -> Optional[str]:
|
||||
"""Check current git branch and warn if not on main."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
current_branch = result.stdout.strip()
|
||||
return current_branch
|
||||
except subprocess.CalledProcessError:
|
||||
# Not in a git repo or git not available
|
||||
return None
|
||||
|
||||
|
||||
def get_all_versions() -> list[tuple[str, datetime]]:
|
||||
"""Extract all versions and dates from CHANGELOG.md."""
|
||||
changelog_path = Path(__file__).parent.parent / "CHANGELOG.md"
|
||||
|
||||
with open(changelog_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Match version pattern like [0.44.0] - 2026-01-04
|
||||
pattern = r'## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})'
|
||||
matches = re.finditer(pattern, content)
|
||||
|
||||
versions = []
|
||||
for match in matches:
|
||||
version = match.group(1)
|
||||
date_str = match.group(2)
|
||||
date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
versions.append((version, date))
|
||||
|
||||
return versions
|
||||
|
||||
|
||||
def get_version_by_release(version_str: str) -> tuple[str, datetime]:
|
||||
"""Find a specific version by version string (e.g., 'v0.44.0' or '0.44.0')."""
|
||||
versions = get_all_versions()
|
||||
|
||||
# Normalize the input (remove 'v' prefix if present)
|
||||
normalized = version_str.lstrip('v')
|
||||
|
||||
for version, date in versions:
|
||||
if version == normalized:
|
||||
return version, date
|
||||
|
||||
raise ValueError(f"Version {version_str} not found in CHANGELOG.md")
|
||||
|
||||
|
||||
def get_previous_version() -> tuple[str, datetime]:
|
||||
"""Extract the most recent version and date from CHANGELOG.md."""
|
||||
versions = get_all_versions()
|
||||
if versions:
|
||||
return versions[0]
|
||||
raise ValueError("Could not find any versions in CHANGELOG.md")
|
||||
|
||||
|
||||
def get_commits_since(since_date: datetime) -> list[dict]:
|
||||
"""Get git commits since the given date."""
|
||||
result = subprocess.run(
|
||||
["git", "log", f"--since={since_date.strftime('%Y-%m-%d')}", "--oneline", "--format=%h|%s|%an|%ai"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
commits = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line:
|
||||
parts = line.split('|', 3)
|
||||
if len(parts) >= 2:
|
||||
commit_date = None
|
||||
if len(parts) >= 4:
|
||||
try:
|
||||
# Parse as naive datetime (extract just the date part)
|
||||
date_str = parts[3].strip().split()[0]
|
||||
commit_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except (ValueError, AttributeError, IndexError):
|
||||
pass
|
||||
|
||||
commits.append({
|
||||
'hash': parts[0],
|
||||
'subject': parts[1],
|
||||
'author': parts[2] if len(parts) > 2 else 'unknown',
|
||||
'date': commit_date,
|
||||
})
|
||||
|
||||
return commits
|
||||
|
||||
|
||||
def get_changelog_section(version: str) -> str:
|
||||
"""Extract the changelog section for a specific version."""
|
||||
changelog_path = Path(__file__).parent.parent / "CHANGELOG.md"
|
||||
|
||||
with open(changelog_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Find the section for this version
|
||||
pattern = rf'## \[{re.escape(version)}\].*?(?=## \[|\Z)'
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
|
||||
if match:
|
||||
return match.group(0)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def extract_new_commands(from_version: str, to_version: str) -> list[dict]:
|
||||
"""Extract new commands added between versions by diffing cmd/ directory.
|
||||
|
||||
Returns list of dicts with: name, short_desc, file_path
|
||||
"""
|
||||
try:
|
||||
# Normalize version strings (remove 'v' prefix if present)
|
||||
from_ver = from_version.lstrip('v')
|
||||
to_ver = to_version.lstrip('v')
|
||||
|
||||
# Try with v prefix first, then without
|
||||
for prefix in ['v', '']:
|
||||
result = subprocess.run(
|
||||
["git", "diff", f"{prefix}{from_ver}..{prefix}{to_ver}", "--name-only", "--", "cmd/bd"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
break
|
||||
|
||||
if result.returncode != 0:
|
||||
# Versions don't exist as tags, return empty
|
||||
return []
|
||||
|
||||
changed_files = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||||
|
||||
commands = []
|
||||
seen = set()
|
||||
|
||||
for file_path in changed_files:
|
||||
if not file_path or file_path.endswith('_test.go'):
|
||||
continue
|
||||
|
||||
# Read the file to extract cobra.Command definitions
|
||||
try:
|
||||
full_path = Path(__file__).parent.parent / file_path
|
||||
content = full_path.read_text()
|
||||
|
||||
# Look for patterns like: var someCmd = &cobra.Command{ ... Use: "commandname" ... Short: "description"
|
||||
cmd_pattern = r'var\s+(\w+Cmd)\s*=\s*&cobra\.Command\{[^}]*?Use:\s*["\']([^"\']+)["\'][^}]*?Short:\s*["\']([^"\']+)["\']'
|
||||
for match in re.finditer(cmd_pattern, content, re.DOTALL):
|
||||
var_name = match.group(1)
|
||||
use = match.group(2)
|
||||
short = match.group(3)
|
||||
|
||||
# Extract just the command name (first word)
|
||||
cmd_name = use.split()[0]
|
||||
|
||||
# Skip if we've seen this one
|
||||
if cmd_name not in seen:
|
||||
seen.add(cmd_name)
|
||||
commands.append({
|
||||
'name': cmd_name,
|
||||
'short': short,
|
||||
'file': file_path
|
||||
})
|
||||
except (FileNotFoundError, IOError):
|
||||
continue
|
||||
|
||||
return sorted(commands, key=lambda x: x['name'])[:5] # Top 5
|
||||
except (subprocess.CalledProcessError, Exception):
|
||||
return []
|
||||
|
||||
|
||||
def extract_breaking_changes(changelog_section: str) -> list[dict]:
|
||||
"""Extract breaking changes from changelog section.
|
||||
|
||||
Returns list of dicts with: title, description
|
||||
"""
|
||||
if not changelog_section:
|
||||
return []
|
||||
|
||||
breaking = []
|
||||
|
||||
# Look for "Breaking" subsection
|
||||
breaking_pattern = r'###\s+Breaking[^\n]*\n(.*?)(?=###|\Z)'
|
||||
breaking_match = re.search(breaking_pattern, changelog_section, re.IGNORECASE | re.DOTALL)
|
||||
|
||||
if breaking_match:
|
||||
breaking_text = breaking_match.group(1)
|
||||
# Split by top-level bullet points (not indented)
|
||||
# Matches: - **Title** followed by optional inline description or newline
|
||||
items = re.findall(r'^[-*]\s+\*\*([^*]+)\*\*\s*(?:-\s+)?([^\n]*)', breaking_text, re.MULTILINE)
|
||||
for title, description in items[:5]: # Top 5
|
||||
# Filter out empty descriptions or nested bullet continuations
|
||||
desc = description.strip()
|
||||
if desc and not desc.startswith('-'):
|
||||
breaking.append({
|
||||
'title': title.strip(),
|
||||
'description': desc
|
||||
})
|
||||
|
||||
return breaking
|
||||
|
||||
|
||||
def find_docs_for_command(command_name: str) -> str:
|
||||
"""Find documentation for a command in README, docs/, or CHANGELOG.
|
||||
|
||||
Returns relevant excerpt or empty string.
|
||||
"""
|
||||
# Search in order of priority
|
||||
search_files = [
|
||||
Path(__file__).parent.parent / "README.md",
|
||||
]
|
||||
|
||||
# Add docs files
|
||||
docs_dir = Path(__file__).parent.parent / "docs"
|
||||
if docs_dir.exists():
|
||||
search_files.extend(sorted(docs_dir.glob("*.md")))
|
||||
|
||||
for file_path in search_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
# Look for command mention with context
|
||||
pattern = rf'`{re.escape(command_name)}`|bd\s+{re.escape(command_name)}'
|
||||
if re.search(pattern, content, re.IGNORECASE):
|
||||
# Find the paragraph/section containing this command
|
||||
matches = re.finditer(rf'[^\n]*{re.escape(command_name)}[^\n]*', content, re.IGNORECASE)
|
||||
for match in matches:
|
||||
start = max(0, match.start() - 200)
|
||||
end = min(len(content), match.end() + 200)
|
||||
excerpt = content[start:end].strip()
|
||||
if len(excerpt) > 20: # Filter out noise
|
||||
return excerpt
|
||||
except (FileNotFoundError, IOError):
|
||||
continue
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def get_model_pricing(model: str) -> tuple[float, float]:
|
||||
"""Get pricing for a model (input_cost, output_cost per 1M tokens).
|
||||
|
||||
Returns tuple of (input_price_per_1m, output_price_per_1m).
|
||||
Prices in dollars per million tokens.
|
||||
"""
|
||||
model_lower = model.lower()
|
||||
|
||||
# Anthropic models
|
||||
if 'opus-4-1' in model_lower or 'opus-4.1' in model_lower:
|
||||
return (15.0, 45.0)
|
||||
elif 'opus' in model_lower:
|
||||
return (15.0, 45.0)
|
||||
elif 'sonnet-4-5' in model_lower:
|
||||
return (3.0, 15.0)
|
||||
elif 'sonnet' in model_lower:
|
||||
return (3.0, 15.0)
|
||||
elif 'haiku-4-5' in model_lower:
|
||||
return (0.80, 4.0)
|
||||
elif 'haiku' in model_lower:
|
||||
return (0.80, 4.0)
|
||||
|
||||
# OpenAI models
|
||||
elif 'gpt-4o' in model_lower:
|
||||
return (5.0, 15.0)
|
||||
elif 'gpt-4-turbo' in model_lower:
|
||||
return (10.0, 30.0)
|
||||
elif 'gpt-4' in model_lower:
|
||||
return (30.0, 60.0)
|
||||
elif 'gpt-3.5' in model_lower:
|
||||
return (0.50, 1.50)
|
||||
|
||||
# Default: unknown pricing
|
||||
return (0.0, 0.0)
|
||||
|
||||
|
||||
def get_model_cost_info(model: str) -> str:
|
||||
"""Get cost information for a model.
|
||||
|
||||
Returns a string with relative cost tier (cheap, moderate, expensive) and approximate cost.
|
||||
"""
|
||||
model_lower = model.lower()
|
||||
input_price, output_price = get_model_pricing(model)
|
||||
|
||||
if input_price == 0:
|
||||
return f"{model} (cost unknown)"
|
||||
|
||||
# Anthropic models
|
||||
if 'opus-4-1' in model_lower or 'opus-4.1' in model_lower:
|
||||
return f"claude-opus-4-1 (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
elif 'opus' in model_lower:
|
||||
return f"claude-opus (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
elif 'sonnet-4-5' in model_lower:
|
||||
return f"claude-sonnet-4-5 (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
elif 'sonnet' in model_lower:
|
||||
return f"claude-sonnet (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
elif 'haiku-4-5' in model_lower:
|
||||
return f"claude-haiku-4-5 (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
elif 'haiku' in model_lower:
|
||||
return f"claude-haiku (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
|
||||
# OpenAI models
|
||||
elif 'gpt-4o' in model_lower:
|
||||
return f"gpt-4o (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
elif 'gpt-4-turbo' in model_lower:
|
||||
return f"gpt-4-turbo (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
elif 'gpt-4' in model_lower:
|
||||
return f"gpt-4 (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
elif 'gpt-3.5' in model_lower:
|
||||
return f"gpt-3.5-turbo (${input_price}/${output_price} per 1M input/output tokens)"
|
||||
|
||||
return f"{model} (cost unknown)"
|
||||
|
||||
|
||||
def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
|
||||
"""Calculate the actual cost of a generation.
|
||||
|
||||
Returns cost in dollars.
|
||||
"""
|
||||
input_price, output_price = get_model_pricing(model)
|
||||
input_cost = (input_tokens / 1_000_000) * input_price
|
||||
output_cost = (output_tokens / 1_000_000) * output_price
|
||||
return input_cost + output_cost
|
||||
|
||||
|
||||
def detect_ai_provider(model: str) -> str:
|
||||
"""Detect AI provider from model name."""
|
||||
model_lower = model.lower()
|
||||
if 'claude' in model_lower:
|
||||
return 'anthropic'
|
||||
elif 'gpt' in model_lower or 'openai' in model_lower:
|
||||
return 'openai'
|
||||
elif 'o1' in model_lower or 'o3' in model_lower:
|
||||
return 'openai' # OpenAI reasoning models
|
||||
else:
|
||||
return 'anthropic' # Default
|
||||
|
||||
|
||||
def get_ai_client(provider: str):
|
||||
"""Get AI client based on provider."""
|
||||
if provider == 'anthropic':
|
||||
api_key = os.environ.get('ANTHROPIC_API_KEY')
|
||||
if not api_key:
|
||||
raise ValueError("ANTHROPIC_API_KEY environment variable not set")
|
||||
client = Anthropic(api_key=api_key)
|
||||
return client
|
||||
elif provider == 'openai':
|
||||
api_key = os.environ.get('OPENAI_API_KEY')
|
||||
if not api_key:
|
||||
raise ValueError("OPENAI_API_KEY environment variable not set")
|
||||
client = OpenAI(api_key=api_key)
|
||||
return client
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
|
||||
|
||||
def build_newsletter_prompt(commits: list[dict], changelog: str, version: str, since_date: datetime,
|
||||
until_date: datetime = None,
|
||||
new_commands: list[dict] = None, breaking_changes: list[dict] = None,
|
||||
from_version: str = None, to_version: str = None) -> str:
|
||||
"""Build the newsletter prompt from components.
|
||||
|
||||
Returns the formatted prompt string.
|
||||
"""
|
||||
if until_date is None:
|
||||
until_date = datetime.now()
|
||||
|
||||
commit_summary = "\n".join([f"- {c['subject']}" for c in commits[:50]])
|
||||
|
||||
# Build structured sections
|
||||
new_commands_text = ""
|
||||
if new_commands:
|
||||
new_commands_text = "## New Commands & Options\n"
|
||||
for cmd in new_commands:
|
||||
new_commands_text += f"- **{cmd['name']}** - {cmd['short']}\n"
|
||||
|
||||
breaking_text = ""
|
||||
if breaking_changes:
|
||||
breaking_text = "## Breaking Changes\n"
|
||||
for change in breaking_changes:
|
||||
breaking_text += f"- **{change['title']}** - {change['description']}\n"
|
||||
|
||||
# Build version info for header
|
||||
version_info = ""
|
||||
if from_version and to_version:
|
||||
version_info = f"Release Range: v{from_version} to v{to_version}"
|
||||
elif from_version:
|
||||
version_info = f"Starting from: v{from_version}"
|
||||
elif version and version != "Newsletter":
|
||||
version_info = f"Version: {version}"
|
||||
else:
|
||||
version_info = f"Period: {version}"
|
||||
|
||||
since_str = since_date.strftime('%B %d, %Y')
|
||||
until_str = until_date.strftime('%B %d, %Y')
|
||||
|
||||
return NEWSLETTER_PROMPT_TEMPLATE.format(
|
||||
since_date=since_str,
|
||||
until_date=until_str,
|
||||
version_info=version_info,
|
||||
commit_summary=commit_summary,
|
||||
version=version,
|
||||
changelog=changelog[:3000],
|
||||
new_commands_text=new_commands_text,
|
||||
breaking_text=breaking_text,
|
||||
)
|
||||
|
||||
|
||||
def generate_with_claude(client, commits: list[dict], changelog: str, version: str, since_date: datetime,
|
||||
until_date: datetime = None,
|
||||
new_commands: list[dict] = None, breaking_changes: list[dict] = None,
|
||||
from_version: str = None, to_version: str = None) -> tuple[str, int, int]:
|
||||
"""Generate newsletter using Claude."""
|
||||
prompt = build_newsletter_prompt(commits, changelog, version, since_date, until_date,
|
||||
new_commands, breaking_changes, from_version, to_version)
|
||||
|
||||
response = client.messages.create(
|
||||
model="claude-opus-4-1-20250805",
|
||||
max_tokens=4000,
|
||||
messages=[
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
)
|
||||
|
||||
# Extract token usage
|
||||
input_tokens = response.usage.input_tokens
|
||||
output_tokens = response.usage.output_tokens
|
||||
|
||||
return response.content[0].text, input_tokens, output_tokens
|
||||
|
||||
|
||||
def generate_with_openai(client, commits: list[dict], changelog: str, version: str, since_date: datetime,
|
||||
until_date: datetime = None,
|
||||
new_commands: list[dict] = None, breaking_changes: list[dict] = None,
|
||||
from_version: str = None, to_version: str = None) -> tuple[str, int, int]:
|
||||
"""Generate newsletter using OpenAI."""
|
||||
prompt = build_newsletter_prompt(commits, changelog, version, since_date, until_date,
|
||||
new_commands, breaking_changes, from_version, to_version)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
max_tokens=4000
|
||||
)
|
||||
|
||||
# Extract token usage
|
||||
input_tokens = response.usage.prompt_tokens
|
||||
output_tokens = response.usage.completion_tokens
|
||||
|
||||
return response.choices[0].message.content, input_tokens, output_tokens
|
||||
|
||||
|
||||
def generate_newsletter(
|
||||
model: Optional[str] = None,
|
||||
since_date: Optional[datetime] = None,
|
||||
until_date: Optional[datetime] = None,
|
||||
version: Optional[str] = None,
|
||||
from_version: Optional[str] = None,
|
||||
to_version: Optional[str] = None,
|
||||
) -> tuple[str, str, datetime, datetime, int, int, float]:
|
||||
"""Generate newsletter content.
|
||||
|
||||
Returns:
|
||||
(newsletter_content, version_range, since_date, until_date)
|
||||
"""
|
||||
# Determine the time period
|
||||
if since_date is None or until_date is None:
|
||||
# Use the current version as reference
|
||||
curr_version, curr_date = get_previous_version()
|
||||
|
||||
if since_date is None:
|
||||
# Check if we should use last week or since last release
|
||||
week_ago = datetime.now() - timedelta(days=7)
|
||||
since_date = curr_date if curr_date > week_ago else week_ago
|
||||
|
||||
if until_date is None:
|
||||
until_date = datetime.now()
|
||||
|
||||
version = curr_version
|
||||
else:
|
||||
# since_date and until_date are provided
|
||||
if version is None:
|
||||
version = "Newsletter"
|
||||
|
||||
# Get commits in the specified date range
|
||||
commits = get_commits_since(since_date)
|
||||
# Filter commits to be before until_date (both are naive datetimes)
|
||||
commits = [c for c in commits if c.get('date') is None or c['date'] <= until_date]
|
||||
|
||||
# Get changelog for this version if applicable
|
||||
changelog = ""
|
||||
if version and version != "Newsletter":
|
||||
changelog = get_changelog_section(version)
|
||||
|
||||
# Extract new commands and breaking changes if we have version info
|
||||
new_commands = []
|
||||
breaking_changes = []
|
||||
|
||||
if from_version and to_version:
|
||||
# Extract from actual version range
|
||||
new_commands = extract_new_commands(from_version, to_version)
|
||||
if changelog:
|
||||
breaking_changes = extract_breaking_changes(changelog)
|
||||
elif version and version != "Newsletter":
|
||||
# Try to extract from changelog if version is available
|
||||
if changelog:
|
||||
breaking_changes = extract_breaking_changes(changelog)
|
||||
|
||||
# Determine AI model
|
||||
if model is None:
|
||||
model = os.environ.get('AI_MODEL', 'claude-opus-4-1-20250805')
|
||||
|
||||
# Detect provider and generate
|
||||
provider = detect_ai_provider(model)
|
||||
|
||||
cost_info = get_model_cost_info(model)
|
||||
typer.echo(f"Using AI provider: {provider}")
|
||||
typer.echo(f"Model: {cost_info}")
|
||||
typer.echo(f"Period: {since_date.strftime('%Y-%m-%d')} to {until_date.strftime('%Y-%m-%d')}")
|
||||
typer.echo(f"Found {len(commits)} commits")
|
||||
if new_commands:
|
||||
typer.echo(f"Found {len(new_commands)} new commands")
|
||||
if breaking_changes:
|
||||
typer.echo(f"Found {len(breaking_changes)} breaking changes")
|
||||
|
||||
client = get_ai_client(provider)
|
||||
|
||||
if provider == 'anthropic':
|
||||
newsletter, input_tokens, output_tokens = generate_with_claude(client, commits, changelog, version, since_date, until_date, new_commands, breaking_changes, from_version, to_version)
|
||||
else:
|
||||
newsletter, input_tokens, output_tokens = generate_with_openai(client, commits, changelog, version, since_date, until_date, new_commands, breaking_changes, from_version, to_version)
|
||||
|
||||
# Calculate actual cost
|
||||
actual_cost = calculate_cost(model, input_tokens, output_tokens)
|
||||
|
||||
return newsletter, version, since_date, until_date, input_tokens, output_tokens, actual_cost
|
||||
|
||||
|
||||
app = typer.Typer(help="Generate a weekly Beads newsletter based on changelog and commits")
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
model: Optional[str] = typer.Option(None, "--model", "-m", help="AI model to use (default: claude-opus-4-1-20250805, e.g., gpt-4o, claude-sonnet-4-5-20250929)"),
|
||||
output: str = typer.Option("NEWSLETTER.md", "--output", "-o", help="Output file"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Print to stdout instead of writing file"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Skip branch check warning"),
|
||||
since: Optional[str] = typer.Option(None, "--since", help="Start date (YYYY-MM-DD) or relative (e.g., 14d for last 14 days)"),
|
||||
days: Optional[int] = typer.Option(None, "--days", help="Generate for the last N days"),
|
||||
from_release: Optional[str] = typer.Option(None, "--from-release", help="Start from a specific release (e.g., v0.39.0 or 0.39.0)"),
|
||||
to_release: Optional[str] = typer.Option(None, "--to-release", help="End at a specific release (e.g., v0.48.0 or 0.48.0)"),
|
||||
):
|
||||
"""Generate a newsletter for a specified time period or release range.
|
||||
|
||||
Examples:
|
||||
# Generate for last week (default)
|
||||
python generate-newsletter.py
|
||||
|
||||
# Generate for last 30 days
|
||||
python generate-newsletter.py --days 30
|
||||
|
||||
# Generate since a specific date
|
||||
python generate-newsletter.py --since 2025-12-15
|
||||
|
||||
# Generate between two releases
|
||||
python generate-newsletter.py --from-release v0.39.0 --to-release v0.48.0
|
||||
"""
|
||||
# Check git branch before proceeding
|
||||
if not force:
|
||||
current_branch = check_git_branch()
|
||||
if current_branch is None or current_branch == 'HEAD':
|
||||
typer.echo("⚠️ WARNING: You are in detached HEAD state (not on any branch)", err=True)
|
||||
typer.echo(" Releases are made from the 'main' branch.", err=True)
|
||||
typer.echo(" The newsletter will be generated from the current commit's CHANGELOG.md.", err=True)
|
||||
typer.echo(" This may result in an outdated newsletter if you're not on the latest main.", err=True)
|
||||
if not typer.confirm("Continue anyway?"):
|
||||
typer.echo("Aborted.")
|
||||
raise typer.Exit(0)
|
||||
elif current_branch != 'main':
|
||||
typer.echo(f"⚠️ WARNING: You are on branch '{current_branch}', not 'main'", err=True)
|
||||
typer.echo(" Releases are made from the 'main' branch.", err=True)
|
||||
typer.echo(" The newsletter will be generated from the current branch's CHANGELOG.md.", err=True)
|
||||
typer.echo(" This may result in an outdated newsletter if your branch is behind main.", err=True)
|
||||
if not typer.confirm("Continue anyway?"):
|
||||
typer.echo("Aborted.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
try:
|
||||
# Determine time period based on arguments
|
||||
since_date: Optional[datetime] = None
|
||||
until_date: Optional[datetime] = None
|
||||
version: Optional[str] = None
|
||||
|
||||
if from_release and to_release:
|
||||
# Both releases specified
|
||||
start_ver, start_date = get_version_by_release(from_release)
|
||||
end_ver, end_date = get_version_by_release(to_release)
|
||||
since_date = start_date
|
||||
until_date = end_date
|
||||
version = f"{start_ver} to {end_ver}"
|
||||
elif from_release:
|
||||
# Only from_release specified
|
||||
start_ver, start_date = get_version_by_release(from_release)
|
||||
since_date = start_date
|
||||
until_date = datetime.now()
|
||||
version = f"{start_ver} to present"
|
||||
elif to_release:
|
||||
# Only to_release specified (generate up to that release)
|
||||
end_ver, end_date = get_version_by_release(to_release)
|
||||
since_date = datetime(2000, 1, 1) # From beginning
|
||||
until_date = end_date
|
||||
version = f"up to {end_ver}"
|
||||
elif days:
|
||||
# Last N days
|
||||
until_date = datetime.now()
|
||||
since_date = until_date - timedelta(days=days)
|
||||
version = f"Last {days} days"
|
||||
elif since:
|
||||
# Parse since parameter
|
||||
until_date = datetime.now()
|
||||
if since.endswith('d'):
|
||||
# Relative: e.g., "14d"
|
||||
num_days = int(since[:-1])
|
||||
since_date = until_date - timedelta(days=num_days)
|
||||
version = f"Last {num_days} days"
|
||||
else:
|
||||
# Absolute date
|
||||
since_date = datetime.strptime(since, "%Y-%m-%d")
|
||||
version = f"Since {since}"
|
||||
|
||||
newsletter, version_str, start, end, input_tokens, output_tokens, actual_cost = generate_newsletter(
|
||||
model=model,
|
||||
since_date=since_date,
|
||||
until_date=until_date,
|
||||
version=version,
|
||||
from_version=from_release,
|
||||
to_version=to_release,
|
||||
)
|
||||
|
||||
# Display generation summary
|
||||
typer.echo("")
|
||||
typer.echo("=== Generation Summary ===")
|
||||
typer.echo(f"Tokens used: {input_tokens:,} input + {output_tokens:,} output = {input_tokens + output_tokens:,} total")
|
||||
typer.echo(f"Estimated cost: ${actual_cost:.4f}")
|
||||
|
||||
if dry_run:
|
||||
typer.echo("")
|
||||
typer.echo(newsletter)
|
||||
else:
|
||||
Path(output).write_text(newsletter)
|
||||
typer.echo(f"Newsletter written to {output}")
|
||||
|
||||
# Optionally commit and push
|
||||
if os.environ.get('AUTO_COMMIT', '').lower() == 'true':
|
||||
subprocess.run(['git', 'add', output], check=True)
|
||||
commit_msg = f'docs: update newsletter for {version_str}'
|
||||
subprocess.run(['git', 'commit', '-m', commit_msg], check=True)
|
||||
subprocess.run(['git', 'push'], check=True)
|
||||
typer.echo("Committed and pushed newsletter")
|
||||
|
||||
except Exception as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
126
scripts/generate-newsletter_README.md
Normal file
126
scripts/generate-newsletter_README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Newsletter Generator
|
||||
|
||||
This script generates a weekly Beads newsletter based on the changelog, git commits, and code changes.
|
||||
|
||||
## Setup
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set the appropriate API key for your chosen model:
|
||||
|
||||
```bash
|
||||
# For Claude
|
||||
export ANTHROPIC_API_KEY="your-api-key"
|
||||
|
||||
# For OpenAI
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
|
||||
# Optionally set the model (defaults to claude-sonnet-4-20250514)
|
||||
export AI_MODEL="claude-sonnet-4-20250514"
|
||||
# or
|
||||
export AI_MODEL="gpt-4o"
|
||||
|
||||
# Optional: Auto-commit and push
|
||||
export AUTO_COMMIT="true"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Generate a newsletter (last week)
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
uv run scripts/generate-newsletter.py
|
||||
```
|
||||
|
||||
### Generate for last N days
|
||||
|
||||
```bash
|
||||
uv run scripts/generate-newsletter.py --days 30
|
||||
```
|
||||
|
||||
### Generate since a specific date
|
||||
|
||||
```bash
|
||||
# Since an absolute date
|
||||
uv run scripts/generate-newsletter.py --since 2025-12-15
|
||||
|
||||
# Or use relative format (last 14 days)
|
||||
uv run scripts/generate-newsletter.py --since 14d
|
||||
```
|
||||
|
||||
### Generate for a specific release range
|
||||
|
||||
```bash
|
||||
# From v0.39 to v0.48
|
||||
uv run scripts/generate-newsletter.py --from-release v0.39.0 --to-release v0.48.0
|
||||
|
||||
# From v0.39 to present
|
||||
uv run scripts/generate-newsletter.py --from-release v0.39.0
|
||||
|
||||
# Up to v0.48.0
|
||||
uv run scripts/generate-newsletter.py --to-release v0.48.0
|
||||
```
|
||||
|
||||
### With specific AI model
|
||||
|
||||
```bash
|
||||
uv run scripts/generate-newsletter.py --model gpt-4o
|
||||
```
|
||||
|
||||
### Dry run (print to stdout)
|
||||
|
||||
```bash
|
||||
uv run scripts/generate-newsletter.py --dry-run
|
||||
```
|
||||
|
||||
### Output to specific file
|
||||
|
||||
```bash
|
||||
uv run scripts/generate-newsletter.py --output my-newsletter.md
|
||||
```
|
||||
|
||||
### Help
|
||||
|
||||
```bash
|
||||
uv run scripts/generate-newsletter.py --help
|
||||
```
|
||||
|
||||
## Cron Job Setup
|
||||
|
||||
Add to your crontab for weekly generation:
|
||||
|
||||
```bash
|
||||
# Run every Monday at 9 AM
|
||||
0 9 * * 1 cd /path/to/beads && uv run scripts/generate-newsletter.py
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The newsletter generator creates a deeper dive beyond the changelog with workflow-impacting content:
|
||||
|
||||
1. **Reads the most recent version** from `CHANGELOG.md`
|
||||
2. **Determines time period** - uses "last week" or "since last release" (whichever is longer), or as specified
|
||||
3. **Fetches git commits** for that period
|
||||
4. **Extracts changelog section** for the current version
|
||||
5. **Extracts new commands & options** - diffs the `cmd/` directory between versions, parses cobra command definitions to identify new CLI commands with descriptions
|
||||
6. **Extracts breaking changes** - mines the changelog for explicit breaking change entries
|
||||
7. **Finds documentation context** - searches `README.md` and `docs/` for relevant command documentation
|
||||
8. **Sends structured data to AI** - includes commits, changelog, new commands, and breaking changes
|
||||
9. **AI generates narrative newsletter** - creates prose sections (not bullet lists) explaining:
|
||||
- Why new commands matter and how to use them
|
||||
- What breaking changes require and migration paths
|
||||
- Which features users should prioritize exploring
|
||||
10. **Writes to `NEWSLETTER.md`**
|
||||
|
||||
The AI prompt specifically requests narrative paragraphs to help users understand workflow impacts and new features worth exploring, rather than just listing changes.
|
||||
|
||||
## Supported Models
|
||||
|
||||
| Provider | Example Models |
|
||||
|----------|---------------|
|
||||
| Anthropic | `claude-sonnet-4-20250514`, `claude-opus-4-20250514` |
|
||||
| OpenAI | `gpt-4o`, `gpt-4o-mini`, `o1-preview`, `o3-mini` |
|
||||
|
||||
The script auto-detects the provider from the model name.
|
||||
Reference in New Issue
Block a user