mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Enhance the script to only check for existing releases if the specified tag exists locally. Added authentication check for the GitHub CLI before querying releases, with informative logging for users. Impact: Users will receive clearer messages regarding the status of GitHub releases and authentication requirements.
962 lines
34 KiB
Bash
Executable file
962 lines
34 KiB
Bash
Executable file
#!/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! ☕
|
|
|
|
[](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
|
|
# Only check for release if the tag exists locally
|
|
if git rev-parse "$TO_TAG" >/dev/null 2>&1; then
|
|
# Check if gh is authenticated before querying the release
|
|
if ! gh auth status >/dev/null 2>&1; then
|
|
log_info "${YELLOW}Note: GitHub CLI is not authenticated — skipping auto-update check.${NC}"
|
|
log_info "${YELLOW} Run 'gh auth login' to enable updating existing releases.${NC}"
|
|
elif 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 "")
|
|
else
|
|
log_info "${YELLOW}Note: No GitHub release found for ${CYAN}${TO_TAG}${YELLOW}.${NC}"
|
|
log_info "${YELLOW} Create one with: gh release create ${TO_TAG}${NC}"
|
|
fi
|
|
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
|