chore(scripts): improve release tooling with trailer filtering and Impact rendering

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
This commit is contained in:
Julian Pawlowski 2026-04-12 12:11:56 +00:00
parent 6e990564b9
commit 32b080d178
2 changed files with 439 additions and 147 deletions

View file

@ -5,13 +5,19 @@
# Template for the changelog body # Template for the changelog body
header = "" header = ""
body = """ body = """
{% for group, commits in commits | group_by(attribute="group") %} {% for group, commits in commits | group_by(attribute="group") -%}
### {{ group | striptags | trim | upper_first }} ### {{ group | striptags | trim | upper_first }}
{% for commit in commits %} {% for commit in commits -%}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\ {% set impact_text = "" -%}
{% if commit.breaking %} [**BREAKING**]{% endif %} \ {% set footers = commit.footers | default(value=[]) -%}
([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }})) {% for footer in footers -%}
{% if footer.token == "Impact" -%}
{% set impact_text = footer.value -%}
{% endif -%}
{% endfor -%}
- {% if impact_text %}{{ impact_text | trim | upper_first }}{% else %}{% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}{% endif %}{% if commit.breaking %} [**BREAKING**]{% endif %} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/jpawlowski/hass.tibber_prices/commit/{{ commit.id }}))
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
--- ---
@ -25,7 +31,8 @@ trim = true
[git] [git]
# Parse conventional commits # Parse conventional commits
conventional_commits = true conventional_commits = true
# Include all commits (even non-conventional) # Keep unconventional commits in parsing pipeline; parser rules decide what to skip.
# This avoids noisy parse-error warnings on older commit history.
filter_unconventional = false filter_unconventional = false
split_commits = false split_commits = false
@ -33,22 +40,28 @@ split_commits = false
commit_parsers = [ commit_parsers = [
# Skip manifest.json version bumps (release housekeeping) # Skip manifest.json version bumps (release housekeeping)
{ message = "^chore\\(release\\): bump version", skip = true }, { message = "^chore\\(release\\): bump version", skip = true },
# Skip explicit revert commits; final net state should drive release notes
{ message = "^revert", skip = true },
# Skip development environment changes (not user-relevant) # Skip development environment changes (not user-relevant)
{ message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true }, { message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true },
# Skip CI/CD infrastructure changes (not user-relevant) # Skip CI/CD infrastructure changes (not user-relevant)
{ message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true }, { message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true },
# Keep dependency updates - these ARE relevant for users # Skip non-user-facing fix scopes
{ message = "^chore\\(deps\\):", group = "📦 Dependencies" }, { message = "^fix\\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\\):", skip = true },
# Regular commit types # User-facing categories aligned with AI output style
{ message = "^feat", group = "🎉 New Features" }, { message = "^feat", group = "🎉 What's New" },
{ message = "^fix", group = "🐛 Bug Fixes" }, { message = "^fix", group = "🐛 Fixed" },
{ message = "^docs?", group = "📚 Documentation" }, { message = "^perf", group = "⚡ More Reliable" },
{ message = "^perf", group = "⚡ Performance" }, { message = "^chore\\(deps\\):", group = "📦 Updated Dependencies" },
{ message = "^refactor", group = "🔧 Maintenance & Refactoring" }, # Skip mostly developer-facing categories
{ message = "^style", group = "🎨 Styling" }, { message = "^docs?", skip = true },
{ message = "^test", group = "🧪 Testing" }, { message = "^refactor", skip = true },
{ message = "^chore", group = "🔧 Maintenance & Refactoring" }, { message = "^style", skip = true },
{ message = "^build", group = "📦 Build" }, { message = "^test", skip = true },
{ message = "^build", skip = true },
{ message = "^chore", skip = true },
# Final fallback to avoid ungrouped commits
{ message = ".*", skip = true },
] ]
# Protect breaking changes # Protect breaking changes
@ -56,5 +69,5 @@ commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/jpawlowski/hass.tibber_prices/issues/${2}))" }, { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/jpawlowski/hass.tibber_prices/issues/${2}))" },
] ]
# Filter out commits # Apply commit parser filtering rules
filter_commits = false filter_commits = true

