mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 22:35:42 +00:00
- Add a "Bottom Line Up Front" section after the title with a short prose paragraph highlighting the most important changes and what to look for when testing - Wrap HTML output in proper document with UTF-8 charset declaration to fix Unicode character rendering (em-dashes, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
474 lines
15 KiB
Bash
Executable File
474 lines
15 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# generate_changelog.sh
|
|
#
|
|
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
|
|
# and sends an HTML email via Fastmail JMAP API.
|
|
#
|
|
# Usage: ./scripts/generate_changelog.sh [--dry-run]
|
|
#
|
|
# Configuration files (in ~/.config/eagle0/):
|
|
# fastmail_token - API token (required)
|
|
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
|
|
#
|
|
# To set up:
|
|
# mkdir -p ~/.config/eagle0
|
|
# echo 'your-token' > ~/.config/eagle0/fastmail_token
|
|
# chmod 600 ~/.config/eagle0/fastmail_token
|
|
#
|
|
# # Optional: configure recipients (one per line, # for comments)
|
|
# cat > ~/.config/eagle0/changelog_recipient << EOF
|
|
# alice@example.com
|
|
# bob@example.com
|
|
# EOF
|
|
#
|
|
# The script tracks its last run using a git tag 'changelog-last-run'.
|
|
# On first run (no tag), it defaults to the previous Friday at 4pm.
|
|
|
|
set -euo pipefail
|
|
|
|
# Ensure homebrew binaries are in PATH
|
|
export PATH="/opt/homebrew/bin:$PATH"
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
TAG_NAME="changelog-last-run"
|
|
DRY_RUN=false
|
|
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
|
|
CONFIG_DIR="$HOME/.config/eagle0"
|
|
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
|
|
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
|
|
|
|
# Load API token from file or environment
|
|
load_api_token() {
|
|
# Environment variable takes precedence
|
|
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
# Try loading from config file
|
|
if [[ -f "$TOKEN_FILE" ]]; then
|
|
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
|
|
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
|
|
echo "Loaded API token from $TOKEN_FILE"
|
|
export FASTMAIL_API_TOKEN
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# Load recipient emails from config file (one per line)
|
|
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
|
|
load_recipients_json() {
|
|
local recipients=""
|
|
if [[ -f "$RECIPIENT_FILE" ]]; then
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
# Skip empty lines and comments
|
|
line=$(echo "$line" | tr -d '[:space:]')
|
|
[[ -z "$line" || "$line" == \#* ]] && continue
|
|
|
|
if [[ -n "$recipients" ]]; then
|
|
recipients="$recipients, "
|
|
fi
|
|
recipients="$recipients{\"email\": \"$line\"}"
|
|
done < "$RECIPIENT_FILE"
|
|
fi
|
|
echo "$recipients"
|
|
}
|
|
|
|
# Get human-readable list of recipients
|
|
load_recipients_display() {
|
|
if [[ -f "$RECIPIENT_FILE" ]]; then
|
|
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
|
|
fi
|
|
}
|
|
|
|
# Parse arguments
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--dry-run)
|
|
DRY_RUN=true
|
|
shift
|
|
;;
|
|
*)
|
|
echo "Unknown option: $1"
|
|
echo "Usage: $0 [--dry-run]"
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
cd "$REPO_ROOT"
|
|
|
|
# Get the cutoff date - either from tag or previous Friday 4pm
|
|
get_cutoff_date() {
|
|
# Try to get the date from the tag
|
|
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
|
|
# Get the commit date of the tagged commit
|
|
git log -1 --format="%aI" "$TAG_NAME"
|
|
else
|
|
# Calculate previous Friday at 4pm
|
|
# Get current day of week (1=Monday, 7=Sunday)
|
|
local dow=$(date +%u)
|
|
local days_since_friday
|
|
|
|
if [[ $dow -ge 5 ]]; then
|
|
# Friday (5), Saturday (6), or Sunday (7)
|
|
days_since_friday=$((dow - 5))
|
|
else
|
|
# Monday (1) through Thursday (4)
|
|
days_since_friday=$((dow + 2))
|
|
fi
|
|
|
|
# Get previous Friday at 4pm in ISO format
|
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
|
|
else
|
|
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Fetch merged PRs since the cutoff date
|
|
fetch_merged_prs() {
|
|
local since_date="$1"
|
|
local output_file="$2"
|
|
|
|
echo "Fetching PRs merged since: $since_date"
|
|
|
|
# Use gh to search for merged PRs
|
|
gh pr list \
|
|
--state merged \
|
|
--base main \
|
|
--json number,title,body,mergedAt,author \
|
|
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
|
|
> "$output_file.json"
|
|
|
|
# Format the output nicely
|
|
echo "# Merged PRs since $since_date" > "$output_file"
|
|
echo "" >> "$output_file"
|
|
|
|
# Process each PR
|
|
jq -r '
|
|
"## PR #\(.number): \(.title)\n" +
|
|
"Author: \(.author.login)\n" +
|
|
"Merged: \(.mergedAt)\n\n" +
|
|
"### Description\n" +
|
|
(.body // "(No description)") +
|
|
"\n\n---\n"
|
|
' "$output_file.json" >> "$output_file"
|
|
|
|
# Count PRs
|
|
local pr_count=$(jq -s 'length' "$output_file.json")
|
|
echo "Found $pr_count merged PRs"
|
|
|
|
rm -f "$output_file.json"
|
|
|
|
if [[ $pr_count -eq 0 ]]; then
|
|
echo "No PRs found since $since_date"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Generate synopsis using Claude
|
|
generate_synopsis() {
|
|
local input_file="$1"
|
|
local output_file="$2"
|
|
|
|
echo "Generating synopsis with Claude..."
|
|
|
|
# Create a prompt file to avoid shell escaping issues
|
|
local prompt_file="/tmp/eagle0_prompt_$$.txt"
|
|
# Get repo URL for PR links
|
|
local repo_url=$(gh repo view --json url -q '.url')
|
|
|
|
cat > "$prompt_file" <<PROMPT_HEADER
|
|
You are summarizing changes for a weekly engineering update email.
|
|
|
|
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
|
|
|
|
Structure:
|
|
1. <h1> title (e.g., "Eagle0 Weekly Update")
|
|
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
|
|
3. Synopsis sections (<h2> headings with bullet point summaries)
|
|
4. <hr> divider
|
|
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
|
|
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
|
|
|
|
Guidelines for the SYNOPSIS sections:
|
|
- Group related changes together under clear headings (use <h2> tags)
|
|
- Use bullet points (<ul><li>) for individual changes
|
|
- Highlight any significant new features, breaking changes, or important fixes
|
|
- Keep the tone professional but accessible
|
|
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
|
|
|
|
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
|
|
|
|
Here are the merged PRs:
|
|
|
|
PROMPT_HEADER
|
|
|
|
cat "$input_file" >> "$prompt_file"
|
|
echo "" >> "$prompt_file"
|
|
echo "Generate the synopsis now:" >> "$prompt_file"
|
|
|
|
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
|
|
local raw_output="/tmp/eagle0_raw_$$.html"
|
|
cat "$prompt_file" | claude --print > "$raw_output"
|
|
|
|
# Wrap in HTML document with UTF-8 charset
|
|
cat > "$output_file" <<'HTML_HEAD'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
</head>
|
|
<body>
|
|
HTML_HEAD
|
|
cat "$raw_output" >> "$output_file"
|
|
echo "</body></html>" >> "$output_file"
|
|
|
|
rm -f "$prompt_file" "$raw_output"
|
|
echo "Synopsis generated at: $output_file"
|
|
}
|
|
|
|
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
|
|
get_fastmail_session() {
|
|
echo "Fetching Fastmail session info..." >&2
|
|
|
|
# Get session
|
|
local session=$(curl -s \
|
|
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
|
"https://api.fastmail.com/jmap/session")
|
|
|
|
# Extract account ID (first account)
|
|
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
|
|
|
|
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
|
|
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
|
|
return 1
|
|
fi
|
|
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
|
|
|
|
# Get identity ID
|
|
local identity_response=$(curl -s \
|
|
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-X POST \
|
|
-d "{
|
|
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
|
|
\"methodCalls\": [
|
|
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
|
|
]
|
|
}" \
|
|
"$FASTMAIL_API")
|
|
|
|
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
|
|
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
|
|
|
|
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
|
|
echo "Error: Could not get Fastmail identity ID." >&2
|
|
return 1
|
|
fi
|
|
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
|
|
|
|
# Get drafts mailbox ID
|
|
local mailbox_response=$(curl -s \
|
|
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-X POST \
|
|
-d "{
|
|
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
|
|
\"methodCalls\": [
|
|
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
|
|
]
|
|
}" \
|
|
"$FASTMAIL_API")
|
|
|
|
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
|
|
|
|
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
|
|
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
|
|
return 1
|
|
fi
|
|
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
|
|
|
|
return 0
|
|
}
|
|
|
|
# Send email via Fastmail JMAP API
|
|
send_email_fastmail() {
|
|
local synopsis_file="$1"
|
|
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
|
|
|
|
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
|
|
local html_body=$(cat "$synopsis_file" | jq -Rs .)
|
|
|
|
echo "Sending email via Fastmail JMAP API..."
|
|
|
|
# Create the email and send it in one request
|
|
local response=$(curl -s \
|
|
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-X POST \
|
|
-d "{
|
|
\"using\": [
|
|
\"urn:ietf:params:jmap:core\",
|
|
\"urn:ietf:params:jmap:mail\",
|
|
\"urn:ietf:params:jmap:submission\"
|
|
],
|
|
\"methodCalls\": [
|
|
[\"Email/set\", {
|
|
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
|
|
\"create\": {
|
|
\"draft\": {
|
|
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
|
|
\"to\": [$recipients_json],
|
|
\"subject\": \"$subject\",
|
|
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
|
|
\"keywords\": {\"\$draft\": true},
|
|
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
|
|
\"bodyValues\": {
|
|
\"body\": {
|
|
\"charset\": \"utf-8\",
|
|
\"value\": $html_body
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, \"0\"],
|
|
[\"EmailSubmission/set\", {
|
|
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
|
|
\"onSuccessDestroyEmail\": [\"#sendIt\"],
|
|
\"create\": {
|
|
\"sendIt\": {
|
|
\"emailId\": \"#draft\",
|
|
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
|
|
}
|
|
}
|
|
}, \"1\"]
|
|
]
|
|
}" \
|
|
"$FASTMAIL_API")
|
|
|
|
# Check for errors
|
|
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
|
|
if [[ -n "$error" ]]; then
|
|
echo "Error creating email: $error" >&2
|
|
echo "Full response: $response" >&2
|
|
return 1
|
|
fi
|
|
|
|
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
|
|
if [[ -n "$send_error" ]]; then
|
|
echo "Error sending email: $send_error" >&2
|
|
echo "Full response: $response" >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "Email sent successfully"
|
|
}
|
|
|
|
# Update the tag to mark this run
|
|
update_tag() {
|
|
echo "Updating $TAG_NAME tag..."
|
|
|
|
# Delete existing tag if present
|
|
git tag -d "$TAG_NAME" 2>/dev/null || true
|
|
git push origin --delete "$TAG_NAME" 2>/dev/null || true
|
|
|
|
# Create new tag at HEAD
|
|
git tag "$TAG_NAME"
|
|
git push origin "$TAG_NAME"
|
|
|
|
echo "Tag updated to current HEAD"
|
|
}
|
|
|
|
# Main
|
|
main() {
|
|
echo "=== Eagle0 Weekly Changelog Generator ==="
|
|
echo ""
|
|
|
|
# Load API token (only required for actual send)
|
|
if [[ "$DRY_RUN" != "true" ]]; then
|
|
if ! load_api_token; then
|
|
echo "Error: No Fastmail API token found."
|
|
echo ""
|
|
echo "To create a token:"
|
|
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
|
|
echo "2. Create a new token with 'Email submission' scope"
|
|
echo "3. Save it using one of these methods:"
|
|
echo ""
|
|
echo " Option A (recommended): Store in config file"
|
|
echo " mkdir -p ~/.config/eagle0"
|
|
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
|
|
echo " chmod 600 ~/.config/eagle0/fastmail_token"
|
|
echo ""
|
|
echo " Option B: Set environment variable"
|
|
echo " export FASTMAIL_API_TOKEN='your-token'"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Get cutoff date
|
|
local cutoff_date=$(get_cutoff_date)
|
|
echo "Cutoff date: $cutoff_date"
|
|
|
|
# Create temp files
|
|
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
|
|
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
|
|
|
|
# Fetch PRs
|
|
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
|
|
echo "No changes to report. Exiting."
|
|
exit 0
|
|
fi
|
|
|
|
echo ""
|
|
echo "PR details saved to: $pr_file"
|
|
|
|
# Generate synopsis
|
|
generate_synopsis "$pr_file" "$synopsis_file"
|
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
echo ""
|
|
echo "=== DRY RUN - Synopsis content ==="
|
|
cat "$synopsis_file"
|
|
echo ""
|
|
echo "=== DRY RUN - Skipping email send and tag update ==="
|
|
else
|
|
# Get Fastmail session info
|
|
if ! get_fastmail_session; then
|
|
echo "Failed to get Fastmail session info. Exiting."
|
|
exit 1
|
|
fi
|
|
|
|
# Determine recipients (from config file, or default to sender)
|
|
local recipients_json=$(load_recipients_json)
|
|
if [[ -z "$recipients_json" ]]; then
|
|
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
|
|
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
|
|
else
|
|
local recipients_display=$(load_recipients_display)
|
|
echo "Sending to: $recipients_display"
|
|
fi
|
|
|
|
# Send email
|
|
send_email_fastmail "$synopsis_file" "$recipients_json"
|
|
|
|
# Update tag for next run
|
|
update_tag
|
|
fi
|
|
|
|
echo ""
|
|
echo "Done!"
|
|
echo "PR details: $pr_file"
|
|
echo "Synopsis: $synopsis_file"
|
|
}
|
|
|
|
main
|