#!/bin/bash set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NOCOLOR='\033[0m' log() { echo -e "${CYAN}[update-changelog]${NOCOLOR} $1"; } error() { echo -e "${RED}[ERROR]${NOCOLOR} $1"; } success() { echo -e "${GREEN}[SUCCESS]${NOCOLOR} $1"; } warning() { echo -e "${YELLOW}[WARNING]${NOCOLOR} $1"; } # File paths CHANGELOG_FILE="CHANGELOG.md" MAKEFILE="Makefile" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" # Load environment variables from .env file if it exists if [ -f "$REPO_ROOT/.env" ]; then # Export variables from .env file (more portable than source <()) set -a while IFS='=' read -r key value; do # Skip comments and empty lines [[ "$key" =~ ^#.*$ ]] && continue [[ -z "$key" ]] && continue # Remove quotes if present value=$(echo "$value" | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//") export "$key=$value" done < "$REPO_ROOT/.env" set +a fi # OpenRouter API key # Priority: 1. Environment variable, 2. .env file, 3. Exit with error if [ -z "$OPENROUTER_API_KEY" ]; then error "OPENROUTER_API_KEY not found!" echo "" echo "Please set the API key in one of these ways:" echo " 1. Create a .env file in the repo root with:" echo " OPENROUTER_API_KEY=your-api-key-here" echo "" echo " 2. Set it as an environment variable:" echo " export OPENROUTER_API_KEY=your-api-key-here" echo "" echo " 3. Copy .env.example to .env and fill in your key:" echo " cp .env.example .env" echo "" echo "Get your API key from: https://openrouter.ai/keys" exit 1 fi # Check dependencies if ! command -v jq > /dev/null 2>&1; then error "jq is required but not installed. Install it with: brew install jq (macOS) or apt-get install jq (Linux)" exit 1 fi if ! command -v curl > /dev/null 2>&1; then error "curl is required but not installed" exit 1 fi # Check for skip flag # To skip changelog generation, set SKIP_CHANGELOG=1 before committing: # SKIP_CHANGELOG=1 git commit -m "your message" # SKIP_CHANGELOG=1 git commit if [ "$SKIP_CHANGELOG" = "1" ] || [ "$SKIP_CHANGELOG" = "true" ]; then log "Skipping changelog update (SKIP_CHANGELOG is set)" exit 0 fi # Check if we're in a git repo if ! git rev-parse --git-dir > /dev/null 2>&1; then error "Not in a git repository" exit 1 fi # Get current branch CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) REMOTE_BRANCH="origin/$CURRENT_BRANCH" # Check if remote branch exists if ! git rev-parse --verify "$REMOTE_BRANCH" > /dev/null 2>&1; then warning "Remote branch $REMOTE_BRANCH does not exist. Using main/master as baseline." if git rev-parse --verify "origin/main" > /dev/null 2>&1; then REMOTE_BRANCH="origin/main" elif git rev-parse --verify "origin/master" > /dev/null 2>&1; then REMOTE_BRANCH="origin/master" else warning "No remote branch found. Using HEAD as baseline." REMOTE_BRANCH="HEAD" fi fi # Gather all git diffs log "Collecting git diffs..." # Check if running from pre-commit context if [ "$CHANGELOG_CONTEXT" = "pre-commit" ]; then log "Running in pre-commit context - analyzing staged changes only" # Unstaged changes (usually none in pre-commit, but check anyway) UNSTAGED_DIFF=$(git diff 2>/dev/null || echo "") UNSTAGED_COUNT=$(echo "$UNSTAGED_DIFF" | grep -c "^diff\|^index" 2>/dev/null || echo "0") [ -z "$UNSTAGED_COUNT" ] && UNSTAGED_COUNT="0" # Staged changes (these are what we're committing) STAGED_DIFF=$(git diff --cached 2>/dev/null || echo "") STAGED_COUNT=$(echo "$STAGED_DIFF" | grep -c "^diff\|^index" 2>/dev/null || echo "0") [ -z "$STAGED_COUNT" ] && STAGED_COUNT="0" # No unpushed commits analysis in pre-commit context UNPUSHED_DIFF="" UNPUSHED_COMMITS="0" log "Found: $UNSTAGED_COUNT unstaged file(s), $STAGED_COUNT staged file(s)" else # Pre-push context - analyze everything # Unstaged changes UNSTAGED_DIFF=$(git diff 2>/dev/null || echo "") UNSTAGED_COUNT=$(echo "$UNSTAGED_DIFF" | grep -c "^diff\|^index" 2>/dev/null || echo "0") [ -z "$UNSTAGED_COUNT" ] && UNSTAGED_COUNT="0" # Staged changes STAGED_DIFF=$(git diff --cached 2>/dev/null || echo "") STAGED_COUNT=$(echo "$STAGED_DIFF" | grep -c "^diff\|^index" 2>/dev/null || echo "0") [ -z "$STAGED_COUNT" ] && STAGED_COUNT="0" # Unpushed commits UNPUSHED_DIFF=$(git diff "$REMOTE_BRANCH"..HEAD 2>/dev/null || echo "") UNPUSHED_COMMITS=$(git rev-list --count "$REMOTE_BRANCH"..HEAD 2>/dev/null || echo "0") [ -z "$UNPUSHED_COMMITS" ] && UNPUSHED_COMMITS="0" # Check if the only unpushed commit is a changelog update commit # If so, exclude it from the diff to avoid infinite loops if [ "$UNPUSHED_COMMITS" -gt 0 ]; then LATEST_COMMIT_MSG=$(git log -1 --pretty=%B HEAD 2>/dev/null || echo "") if echo "$LATEST_COMMIT_MSG" | grep -q "chore: update changelog and version"; then # If the latest commit is a changelog commit, check if there are other commits if [ "$UNPUSHED_COMMITS" -eq 1 ]; then log "Latest commit is a changelog update. No other changes detected. Skipping changelog update." # Clean up any old preview files rm -f "$REPO_ROOT/.changelog_preview.tmp" "$REPO_ROOT/.changelog_version.tmp" exit 0 else # Multiple commits, exclude the latest changelog commit from diff log "Multiple unpushed commits detected. Excluding latest changelog commit from analysis." # Get all commits except the latest one UNPUSHED_DIFF=$(git diff "$REMOTE_BRANCH"..HEAD~1 2>/dev/null || echo "") UNPUSHED_COMMITS=$(git rev-list --count "$REMOTE_BRANCH"..HEAD~1 2>/dev/null || echo "0") [ -z "$UNPUSHED_COMMITS" ] && UNPUSHED_COMMITS="0" fi fi fi log "Found: $UNSTAGED_COUNT unstaged file(s), $STAGED_COUNT staged file(s), $UNPUSHED_COMMITS unpushed commit(s)" fi # Combine all diffs if [ "$CHANGELOG_CONTEXT" = "pre-commit" ]; then ALL_DIFFS="${UNSTAGED_DIFF} --- STAGED CHANGES: --- ${STAGED_DIFF}" else ALL_DIFFS="${UNSTAGED_DIFF} --- STAGED CHANGES: --- ${STAGED_DIFF} --- UNPUSHED COMMITS: --- ${UNPUSHED_DIFF}" fi # Check if there are any changes if [ "$CHANGELOG_CONTEXT" = "pre-commit" ]; then # In pre-commit, only check staged changes if [ -z "$(echo "$STAGED_DIFF" | tr -d '[:space:]')" ]; then log "No staged changes detected. Skipping changelog update." rm -f "$REPO_ROOT/.changelog_preview.tmp" "$REPO_ROOT/.changelog_version.tmp" exit 0 fi else # In pre-push, check all changes if [ -z "$(echo "$UNSTAGED_DIFF$STAGED_DIFF$UNPUSHED_DIFF" | tr -d '[:space:]')" ]; then log "No changes detected (unstaged, staged, or unpushed). Skipping changelog update." rm -f "$REPO_ROOT/.changelog_preview.tmp" "$REPO_ROOT/.changelog_version.tmp" exit 0 fi fi # Get current version from Makefile CURRENT_VERSION=$(grep "^VERSION :=" "$MAKEFILE" | sed 's/.*:= *//' | tr -d ' ') if [ -z "$CURRENT_VERSION" ]; then error "Could not find VERSION in Makefile" exit 1 fi log "Current version: $CURRENT_VERSION" # Get today's date programmatically (YYYY-MM-DD format) TODAY_DATE=$(date +%Y-%m-%d) log "Using date: $TODAY_DATE" # Prepare prompt for OpenRouter PROMPT="You are analyzing git diffs to create a changelog entry. Based on the following git diffs, create a simple, easy-to-understand changelog entry. Current version: $CURRENT_VERSION Git diffs: \`\`\` $ALL_DIFFS \`\`\` Please respond with ONLY a valid JSON object in this exact format: { \"version\": \"x.y.z\", \"bump_type\": \"minor\" or \"patch\", \"added\": [\"item1\", \"item2\"], \"changed\": [\"item1\", \"item2\"], \"fixed\": [\"item1\", \"item2\"] } Rules: - Bump version based on changes: use \"minor\" for new features, \"patch\" for bug fixes and small changes - Never bump major version (keep major version the same) - Keep descriptions simple and easy to understand (1-2 sentences max per item) - Only include items that actually changed - If a category is empty, use an empty array [] - Do NOT include a date field - the date will be set programmatically" # Call OpenRouter API log "Calling OpenRouter API to generate changelog..." # Prepare the JSON payload properly PROMPT_ESCAPED=$(echo "$PROMPT" | jq -Rs .) REQUEST_BODY=$(cat < /dev/null 2>&1; then error "OpenRouter API error:" ERROR_MESSAGE=$(echo "$RESPONSE_BODY" | jq -r '.error.message // .error' 2>/dev/null || echo "$RESPONSE_BODY") echo "$ERROR_MESSAGE" echo "" error "Full API response:" echo "$RESPONSE_BODY" | jq '.' 2>/dev/null || echo "$RESPONSE_BODY" echo "" error "The API key may be invalid or expired. Please verify your OpenRouter API key at https://openrouter.ai/keys" echo "" error "To test your API key manually, run:" echo " curl https://openrouter.ai/api/v1/chat/completions \\" echo " -H \"Content-Type: application/json\" \\" echo " -H \"Authorization: Bearer YOUR_API_KEY\" \\" echo " -d '{\"model\": \"google/gemini-2.5-flash-preview-09-2025\", \"messages\": [{\"role\": \"user\", \"content\": \"test\"}]}'" exit 1 fi # Extract JSON from response JSON_CONTENT=$(echo "$RESPONSE_BODY" | jq -r '.choices[0].message.content' 2>/dev/null) # Check if content was extracted if [ -z "$JSON_CONTENT" ] || [ "$JSON_CONTENT" = "null" ]; then error "Failed to extract content from API response" echo "Response: $RESPONSE_BODY" exit 1 fi # Try to extract JSON if it's wrapped in markdown code blocks if echo "$JSON_CONTENT" | grep -q '```json'; then JSON_CONTENT=$(echo "$JSON_CONTENT" | sed -n '/```json/,/```/p' | sed '1d;$d') elif echo "$JSON_CONTENT" | grep -q '```'; then JSON_CONTENT=$(echo "$JSON_CONTENT" | sed -n '/```/,/```/p' | sed '1d;$d') fi # Validate JSON if ! echo "$JSON_CONTENT" | jq . > /dev/null 2>&1; then error "Invalid JSON response from API:" echo "$JSON_CONTENT" exit 1 fi # Parse JSON NEW_VERSION=$(echo "$JSON_CONTENT" | jq -r '.version') BUMP_TYPE=$(echo "$JSON_CONTENT" | jq -r '.bump_type') ADDED=$(echo "$JSON_CONTENT" | jq -r '.added[]?' | sed 's/^/- /') CHANGED=$(echo "$JSON_CONTENT" | jq -r '.changed[]?' | sed 's/^/- /') FIXED=$(echo "$JSON_CONTENT" | jq -r '.fixed[]?' | sed 's/^/- /') log "Generated version: $NEW_VERSION ($BUMP_TYPE bump)" log "Date: $TODAY_DATE" # Validate version format if ! echo "$NEW_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then error "Invalid version format: $NEW_VERSION" exit 1 fi # Validate bump type if [ "$BUMP_TYPE" != "minor" ] && [ "$BUMP_TYPE" != "patch" ]; then error "Invalid bump type: $BUMP_TYPE (must be 'minor' or 'patch')" exit 1 fi # Update Makefile log "Updating Makefile..." if [[ "$OSTYPE" == "darwin"* ]]; then # macOS sed requires backup extension sed -i '' "s/^VERSION := .*/VERSION := $NEW_VERSION/" "$MAKEFILE" else # Linux sed sed -i "s/^VERSION := .*/VERSION := $NEW_VERSION/" "$MAKEFILE" fi success "Makefile updated to version $NEW_VERSION" # Update CHANGELOG.md log "Updating CHANGELOG.md..." # Create changelog entry CHANGELOG_ENTRY="## [$NEW_VERSION] - $TODAY_DATE ### Added " if [ -n "$ADDED" ]; then CHANGELOG_ENTRY+="$ADDED"$'\n' else CHANGELOG_ENTRY+="\n" fi CHANGELOG_ENTRY+=" ### Changed " if [ -n "$CHANGED" ]; then CHANGELOG_ENTRY+="$CHANGED"$'\n' else CHANGELOG_ENTRY+="\n" fi CHANGELOG_ENTRY+=" ### Deprecated ### Removed ### Fixed " if [ -n "$FIXED" ]; then CHANGELOG_ENTRY+="$FIXED"$'\n' else CHANGELOG_ENTRY+="\n" fi CHANGELOG_ENTRY+=" " # Save preview to temp file for pre-push hook PREVIEW_FILE="$REPO_ROOT/.changelog_preview.tmp" echo "$CHANGELOG_ENTRY" > "$PREVIEW_FILE" echo "$NEW_VERSION" > "$REPO_ROOT/.changelog_version.tmp" # Insert after [Unreleased] section using awk (more portable) # Find the line number after [Unreleased] section (after the "### Fixed" line) INSERT_LINE=$(awk '/^## \[Unreleased\]/{found=1} found && /^### Fixed$/{print NR+1; exit}' "$CHANGELOG_FILE") if [ -z "$INSERT_LINE" ]; then # Fallback: insert after line 16 (after [Unreleased] section) INSERT_LINE=16 fi # Use a temp file approach to insert multiline content TMP_FILE=$(mktemp) { head -n $((INSERT_LINE - 1)) "$CHANGELOG_FILE" printf '%s' "$CHANGELOG_ENTRY" tail -n +$INSERT_LINE "$CHANGELOG_FILE" } > "$TMP_FILE" mv "$TMP_FILE" "$CHANGELOG_FILE" success "CHANGELOG.md updated with version $NEW_VERSION" log "Changelog update complete!" log "New version: $NEW_VERSION" log "Bump type: $BUMP_TYPE"