#!/bin/bash # script/generate-notes: Generate release notes from conventional commits # # Parses conventional commits between git tags and generates formatted release # notes. Supports multiple backends: GitHub Copilot CLI (AI), git-cliff # (template), or manual grep/awk parsing. Can auto-update existing GitHub releases. # # Usage: # ./scripts/release/generate-notes [FROM_TAG] [TO_TAG] # # Examples: # ./scripts/release/generate-notes # Latest tag to HEAD # ./scripts/release/generate-notes v1.0.0 v1.1.0 # ./scripts/release/generate-notes v1.0.0 HEAD set -e cd "$(dirname "$0")/../.." # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # No Color # Detect if running in CI (suppress colored output to stdout) if [[ -n $CI || -n $GITHUB_ACTIONS ]]; then # In CI, send info messages to stderr to keep release notes clean log_info() { echo -e "$@" >&2 } else # Local execution, show colored output log_info() { echo -e "$@" } fi # Configuration BACKEND="${RELEASE_NOTES_BACKEND:-auto}" USE_AI="${USE_AI:-true}" GITHUB_REPO="${GITHUB_REPOSITORY:-jpawlowski/hass.tibber_prices}" RELEASE_NOTES_COMPACT_DIFF="${RELEASE_NOTES_COMPACT_DIFF:-true}" RELEASE_NOTES_DIFF_MAX_BYTES="${RELEASE_NOTES_DIFF_MAX_BYTES:-8000}" RELEASE_NOTES_CLIFF_FILTER_PATHS="${RELEASE_NOTES_CLIFF_FILTER_PATHS:-true}" RELEASE_NOTES_CLIFF_SINGLE_RELEASE="${RELEASE_NOTES_CLIFF_SINGLE_RELEASE:-true}" RELEASE_NOTES_TRAILER_SKIP_FILTER="${RELEASE_NOTES_TRAILER_SKIP_FILTER:-true}" # Commits explicitly marked to be excluded from release notes. RELEASE_NOTES_SKIP_COMMITS=() # User-facing paths for AI diff context USER_FACING_PATHS=( "custom_components/tibber_prices/sensor/" "custom_components/tibber_prices/binary_sensor/" "custom_components/tibber_prices/config_flow_handlers/" "custom_components/tibber_prices/services/" "custom_components/tibber_prices/translations/" "custom_components/tibber_prices/number/" "custom_components/tibber_prices/switch/" ) # Parse common truthy values from environment variables is_truthy() { case "${1,,}" in 1 | true | yes | on) return 0 ;; *) return 1 ;; esac } # Determine if commit body explicitly marks commit as non-user-facing for release notes. # Supported trailers (case-insensitive): # - Release-Notes: skip # - User-Impact: none # - Released-Bug: no commit_marked_for_release_notes_skip() { local commit_hash="$1" local body body=$(git show -s --format=%B "$commit_hash" 2>/dev/null || true) if printf "%s\n" "$body" | grep -Eiq '^[[:space:]]*release[ -]notes:[[:space:]]*skip([[:space:]]|$)'; then return 0 fi if printf "%s\n" "$body" | grep -Eiq '^[[:space:]]*user-impact:[[:space:]]*none([[:space:]]|$)'; then return 0 fi if printf "%s\n" "$body" | grep -Eiq '^[[:space:]]*released-bug:[[:space:]]*no([[:space:]]|$)'; then return 0 fi return 1 } collect_release_notes_skip_commits() { RELEASE_NOTES_SKIP_COMMITS=() if ! is_truthy "$RELEASE_NOTES_TRAILER_SKIP_FILTER"; then return fi while IFS= read -r commit_hash; do if commit_marked_for_release_notes_skip "$commit_hash"; then RELEASE_NOTES_SKIP_COMMITS+=("$commit_hash") fi done < <(git rev-list --reverse "${FROM_TAG}..${TO_TAG}") } is_release_notes_skipped_commit() { local target_hash="$1" local skip_hash for skip_hash in "${RELEASE_NOTES_SKIP_COMMITS[@]}"; do if [[ "$skip_hash" == "$target_hash" ]]; then return 0 fi done return 1 } build_copilot_commits_context() { local commit_hash="" local output="" local subject="" local body="" local short_hash="" local stat="" while IFS= read -r commit_hash; do if is_release_notes_skipped_commit "$commit_hash"; then continue fi short_hash=$(git rev-parse --short "$commit_hash") subject=$(git show -s --format=%s "$commit_hash") body=$(git show -s --format=%b "$commit_hash") stat=$(git show --stat --compact-summary --format='' "$commit_hash" 2>/dev/null || true) output+="${short_hash} | ${subject}"$'\n' if [[ -n "$body" ]]; then output+="${body}"$'\n' fi if [[ -n "$stat" ]]; then output+="${stat}"$'\n' fi output+=$'\n' done < <(git rev-list --reverse "${FROM_TAG}..${TO_TAG}") printf "%s" "$output" } # Build AI diff context with compact formatting by default to save tokens. # Set RELEASE_NOTES_COMPACT_DIFF=false to use the legacy full patch context format. build_diff_context() { local diff_context="" if is_truthy "$RELEASE_NOTES_COMPACT_DIFF"; then if ! diff_context=$( set -o pipefail git diff --unified=0 --no-color --minimal --patience --diff-filter=AM \ "${FROM_TAG}..${TO_TAG}" -- "${USER_FACING_PATHS[@]}" 2>/dev/null | awk ' /^diff --git / { print; next } /^--- / || /^\+\+\+ / || /^@@ / { print; next } /^[+-]/ { sub(/[ \t]+$/, "", $0) print next } ' | head -c "$RELEASE_NOTES_DIFF_MAX_BYTES" ); then log_info "${YELLOW}Warning: compact diff generation failed, falling back to legacy diff context${NC}" diff_context=$(git diff --unified=2 --diff-filter=AM \ "${FROM_TAG}..${TO_TAG}" -- "${USER_FACING_PATHS[@]}" 2>/dev/null | head -c "$RELEASE_NOTES_DIFF_MAX_BYTES" || true) fi else diff_context=$(git diff --unified=2 --diff-filter=AM \ "${FROM_TAG}..${TO_TAG}" -- "${USER_FACING_PATHS[@]}" 2>/dev/null | head -c "$RELEASE_NOTES_DIFF_MAX_BYTES" || true) fi echo "$diff_context" } # Parse arguments FROM_TAG="${1:-$(git describe --tags --abbrev=0 2>/dev/null || echo "")}" TO_TAG="${2:-HEAD}" if [[ -z $FROM_TAG ]]; then echo -e "${RED}Error: No tags found in repository${NC}" >&2 echo "Usage: $0 [FROM_TAG] [TO_TAG]" >&2 exit 1 fi collect_release_notes_skip_commits if [[ ${#RELEASE_NOTES_SKIP_COMMITS[@]} -gt 0 ]]; then log_info "${YELLOW}Skipping ${#RELEASE_NOTES_SKIP_COMMITS[@]} commit(s) marked as non-user-facing via trailers${NC}" fi log_info "${BLUE}==> Generating release notes: ${FROM_TAG}..${TO_TAG}${NC}" log_info "" # Detect available backends detect_backend() { if [[ $BACKEND != auto ]]; then echo "$BACKEND" return fi # Skip AI in CI/CD or if disabled if [[ $USE_AI == false || -n $CI || -n $GITHUB_ACTIONS ]]; then if command -v git-cliff >/dev/null 2>&1; then echo "git-cliff" return fi echo "manual" return fi # Check for GitHub Copilot CLI (AI-powered, best quality) if command -v copilot >/dev/null 2>&1; then echo "copilot" return fi # Check for git-cliff (fast and reliable) if command -v git-cliff >/dev/null 2>&1; then echo "git-cliff" return fi # Fallback to manual parsing echo "manual" } BACKEND=$(detect_backend) log_info "${GREEN}Using backend: ${BACKEND}${NC}" log_info "" # Backend: GitHub Copilot CLI (AI-powered) generate_with_copilot() { log_info "${BLUE}==> Generating with GitHub Copilot CLI (AI-powered)${NC}" log_info "${YELLOW}Note: This will use one premium request from your monthly quota${NC}" log_info "" # Get filtered commit log for the range with file statistics (oldest -> newest). # Commits explicitly marked as non-user-facing are excluded. COMMITS=$(build_copilot_commits_context) # Final repository-level changed file list (net state from FROM_TAG to TO_TAG). # This is useful when commit history contains back-and-forth changes. FINAL_FILE_CHANGES=$(git diff --name-status "${FROM_TAG}..${TO_TAG}" 2>/dev/null || true) # Revert commits are useful chronology hints (earlier changes may be superseded). REVERT_COMMITS=$(git log --reverse --pretty=format:"%h | %s" "${FROM_TAG}..${TO_TAG}" | grep -Ei '\|[[:space:]]*revert\b' || true) if [[ -z $FINAL_FILE_CHANGES ]]; then FINAL_FILE_CHANGES="(none)" fi if [[ -z $REVERT_COMMITS ]]; then REVERT_COMMITS="(none)" fi if [[ ${#RELEASE_NOTES_SKIP_COMMITS[@]} -gt 0 ]]; then SKIPPED_COMMITS_CONTEXT=$(printf "%s\n" "${RELEASE_NOTES_SKIP_COMMITS[@]}" | xargs -I{} git show -s --format='%h | %s' {} 2>/dev/null || true) else SKIPPED_COMMITS_CONTEXT="(none)" fi # Get code diff for user-facing files to give the AI real context about what changed. # Compact mode is enabled by default to reduce prompt tokens without reducing semantic signal. # Toggle: RELEASE_NOTES_COMPACT_DIFF=false DIFF_CONTEXT=$(build_diff_context) if [[ -z $COMMITS ]]; then log_info "${YELLOW}No commits found between ${FROM_TAG} and ${TO_TAG}${NC}" exit 0 fi # Create prompt for Copilot PROMPT="You are writing a GitHub release announcement for a Home Assistant integration called **Tibber Prices**. ## YOUR AUDIENCE Home Assistant users — people who automate their home and build dashboards. They are NOT developers. They do not understand technical terms like: coordinator, entity, async, refactor, TypedDict, module, class, iterator, cache, pool, BaseCalculator, interval_pool, or any Python/software engineering terminology. ## WHAT THIS INTEGRATION DOES It fetches electricity prices from Tibber and provides sensors to Home Assistant, so users can: - See the current electricity price on their dashboard - Automate appliances to run during cheapest hours (dishwasher, washing machine, heat pump, EV charging) - Get alerts when prices are unusually high or low - Find the best time window in the day for flexible loads ## YOUR GOAL Translate technical changes into real user benefits. Write as a product manager telling users what they can now DO, what was BROKEN and is now FIXED, or why the integration is now more RELIABLE. ## SOURCE OF TRUTH (IMPORTANT) - Primary source: final net repository state from ${FROM_TAG} to ${TO_TAG} - Use commit text as context, not as absolute truth - If commit text conflicts with final file list or final diff, trust final state - Revert commits indicate earlier changes may be superseded - Commits listed as "explicitly skipped" are non-user-facing by author intent and must not be mentioned ## WRITING RULES — FOLLOW STRICTLY **Rule 1 — No jargon, ever.** NEVER use: coordinator, entity, async, class, module, package, cache invalidation, refactor, pool, iterator, TypedDict, BaseCalculator, interval_pool, config entry, data class, state machine. Always rephrase as a user experience. **Rule 2 — Second person, present tense.** Write: \"You can now see...\", \"Your automations will...\", \"The setup wizard now...\" **Rule 3 — Bug fixes: describe the SYMPTOM, not the technical cause.** ❌ \"Fix timezone-aware datetime comparison at resolution boundary\" ✅ \"Fixed: Price data was missing or showed wrong values when switching to/from daylight saving time\" ❌ \"Restore missing while-loop increment preventing indefinite hang during period calculation\" ✅ \"Fixed: The integration could occasionally freeze when calculating the cheapest time windows\" **Rule 4 — New features: lead with what the user can DO.** ❌ \"Add volatility calculator with BaseCalculator integration\" ✅ \"New sensors show how stable today's prices are — useful for deciding whether to run flexible appliances now or wait\" **Rule 5 — Refactoring without user impact: omit completely.** Only mention internal changes if they improve actual reliability or stability that users would notice. If so, group them under \"⚡ More Reliable\" as a brief single line. NEVER write a refactoring bullet per commit. **Rule 6 — Combine related commits into one story.** Do NOT list every commit as a separate bullet. Group related commits into a single paragraph or bullet that tells the complete story. Use multiple commit links for the group. **Rule 7 — Use Impact: sections.** Commit bodies may contain an \"Impact:\" line — this is the user-facing description. Prioritize it over the commit subject line. **Rule 8 — Imagine explaining to your neighbor.** Before writing each bullet, ask: \"Would my neighbor who uses Home Assistant to automate their home understand this?\" If not, rephrase. ## OUTPUT FORMAT Start with: # [Short, exciting title — max 6 words, NO version number] Then sections using ### (H3 headings): - ### 🎉 What's New (New sensors, new config options, new automations possible — use this for feat commits with real user benefit) - ### 🐛 Fixed (Bugs users experienced — describe what was wrong, what is better now) - ### ⚡ More Reliable (Performance improvements OR refactoring that improves stability — only when users would notice) - ### 📦 Updated Dependencies (Dependency bumps — keep brief, one line per group) Skip any section that has no content. After the last section, ALWAYS end with this exact footer, no modifications: --- If this release saved you some money on your electricity bill, a coffee would be much appreciated! ☕ [![Buy Me A Coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=jpawlowski&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff)](https://www.buymeacoffee.com/jpawlowski) ## TITLE SELECTION - Find the most user-impactful change: new sensors > bug fixes affecting data quality > reliability improvements > UI improvements > translations - Ignore dev/CI/docs commits for the title - Make it sound exciting: \"Smarter Cheap-Period Detection\", \"Reliable DST Price Data\", \"New Price Stability Sensors\" - Example: if DST price data was wrong and now fixed → title = \"Reliable Prices Around Clock Changes\" ## WHAT TO INCLUDE vs SKIP ✅ Include: Changes to what sensors show, bugs fixing wrong data, config UI improvements, automation reliability, dependency updates ❌ Skip: Script/CI/workflow changes, version bumps, pure code reorganization, documentation commits, test changes ## FILE PATH GUIDE (for assessing importance) - sensor/ = Price sensors users see → HIGH - binary_sensor/ = On/off sensors for automations → HIGH - config_flow_handlers/ or config_flow/ = Setup wizard users interact with → HIGH - services/ = Actions users trigger → HIGH - translations/ = Labels users see in the UI → MEDIUM - number/ or switch/ = Controls users interact with → HIGH - coordinator/ = Data fetching/processing (rarely user-visible unless fixing data bugs) → LOW-MEDIUM - scripts/, .github/, docs/ = Developer-only → SKIP --- **Commits to analyze (${FROM_TAG} → ${TO_TAG}):** ${COMMITS} --- **Final changed files (net state, source of truth):** ${FINAL_FILE_CHANGES} --- **Revert commits in range (chronology hints):** ${REVERT_COMMITS} --- **Commits explicitly skipped by trailer (do not mention):** ${SKIPPED_COMMITS_CONTEXT} --- **Code diff for user-facing files (use this to understand WHAT actually changed — but always translate to user language):** ${DIFF_CONTEXT} --- Output ONLY the release notes. Start directly with the # title. End after the Buy Me A Coffee button. No meta-commentary, no explanations." # Save prompt to temp file for copilot TEMP_PROMPT=$(mktemp) echo "$PROMPT" >"$TEMP_PROMPT" # Use Claude Sonnet for better user-focused, plain-language release notes. # Haiku tends to echo technical commit language; Sonnet better translates to user benefits. # Can override with: COPILOT_MODEL=claude-haiku-4.5 ./scripts/release/generate-notes COPILOT_MODEL="${COPILOT_MODEL:-claude-sonnet-4.6}" # Call copilot CLI (it will handle authentication interactively) copilot --model "$COPILOT_MODEL" <"$TEMP_PROMPT" || { echo "" log_info "${YELLOW}Warning: GitHub Copilot CLI failed or was not authenticated${NC}" log_info "${YELLOW}Falling back to git-cliff${NC}" rm -f "$TEMP_PROMPT" if command -v git-cliff >/dev/null 2>&1; then generate_with_gitcliff else generate_with_manual fi return } rm -f "$TEMP_PROMPT" } # Backend: git-cliff (template-based) generate_with_gitcliff() { log_info "${BLUE}==> Generating with git-cliff${NC}" # Analyze commits to generate a title aligned with current user-facing groups COMMITS_LOG=$(git log --pretty=format:"%s" "${FROM_TAG}..${TO_TAG}" 2>/dev/null || echo "") WHATS_NEW_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^feat(\(|:)" || true) FIXED_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^fix(\(|:)" || true) RELIABLE_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^perf(\(|:)" || true) DEPS_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^chore\(deps\):" || true) if [ "$WHATS_NEW_COUNT" -gt 0 ] && [ "$FIXED_COUNT" -gt 0 ]; then TITLE="# What's New & Fixed" elif [ "$WHATS_NEW_COUNT" -gt 0 ] && [ "$RELIABLE_COUNT" -gt 0 ]; then TITLE="# What's New & More Reliable" elif [ "$WHATS_NEW_COUNT" -gt 0 ]; then TITLE="# What's New" elif [ "$FIXED_COUNT" -gt 0 ] && [ "$RELIABLE_COUNT" -gt 0 ]; then TITLE="# Fixed & More Reliable" elif [ "$FIXED_COUNT" -gt 0 ]; then TITLE="# Fixed" elif [ "$RELIABLE_COUNT" -gt 0 ]; then TITLE="# More Reliable" elif [ "$DEPS_COUNT" -gt 0 ]; then TITLE="# Dependency Updates" else TITLE="# Release Updates" fi echo "$TITLE" echo "" # Create temporary cliff.toml if not exists if [ ! -f "cliff.toml" ]; then cat >/tmp/cliff.toml <<'EOF' [changelog] header = "" body = """ {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | striptags | trim | upper_first }} {% for commit in commits %} - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\ {% if commit.breaking %} [**BREAKING**]{% endif %} {% endfor %} {% endfor %} """ trim = true [git] conventional_commits = true filter_unconventional = false split_commits = false commit_parsers = [ { message = "^chore\\(release\\): bump version", skip = true }, { message = "^revert", skip = true }, { message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true }, { message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true }, { message = "^fix\\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\\):", skip = true }, { message = "^feat", group = "🎉 What's New" }, { message = "^fix", group = "🐛 Fixed" }, { message = "^perf", group = "⚡ More Reliable" }, { message = "^chore\\(deps\\):", group = "📦 Updated Dependencies" }, { message = "^docs?", skip = true }, { message = "^refactor", skip = true }, { message = "^style", skip = true }, { message = "^test", skip = true }, { message = "^build", skip = true }, { message = "^chore", skip = true }, { message = ".*", skip = true }, ] filter_commits = true EOF CLIFF_CONFIG="/tmp/cliff.toml" else CLIFF_CONFIG="cliff.toml" fi CLIFF_CMD=(git-cliff --config "$CLIFF_CONFIG") # Restrict changelog to user-facing integration files by default. # Toggle with: RELEASE_NOTES_CLIFF_FILTER_PATHS=false if is_truthy "$RELEASE_NOTES_CLIFF_FILTER_PATHS"; then CLIFF_CMD+=(--include-path "custom_components/tibber_prices/**") fi # Exclude commits explicitly marked as non-user-facing. if [[ ${#RELEASE_NOTES_SKIP_COMMITS[@]} -gt 0 ]]; then for skip_hash in "${RELEASE_NOTES_SKIP_COMMITS[@]}"; do CLIFF_CMD+=(--skip-commit "$skip_hash") done fi CLIFF_CMD+=("${FROM_TAG}..${TO_TAG}") CLIFF_OUTPUT=$("${CLIFF_CMD[@]}") # In unusual ranges, git-cliff can emit multiple release blocks. # Keep only the first block by default to match this script's single-release intent. # Toggle with: RELEASE_NOTES_CLIFF_SINGLE_RELEASE=false if is_truthy "$RELEASE_NOTES_CLIFF_SINGLE_RELEASE"; then BMAC_COUNT=$(printf "%s" "$CLIFF_OUTPUT" | grep -c "buymeacoffee.com/jpawlowski" || true) if [[ $BMAC_COUNT -gt 1 ]]; then log_info "${YELLOW}Notice: Multiple release blocks detected; keeping the first block${NC}" CLIFF_OUTPUT=$(printf "%s" "$CLIFF_OUTPUT" | awk ' /https:\/\/www\.buymeacoffee\.com\/jpawlowski/ { line = $0 marker = "https://www.buymeacoffee.com/jpawlowski)" marker_pos = index(line, marker) if (marker_pos > 0) { line = substr(line, 1, marker_pos + length(marker) - 1) } print line exit } { print } ') fi fi # Normalize spacing from template edge-cases: # - remove blank lines between consecutive bullet points # - collapse multiple blank lines to a single blank line CLIFF_OUTPUT=$(printf "%s" "$CLIFF_OUTPUT" | awk ' { lines[NR] = $0 } END { blank_streak = 0 for (i = 1; i <= NR; i++) { line = lines[i] next_line = (i < NR) ? lines[i + 1] : "" if (line ~ /^[[:space:]]*$/) { prev_line = (out_count > 0) ? out[out_count] : "" prev_is_bullet = prev_line ~ /^- / next_is_bullet = next_line ~ /^- / # Skip blank separators between bullet items if (prev_is_bullet && next_is_bullet) { continue } blank_streak++ if (blank_streak > 1) { continue } } else { blank_streak = 0 } out_count++ out[out_count] = line } for (i = 1; i <= out_count; i++) { print out[i] } } ') printf "%s\n" "$CLIFF_OUTPUT" echo "" # Ensure output ends with newline (cliff.toml trim=true removes trailing newline) if [ "$CLIFF_CONFIG" = "/tmp/cliff.toml" ]; then rm -f /tmp/cliff.toml fi } # Backend: Manual parsing (fallback) generate_with_manual() { log_info "${BLUE}==> Generating with manual parsing${NC}" echo "" # Check if we have commits if ! git log --oneline "${FROM_TAG}..${TO_TAG}" >/dev/null 2>&1; then log_info "${YELLOW}No commits found between ${FROM_TAG} and ${TO_TAG}${NC}" exit 0 fi # Analyze commits to generate title aligned with user-facing groups COMMITS_LOG=$(git log --pretty=format:"%s" "${FROM_TAG}..${TO_TAG}" 2>/dev/null || echo "") WHATS_NEW_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^feat(\(|:)" || true) FIXED_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^fix(\(|:)" || true) RELIABLE_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^perf(\(|:)" || true) DEPS_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^chore\(deps\):" || true) if [ "$WHATS_NEW_COUNT" -gt 0 ] && [ "$FIXED_COUNT" -gt 0 ]; then echo "# What's New & Fixed" elif [ "$WHATS_NEW_COUNT" -gt 0 ] && [ "$RELIABLE_COUNT" -gt 0 ]; then echo "# What's New & More Reliable" elif [ "$WHATS_NEW_COUNT" -gt 0 ]; then echo "# What's New" elif [ "$FIXED_COUNT" -gt 0 ] && [ "$RELIABLE_COUNT" -gt 0 ]; then echo "# Fixed & More Reliable" elif [ "$FIXED_COUNT" -gt 0 ]; then echo "# Fixed" elif [ "$RELIABLE_COUNT" -gt 0 ]; then echo "# More Reliable" elif [ "$DEPS_COUNT" -gt 0 ]; then echo "# Dependency Updates" else echo "# Release Updates" fi echo "" # Create temporary files for each category TMPDIR=$(mktemp -d) WHATS_NEW_FILE="${TMPDIR}/whats_new" FIXED_FILE="${TMPDIR}/fixed" RELIABLE_FILE="${TMPDIR}/reliable" DEPS_FILE="${TMPDIR}/deps" touch "$WHATS_NEW_FILE" "$FIXED_FILE" "$RELIABLE_FILE" "$DEPS_FILE" # Process commits git log --pretty=format:"%H %s" "${FROM_TAG}..${TO_TAG}" | while read -r hash subject; do if is_release_notes_skipped_commit "$hash"; then continue fi short_hash=$(git rev-parse --short "$hash") # Simple type extraction (before colon) TYPE="" # Skip non-user-facing scope changes to align with git-cliff behavior if echo "$subject" | grep -qE '^(feat|fix|chore|refactor)\([^)]*(devcontainer|vscode|scripts|dev-env|environment)[^)]*\):'; then TYPE="skip" elif echo "$subject" | grep -qE '^(feat|fix|chore|ci)\([^)]*(ci|workflow|actions|github-actions)[^)]*\):'; then TYPE="skip" elif echo "$subject" | grep -qE '^revert'; then TYPE="skip" else if echo "$subject" | grep -qE '^feat(\(|:)'; then TYPE="whats_new" elif echo "$subject" | grep -qE '^fix(\(|:)'; then # Skip non-user-facing fix scopes to align with cliff filtering if echo "$subject" | grep -qE '^fix\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\):'; then TYPE="skip" else TYPE="fixed" fi elif echo "$subject" | grep -qE '^perf(\(|:)'; then TYPE="reliable" elif echo "$subject" | grep -qE '^chore\(deps\):'; then TYPE="deps" else TYPE="skip" fi fi # Create markdown line LINE="- ${subject} ([${short_hash}](https://github.com/jpawlowski/hass.tibber_prices/commit/${hash}))" # Append to appropriate file case "$TYPE" in whats_new) echo "$LINE" >>"$WHATS_NEW_FILE" ;; fixed) echo "$LINE" >>"$FIXED_FILE" ;; reliable) echo "$LINE" >>"$RELIABLE_FILE" ;; deps) echo "$LINE" >>"$DEPS_FILE" ;; skip) ;; *) ;; esac done # Output grouped by category if [ -s "$WHATS_NEW_FILE" ]; then echo "### 🎉 What's New" echo "" cat "$WHATS_NEW_FILE" echo "" fi if [ -s "$FIXED_FILE" ]; then echo "### 🐛 Fixed" echo "" cat "$FIXED_FILE" echo "" fi if [ -s "$RELIABLE_FILE" ]; then echo "### ⚡ More Reliable" echo "" cat "$RELIABLE_FILE" echo "" fi if [ -s "$DEPS_FILE" ]; then echo "### 📦 Updated Dependencies" echo "" cat "$DEPS_FILE" echo "" fi # Cleanup rm -rf "$TMPDIR" } # ============================================================================ # Check if auto-update is possible (before generation) # ============================================================================ AUTO_UPDATE_AVAILABLE=false CURRENT_TITLE="" CURRENT_URL="" if [ -z "$CI" ] && [ -z "$GITHUB_ACTIONS" ] && command -v gh >/dev/null 2>&1 && [ "$TO_TAG" != "HEAD" ]; then # Check if TO_TAG is a valid tag and release exists if git rev-parse "$TO_TAG" >/dev/null 2>&1 && gh release view "$TO_TAG" >/dev/null 2>&1; then AUTO_UPDATE_AVAILABLE=true CURRENT_TITLE=$(gh release view "$TO_TAG" --json name --jq '.name' 2>/dev/null || echo "$TO_TAG") CURRENT_URL=$(gh release view "$TO_TAG" --json url --jq '.url' 2>/dev/null || echo "") fi fi # ============================================================================ # Generate release notes (only once) # ============================================================================ # Validate backend availability case "$BACKEND" in copilot) if ! command -v copilot >/dev/null 2>&1; then echo -e "${RED}Error: GitHub Copilot CLI not found${NC}" >&2 echo "Install: npm install -g @github/copilot" >&2 echo "See: https://github.com/github/copilot-cli" >&2 exit 1 fi ;; git-cliff) if ! command -v git-cliff >/dev/null 2>&1; then echo -e "${RED}Error: git-cliff not found${NC}" >&2 echo "Install: https://git-cliff.org/docs/installation" >&2 exit 1 fi ;; esac # Generate to temp file if auto-update is possible, else stdout if [ "$AUTO_UPDATE_AVAILABLE" = "true" ]; then TEMP_NOTES=$(mktemp) case "$BACKEND" in copilot) generate_with_copilot >"$TEMP_NOTES" ;; git-cliff) generate_with_gitcliff >"$TEMP_NOTES" ;; manual) generate_with_manual >"$TEMP_NOTES" ;; esac else # No auto-update, just output to stdout case "$BACKEND" in copilot) generate_with_copilot ;; git-cliff) generate_with_gitcliff ;; manual) generate_with_manual ;; esac echo "" >&2 log_info "${GREEN}==> Release notes generated successfully!${NC}" exit 0 fi # ============================================================================ # Auto-update existing GitHub release # ============================================================================ # Extract title and body from generated notes # Title is the first line starting with '# ' (H1) # Body is everything after the first blank line following the title EXTRACTED_TITLE="" NOTES_BODY="" # Find the first line that starts with '# ' (skip any metadata before it) TITLE_LINE_NUM=$(grep -n '^# ' "$TEMP_NOTES" | head -n 1 | cut -d: -f1) if [ -n "$TITLE_LINE_NUM" ]; then # Extract title (remove '# ' prefix from that specific line) FIRST_TITLE_LINE=$(sed -n "${TITLE_LINE_NUM}p" "$TEMP_NOTES") EXTRACTED_TITLE=$(echo "$FIRST_TITLE_LINE" | sed 's/^# //') # Body starts from the line after the title BODY_START_LINE=$((TITLE_LINE_NUM + 1)) NOTES_BODY=$(tail -n +$BODY_START_LINE "$TEMP_NOTES" | sed '1{/^$/d;}') else # No H1 title found, use entire content as body NOTES_BODY=$(cat "$TEMP_NOTES") fi # Generate final title for GitHub release (without version tag - HACS adds it automatically) if [ -n "$EXTRACTED_TITLE" ]; then # Use the extracted title from release notes (no version prefix) RELEASE_TITLE="$EXTRACTED_TITLE" else # Fallback: Keep current title if it looks meaningful # (more than just the tag itself) RELEASE_TITLE="$CURRENT_TITLE" if [ "$RELEASE_TITLE" = "$TO_TAG" ]; then # Current title is just the tag, generate from commit analysis # This is the git-cliff style fallback (simple but functional) FEAT_COUNT=$(echo "$NOTES_BODY" | grep -c "^### 🎉 New Features" || true) FIX_COUNT=$(echo "$NOTES_BODY" | grep -c "^### 🐛 Bug Fixes" || true) DOCS_COUNT=$(echo "$NOTES_BODY" | grep -c "^### 📚 Documentation" || true) if [ "$FEAT_COUNT" -gt 0 ] && [ "$FIX_COUNT" -gt 0 ]; then RELEASE_TITLE="New Features & Bug Fixes" elif [ "$FEAT_COUNT" -gt 0 ]; then RELEASE_TITLE="New Features" elif [ "$FIX_COUNT" -gt 0 ]; then RELEASE_TITLE="Bug Fixes" elif [ "$DOCS_COUNT" -gt 0 ]; then RELEASE_TITLE="Documentation Updates" else RELEASE_TITLE="Release Updates" fi fi fi # Save body (without H1 title) to temp file for GitHub TEMP_BODY=$(mktemp) echo "$NOTES_BODY" >"$TEMP_BODY" # Show the generated notes (with title for preview) if [ -n "$EXTRACTED_TITLE" ]; then echo "# $EXTRACTED_TITLE" echo "" fi cat "$TEMP_BODY" echo "" >&2 log_info "${GREEN}==> Release notes generated successfully!${NC}" echo "" >&2 log_info "${BLUE}═══════════════════════════════════════════════════════════════${NC}" log_info "A GitHub release exists for ${CYAN}$TO_TAG${NC}" log_info "Current title: ${CYAN}$CURRENT_TITLE${NC}" log_info "New title: ${CYAN}$RELEASE_TITLE${NC}" if [ -n "$CURRENT_URL" ]; then log_info "URL: ${CYAN}$CURRENT_URL${NC}" fi log_info "${BLUE}═══════════════════════════════════════════════════════════════${NC}" echo "" >&2 printf "${YELLOW}Do you want to update the release notes on GitHub? [y/N]:${NC} " >&2 read -r UPDATE_RELEASE if [ "$UPDATE_RELEASE" = "y" ] || [ "$UPDATE_RELEASE" = "Y" ]; then log_info "Updating release $TO_TAG on GitHub..." log_info "Title: ${CYAN}$RELEASE_TITLE${NC}" # Update release with both title and body if gh release edit "$TO_TAG" --title "$RELEASE_TITLE" --notes-file "$TEMP_BODY" 2>&1 >&2; then echo "" >&2 log_info "${GREEN}✓ Release notes updated successfully!${NC}" if [ -n "$CURRENT_URL" ]; then log_info "View at: ${CYAN}$CURRENT_URL${NC}" fi else echo "" >&2 log_info "${RED}✗ Failed to update release${NC}" log_info "You can manually update with:" echo -e " ${CYAN}gh release edit $TO_TAG --notes-file -${NC} < notes.md" >&2 fi else log_info "Skipped release update" log_info "You can update manually later with:" echo -e " ${CYAN}./scripts/release/generate-notes $FROM_TAG $TO_TAG | gh release edit $TO_TAG --notes-file -${NC}" >&2 fi rm -f "$TEMP_NOTES" "$TEMP_BODY" exit 0