mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
cliff.toml: - Extract Impact footer value from commit footers and use as release note text when present, falling back to scope+message format otherwise - Fix whitespace in body template (remove extra indentation) scripts/release/generate-notes: - Add RELEASE_NOTES_TRAILER_SKIP_FILTER to exclude commits marked with Release-Notes: skip, User-Impact: none, or Released-Bug: no trailers - Add RELEASE_NOTES_COMPACT_DIFF and RELEASE_NOTES_DIFF_MAX_BYTES to limit AI diff context size for faster, more focused prompts - Add RELEASE_NOTES_CLIFF_FILTER_PATHS to restrict cliff to user-facing paths only when generating AI-assisted notes - Add RELEASE_NOTES_CLIFF_SINGLE_RELEASE to pass --latest to cliff - Define USER_FACING_PATHS list for scoped AI diff context Release-Notes: skip User-Impact: none
955 lines
34 KiB
Bash
Executable file
955 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
|
|
# 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
|