View file

@ -43,6 +43,156 @@ fi
BACKEND="${RELEASE_NOTES_BACKEND:-auto}" BACKEND="${RELEASE_NOTES_BACKEND:-auto}"
USE_AI="${USE_AI:-true}" USE_AI="${USE_AI:-true}"
GITHUB_REPO="${GITHUB_REPOSITORY:-jpawlowski/hass.tibber_prices}" 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 # Parse arguments
FROM_TAG="${1:-$(git describe --tags --abbrev=0 2>/dev/null || echo "")}" FROM_TAG="${1:-$(git describe --tags --abbrev=0 2>/dev/null || echo "")}"
@ -54,6 +204,11 @@ if [[ -z $FROM_TAG ]]; then
exit 1 exit 1
fi 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 "${BLUE}==> Generating release notes: ${FROM_TAG}..${TO_TAG}${NC}"
log_info "" log_info ""
@ -100,21 +255,36 @@ generate_with_copilot() {
log_info "${YELLOW}Note: This will use one premium request from your monthly quota${NC}" log_info "${YELLOW}Note: This will use one premium request from your monthly quota${NC}"
log_info "" log_info ""
# Get commit log for the range with file statistics # Get filtered commit log for the range with file statistics (oldest -> newest).
# This helps the AI understand which commits touched which files # Commits explicitly marked as non-user-facing are excluded.
COMMITS=$(git log --pretty=format:"%h | %s%n%b%n" --stat --compact-summary "${FROM_TAG}..${TO_TAG}") 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. # Get code diff for user-facing files to give the AI real context about what changed.
# Limited to ~8KB to keep the prompt manageable. # Compact mode is enabled by default to reduce prompt tokens without reducing semantic signal.
DIFF_CONTEXT=$(git diff --unified=2 --diff-filter=AM \ # Toggle: RELEASE_NOTES_COMPACT_DIFF=false
-- "custom_components/tibber_prices/sensor/" \ DIFF_CONTEXT=$(build_diff_context)
-- "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/" \
"${FROM_TAG}..${TO_TAG}" 2>/dev/null | head -c 8000 || true)
if [[ -z $COMMITS ]]; then if [[ -z $COMMITS ]]; then
log_info "${YELLOW}No commits found between ${FROM_TAG} and ${TO_TAG}${NC}" log_info "${YELLOW}No commits found between ${FROM_TAG} and ${TO_TAG}${NC}"
@ -141,6 +311,13 @@ Translate technical changes into real user benefits. Write as a product manager
users what they can now DO, what was BROKEN and is now FIXED, or why the integration users what they can now DO, what was BROKEN and is now FIXED, or why the integration
is now more RELIABLE. 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 ## WRITING RULES — FOLLOW STRICTLY
**Rule 1 — No jargon, ever.** **Rule 1 — No jargon, ever.**
@ -236,6 +413,24 @@ ${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):** **Code diff for user-facing files (use this to understand WHAT actually changed — but always translate to user language):**
${DIFF_CONTEXT} ${DIFF_CONTEXT}
@ -275,29 +470,28 @@ End after the Buy Me A Coffee button. No meta-commentary, no explanations."
generate_with_gitcliff() { generate_with_gitcliff() {
log_info "${BLUE}==> Generating with git-cliff${NC}" log_info "${BLUE}==> Generating with git-cliff${NC}"
# Analyze commits to generate smart title # Analyze commits to generate a title aligned with current user-facing groups
FROM_TAG_SHORT="${FROM_TAG}"
TO_TAG_SHORT="${TO_TAG}"
COMMITS_LOG=$(git log --pretty=format:"%s" "${FROM_TAG}..${TO_TAG}" 2>/dev/null || echo "") COMMITS_LOG=$(git log --pretty=format:"%s" "${FROM_TAG}..${TO_TAG}" 2>/dev/null || echo "")
# Count user-facing changes (exclude dev/ci/docs) WHATS_NEW_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^feat(\(|:)" || true)
FEAT_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^feat\((sensors|binary_sensor|config_flow|services|coordinator|api)\):" || true) FIXED_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^fix(\(|:)" || true)
FIX_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^fix\((sensors|binary_sensor|config_flow|services|coordinator|api|translations)\):" || true) RELIABLE_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^perf(\(|:)" || true)
PERF_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^perf\(" || true) DEPS_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^chore\(deps\):" || true)
# Generate intelligent title based on changes if [ "$WHATS_NEW_COUNT" -gt 0 ] && [ "$FIXED_COUNT" -gt 0 ]; then
if [ $PERF_COUNT -gt 0 ]; then TITLE="# What's New & Fixed"
TITLE="# Performance & Reliability Improvements" elif [ "$WHATS_NEW_COUNT" -gt 0 ] && [ "$RELIABLE_COUNT" -gt 0 ]; then
elif [ $FEAT_COUNT -gt 2 ]; then TITLE="# What's New & More Reliable"
TITLE="# New Features & Enhancements" elif [ "$WHATS_NEW_COUNT" -gt 0 ]; then
elif [ $FEAT_COUNT -gt 0 ] && [ $FIX_COUNT -gt 0 ]; then TITLE="# What's New"
TITLE="# New Features & Bug Fixes" elif [ "$FIXED_COUNT" -gt 0 ] && [ "$RELIABLE_COUNT" -gt 0 ]; then
elif [ $FEAT_COUNT -gt 0 ]; then TITLE="# Fixed & More Reliable"
TITLE="# New Features" elif [ "$FIXED_COUNT" -gt 0 ]; then
elif [ $FIX_COUNT -gt 2 ]; then TITLE="# Fixed"
TITLE="# Bug Fixes & Improvements" elif [ "$RELIABLE_COUNT" -gt 0 ]; then
elif [ $FIX_COUNT -gt 0 ]; then TITLE="# More Reliable"
TITLE="# Bug Fixes" elif [ "$DEPS_COUNT" -gt 0 ]; then
TITLE="# Dependency Updates"
else else
TITLE="# Release Updates" TITLE="# Release Updates"
fi fi
@ -326,24 +520,114 @@ conventional_commits = true
filter_unconventional = false filter_unconventional = false
split_commits = false split_commits = false
commit_parsers = [ commit_parsers = [
{ message = "^feat", group = "🎉 New Features" }, { message = "^chore\\(release\\): bump version", skip = true },
{ message = "^fix", group = "🐛 Bug Fixes" }, { message = "^revert", skip = true },
{ message = "^doc", group = "📚 Documentation" }, { message = "^(feat|fix|chore|refactor)\\((devcontainer|vscode|scripts|dev-env|environment)\\):", skip = true },
{ message = "^perf", group = "⚡ Performance" }, { message = "^(feat|fix|chore|ci)\\((ci|workflow|actions|github-actions)\\):", skip = true },
{ message = "^refactor", group = "🔧 Maintenance & Refactoring" }, { message = "^fix\\((docs|lint|types|tests?|ci|workflow|scripts|devcontainer|vscode|build|release)\\):", skip = true },
{ message = "^style", group = "🎨 Styling" }, { message = "^feat", group = "🎉 What's New" },
{ message = "^test", group = "🧪 Testing" }, { message = "^fix", group = "🐛 Fixed" },
{ message = "^chore", group = "🔧 Maintenance & Refactoring" }, { message = "^perf", group = "⚡ More Reliable" },
{ message = "^ci", group = "🔄 CI/CD" }, { message = "^chore\\(deps\\):", group = "📦 Updated Dependencies" },
{ message = "^build", group = "📦 Build" }, { 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 EOF
CLIFF_CONFIG="/tmp/cliff.toml" CLIFF_CONFIG="/tmp/cliff.toml"
else else
CLIFF_CONFIG="cliff.toml" CLIFF_CONFIG="cliff.toml"
fi fi
git-cliff --config "$CLIFF_CONFIG" "${FROM_TAG}..${TO_TAG}" 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) echo "" # Ensure output ends with newline (cliff.toml trim=true removes trailing newline)
if [ "$CLIFF_CONFIG" = "/tmp/cliff.toml" ]; then if [ "$CLIFF_CONFIG" = "/tmp/cliff.toml" ]; then
@ -362,27 +646,28 @@ generate_with_manual() {
exit 0 exit 0
fi fi
# Analyze commits to generate smart title # 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 "") COMMITS_LOG=$(git log --pretty=format:"%s" "${FROM_TAG}..${TO_TAG}" 2>/dev/null || echo "")
# Count user-facing changes (exclude dev/ci/docs) WHATS_NEW_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^feat(\(|:)" || true)
FEAT_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^feat\((sensors|binary_sensor|config_flow|services|coordinator|api)\):" || true) FIXED_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^fix(\(|:)" || true)
FIX_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^fix\((sensors|binary_sensor|config_flow|services|coordinator|api|translations)\):" || true) RELIABLE_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^perf(\(|:)" || true)
PERF_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^perf\(" || true) DEPS_COUNT=$(echo "$COMMITS_LOG" | grep -cE "^chore\(deps\):" || true)
# Generate intelligent title based on changes if [ "$WHATS_NEW_COUNT" -gt 0 ] && [ "$FIXED_COUNT" -gt 0 ]; then
if [ $PERF_COUNT -gt 0 ]; then echo "# What's New & Fixed"
echo "# Performance & Reliability Improvements" elif [ "$WHATS_NEW_COUNT" -gt 0 ] && [ "$RELIABLE_COUNT" -gt 0 ]; then
elif [ $FEAT_COUNT -gt 2 ]; then echo "# What's New & More Reliable"
echo "# New Features & Enhancements" elif [ "$WHATS_NEW_COUNT" -gt 0 ]; then
elif [ $FEAT_COUNT -gt 0 ] && [ $FIX_COUNT -gt 0 ]; then echo "# What's New"
echo "# New Features & Bug Fixes" elif [ "$FIXED_COUNT" -gt 0 ] && [ "$RELIABLE_COUNT" -gt 0 ]; then
elif [ $FEAT_COUNT -gt 0 ]; then echo "# Fixed & More Reliable"
echo "# New Features" elif [ "$FIXED_COUNT" -gt 0 ]; then
elif [ $FIX_COUNT -gt 2 ]; then echo "# Fixed"
echo "# Bug Fixes & Improvements" elif [ "$RELIABLE_COUNT" -gt 0 ]; then
elif [ $FIX_COUNT -gt 0 ]; then echo "# More Reliable"
echo "# Bug Fixes" elif [ "$DEPS_COUNT" -gt 0 ]; then
echo "# Dependency Updates"
else else
echo "# Release Updates" echo "# Release Updates"
fi fi
@ -390,99 +675,98 @@ generate_with_manual() {
# Create temporary files for each category # Create temporary files for each category
TMPDIR=$(mktemp -d) TMPDIR=$(mktemp -d)
FEAT_FILE="${TMPDIR}/feat" WHATS_NEW_FILE="${TMPDIR}/whats_new"
FIX_FILE="${TMPDIR}/fix" FIXED_FILE="${TMPDIR}/fixed"
DOCS_FILE="${TMPDIR}/docs" RELIABLE_FILE="${TMPDIR}/reliable"
REFACTOR_FILE="${TMPDIR}/refactor" DEPS_FILE="${TMPDIR}/deps"
TEST_FILE="${TMPDIR}/test"
OTHER_FILE="${TMPDIR}/other"
touch "$FEAT_FILE" "$FIX_FILE" "$DOCS_FILE" "$REFACTOR_FILE" "$TEST_FILE" "$OTHER_FILE" touch "$WHATS_NEW_FILE" "$FIXED_FILE" "$RELIABLE_FILE" "$DEPS_FILE"
# Process commits # Process commits
git log --pretty=format:"%h %s" "${FROM_TAG}..${TO_TAG}" | while read -r hash subject; do 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) # Simple type extraction (before colon)
TYPE="" TYPE=""
if echo "$subject" | grep -q '^feat'; then # Skip non-user-facing scope changes to align with git-cliff behavior
TYPE="feat" if echo "$subject" | grep -qE '^(feat|fix|chore|refactor)\([^)]*(devcontainer|vscode|scripts|dev-env|environment)[^)]*\):'; then
elif echo "$subject" | grep -q '^fix'; then TYPE="skip"
TYPE="fix" elif echo "$subject" | grep -qE '^(feat|fix|chore|ci)\([^)]*(ci|workflow|actions|github-actions)[^)]*\):'; then
elif echo "$subject" | grep -q '^docs'; then TYPE="skip"
TYPE="docs" elif echo "$subject" | grep -qE '^revert'; then
elif echo "$subject" | grep -q '^refactor\|^chore'; then TYPE="skip"
TYPE="refactor"
elif echo "$subject" | grep -q '^test'; then
TYPE="test"
else else
TYPE="other" 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 fi
# Create markdown line # Create markdown line
LINE="- ${subject} ([${hash}](https://github.com/jpawlowski/hass.tibber_prices/commit/${hash}))" LINE="- ${subject} ([${short_hash}](https://github.com/jpawlowski/hass.tibber_prices/commit/${hash}))"
# Append to appropriate file # Append to appropriate file
case "$TYPE" in case "$TYPE" in
feat) whats_new)
echo "$LINE" >> "$FEAT_FILE" echo "$LINE" >> "$WHATS_NEW_FILE"
;; ;;
fix) fixed)
echo "$LINE" >> "$FIX_FILE" echo "$LINE" >> "$FIXED_FILE"
;; ;;
docs) reliable)
echo "$LINE" >> "$DOCS_FILE" echo "$LINE" >> "$RELIABLE_FILE"
;; ;;
refactor) deps)
echo "$LINE" >> "$REFACTOR_FILE" echo "$LINE" >> "$DEPS_FILE"
;; ;;
test) skip)
echo "$LINE" >> "$TEST_FILE"
;; ;;
*) *)
echo "$LINE" >> "$OTHER_FILE"
;; ;;
esac esac
done done
# Output grouped by category # Output grouped by category
if [ -s "$FEAT_FILE" ]; then if [ -s "$WHATS_NEW_FILE" ]; then
echo "### 🎉 New Features" echo "### 🎉 What's New"
echo "" echo ""
cat "$FEAT_FILE" cat "$WHATS_NEW_FILE"
echo "" echo ""
fi fi
if [ -s "$FIX_FILE" ]; then if [ -s "$FIXED_FILE" ]; then
echo "### 🐛 Bug Fixes" echo "### 🐛 Fixed"
echo "" echo ""
cat "$FIX_FILE" cat "$FIXED_FILE"
echo "" echo ""
fi fi
if [ -s "$DOCS_FILE" ]; then if [ -s "$RELIABLE_FILE" ]; then
echo "### 📚 Documentation" echo "### ⚡ More Reliable"
echo "" echo ""
cat "$DOCS_FILE" cat "$RELIABLE_FILE"
echo "" echo ""
fi fi
if [ -s "$REFACTOR_FILE" ]; then if [ -s "$DEPS_FILE" ]; then
echo "### 🔧 Maintenance & Refactoring" echo "### 📦 Updated Dependencies"
echo "" echo ""
cat "$REFACTOR_FILE" cat "$DEPS_FILE"
echo ""
fi
if [ -s "$TEST_FILE" ]; then
echo "### 🧪 Testing"
echo ""
cat "$TEST_FILE"
echo ""
fi
if [ -s "$OTHER_FILE" ]; then
echo "### 📝 Other Changes"
echo ""
cat "$OTHER_FILE"
echo "" echo ""
fi fi
@ -669,8 +953,3 @@ fi
rm -f "$TEMP_NOTES" "$TEMP_BODY" rm -f "$TEMP_NOTES" "$TEMP_BODY"
exit 0 exit 0
fi
# If no auto-update, just show success message
echo ""
log_info "${GREEN}==> Release notes generated successfully!${NC}"