This is an automated email from the ASF dual-hosted git repository.

janhoy pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_9x by this push:
     new c4c83c47cb5 SOLR-17989 Backport auto creation of changelog for 
renovate to branch_9x (#4003)
c4c83c47cb5 is described below

commit c4c83c47cb58804256ae02aa178e2cd14fc85de0
Author: Jan Høydahl <[email protected]>
AuthorDate: Fri Jan 2 23:18:02 2026 +0100

    SOLR-17989 Backport auto creation of changelog for renovate to branch_9x 
(#4003)
    
    Also backport improvements to changelog validation
---
 .github/scripts/generate-renovate-changelog.py | 249 +++++++++++++++++++++++++
 .github/scripts/validate-changelog-yaml.py     |  32 ++++
 .github/workflows/validate-changelog.yml       |   2 +
 dev-tools/scripts/releaseWizard.yaml           |   6 +-
 4 files changed, 288 insertions(+), 1 deletion(-)

diff --git a/.github/scripts/generate-renovate-changelog.py 
b/.github/scripts/generate-renovate-changelog.py
new file mode 100644
index 00000000000..5c04daf710a
--- /dev/null
+++ b/.github/scripts/generate-renovate-changelog.py
@@ -0,0 +1,249 @@
+#!/usr/bin/env python3
+"""
+Generate changelog YAML entry for Renovate dependency update PRs.
+
+This script parses the PR title to extract dependency information,
+then generates a changelog YAML file in changelog/unreleased/ with the proper
+naming convention and content structure.
+
+Usage:
+    python3 generate-renovate-changelog.py --pr-number 1234 --pr-title "Update 
org.apache.httpcomponents to v1.2.3"
+"""
+
+import argparse
+import os
+import re
+import sys
+import yaml
+from pathlib import Path
+from typing import Optional, Tuple
+
+
+def sanitize_slug(text: str, max_length: int = 50) -> str:
+    """
+    Sanitize text to create a valid filename slug.
+
+    - Convert to lowercase
+    - Replace dots, colons, slashes with dashes
+    - Replace other special chars with dashes
+    - Preserve word boundaries
+    - Truncate to max_length while preserving word boundaries
+    """
+    # Convert to lowercase
+    text = text.lower()
+
+    # Replace colons, slashes, dots with dashes
+    text = re.sub(r'[:/.]+', '-', text)
+
+    # Replace other special characters with dashes
+    text = re.sub(r'[^a-z0-9\s-]', '-', text)
+
+    # Replace spaces with dashes
+    text = re.sub(r'\s+', '-', text)
+
+    # Replace multiple dashes with single dash
+    text = re.sub(r'-+', '-', text)
+
+    # Remove leading/trailing dashes
+    text = text.strip('-')
+
+    # Truncate to max_length at word boundary
+    if len(text) > max_length:
+        text = text[:max_length]
+        # Find last dash and truncate there
+        last_dash = text.rfind('-')
+        if last_dash > 0:
+            text = text[:last_dash]
+        text = text.rstrip('-')
+
+    return text
+
+
+def parse_pr_title(title: str) -> Tuple[str, Optional[str]]:
+    """
+    Parse Renovate PR title to extract dependency name and version.
+
+    Handles patterns like:
+    - "Update dependency org.junit.jupiter:junit-jupiter to v6"
+    - "Update dependency com.jayway.jsonpath:json-path to v2.10.0"
+    - "Update netty to v4.2.6.Final"
+    - "Update apache.kafka to v3.9.1"
+    - "Update actions/checkout action to v5"
+
+    Returns:
+        Tuple of (title_for_changelog, dependency_slug_for_filename)
+
+    Note: The slug excludes the version number so the filename remains stable
+          across version updates.
+    """
+
+    # Pattern 1: "Update dependency {group}:{artifact} to {version}"
+    match = re.match(r'Update dependency (.+?) to (.+?)(?:\s*$|\s*\(|$)', 
title)
+    if match:
+        dep_name = match.group(1)
+        version = match.group(2).strip()
+        changelog_title = f"Update {dep_name} to {version}"
+        # Slug contains only the dependency name, not the version
+        slug = sanitize_slug(f"Update {dep_name}")
+        return changelog_title, slug
+
+    # Pattern 2: "Update {owner}/{action} action to {version}"
+    match = re.match(r'Update ([a-z0-9-]+/[a-z0-9-]+) action to 
(.+?)(?:\s*$|\s*\(|$)', title)
+    if match:
+        action = match.group(1)
+        version = match.group(2).strip()
+        changelog_title = f"Update {action} action to {version}"
+        # Slug contains only the action name, not the version
+        slug = sanitize_slug(f"Update {action} action")
+        return changelog_title, slug
+
+    # Pattern 3: "Update {package} to {version}" (short form)
+    match = re.match(r'Update ([a-z0-9\-_.]+) to (.+?)(?:\s*$|\s*\(|$)', title)
+    if match:
+        package = match.group(1)
+        version = match.group(2).strip()
+        changelog_title = f"Update {package} to {version}"
+        # Slug contains only the package name, not the version
+        slug = sanitize_slug(f"Update {package}")
+        return changelog_title, slug
+
+    # Fallback: use title as-is if no pattern matches
+    return title, sanitize_slug(title)
+
+
+def generate_changelog_entry(
+    pr_number: int,
+    changelog_title: str,
+    pr_url: str = None
+) -> dict:
+    """Generate the changelog YAML entry dict."""
+
+    if pr_url is None:
+        pr_url = f"https://github.com/apache/solr/pull/{pr_number}";
+
+    return {
+        'title': changelog_title,
+        'type': 'dependency_update',
+        'authors': [
+            {'name': 'solrbot'}
+        ],
+        'links': [
+            {
+                'name': f'PR#{pr_number}',
+                'url': pr_url
+            }
+        ]
+    }
+
+
+def find_existing_changelog_file(pr_number: int, changelog_dir: str = 
'changelog/unreleased') -> Optional[str]:
+    """Find existing changelog file for this PR, returns path if exists."""
+    pattern = f"PR#{pr_number}-*.yml"
+    path = Path(changelog_dir)
+
+    if not path.exists():
+        return None
+
+    for file in path.glob(f"PR#{pr_number}-*.yml"):
+        return str(file)
+
+    return None
+
+
+def should_update_changelog(existing_file: str, new_title: str) -> bool:
+    """
+    Check if we need to update the changelog file.
+
+    Updates if the title has changed (version was bumped).
+    """
+    if not existing_file or not Path(existing_file).exists():
+        return False
+
+    try:
+        with open(existing_file, 'r') as f:
+            content = yaml.safe_load(f)
+
+        existing_title = content.get('title', '')
+        return existing_title != new_title
+    except Exception as e:
+        print(f"Warning: Could not read existing file {existing_file}: {e}", 
file=sys.stderr)
+        return False
+
+
+def write_changelog_file(filename: str, entry: dict, changelog_dir: str = 
'changelog/unreleased') -> None:
+    """Write the changelog YAML file."""
+    path = Path(changelog_dir)
+    path.mkdir(parents=True, exist_ok=True)
+
+    filepath = path / filename
+
+    # Use YAML dumper that preserves order and formatting
+    with open(filepath, 'w') as f:
+        yaml.dump(
+            entry,
+            f,
+            default_flow_style=False,
+            sort_keys=False,
+            allow_unicode=True
+        )
+
+    print(f"Created/updated changelog file: {filepath}")
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description='Generate changelog entry for Renovate PR',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  python3 generate-renovate-changelog.py --pr-number 1234 --pr-title "Update 
org.apache.httpcomponents to v1.2.3"
+  python3 generate-renovate-changelog.py --pr-number 3751 --pr-title "Update 
dependency com.microsoft.onnxruntime:onnxruntime to v1.23.1"
+        """
+    )
+
+    parser.add_argument(
+        '--pr-number',
+        type=int,
+        required=True,
+        help='GitHub PR number'
+    )
+    parser.add_argument(
+        '--pr-title',
+        required=True,
+        help='GitHub PR title (from the Renovate bot)'
+    )
+    parser.add_argument(
+        '--changelog-dir',
+        default='changelog/unreleased',
+        help='Directory for changelog files (default: changelog/unreleased)'
+    )
+
+    args = parser.parse_args()
+
+    # Parse the PR title
+    changelog_title, slug = parse_pr_title(args.pr_title)
+
+    # Generate filename
+    filename = f"PR#{args.pr_number}-{slug}.yml"
+
+    # Check if file already exists
+    existing_file = find_existing_changelog_file(args.pr_number, 
args.changelog_dir)
+
+    # Generate the new entry
+    entry = generate_changelog_entry(args.pr_number, changelog_title)
+
+    # Decide if we need to write
+    should_write = True
+    if existing_file and not should_update_changelog(existing_file, 
changelog_title):
+        print(f"Changelog entry already up-to-date: {existing_file}")
+        should_write = False
+
+    if should_write:
+        write_changelog_file(filename, entry, args.changelog_dir)
+        return 0
+    else:
+        return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/.github/scripts/validate-changelog-yaml.py 
b/.github/scripts/validate-changelog-yaml.py
index be1c9205cdb..5503872cf6b 100644
--- a/.github/scripts/validate-changelog-yaml.py
+++ b/.github/scripts/validate-changelog-yaml.py
@@ -22,10 +22,14 @@ Validates changelog YAML files in changelog/unreleased/ 
folder.
 
 Checks:
 - File is valid YAML
+- All top-level keys are valid (title, type, issues, links, important_notes, 
modules, authors)
+- Deprecated keys (merge_requests, configurations) are not used
 - Contains required 'title' field (non-empty string)
 - Contains required 'type' field (one of: added, changed, fixed, deprecated, 
removed, dependency_update, security, other)
 - Contains required 'authors' field with at least one author
 - Each author has a 'name' field (non-empty string)
+- Contains either 'links' or 'issues' field (or both)
+- If 'issues' is present, it must be an integer not exceeding 17000
 """
 
 import sys
@@ -35,6 +39,8 @@ import yaml
 def validate_changelog_yaml(file_path):
     """Validate a changelog YAML file."""
     valid_types = ['added', 'changed', 'fixed', 'deprecated', 'removed', 
'dependency_update', 'security', 'other']
+    valid_keys = ['title', 'type', 'issues', 'links', 'important_notes', 
'modules', 'authors']
+    deprecated_keys = ['merge_requests', 'configurations']
 
     try:
         with open(file_path, 'r', encoding='utf-8') as f:
@@ -45,6 +51,18 @@ def validate_changelog_yaml(file_path):
             print(f"::error file={file_path}::File must contain YAML mapping 
(key-value pairs)")
             return False
 
+        # Check for invalid top-level keys
+        for key in data.keys():
+            if key not in valid_keys and key not in deprecated_keys:
+                print(f"::error file={file_path}::Invalid top-level key 
'{key}'. Valid keys are: {', '.join(valid_keys)}")
+                return False
+
+        # Check for deprecated keys
+        for deprecated_key in deprecated_keys:
+            if deprecated_key in data:
+                print(f"::error file={file_path}::Our project does not use the 
'{deprecated_key}' yaml key, please remove")
+                return False
+
         # Validate 'title' field
         if 'title' not in data or not data['title']:
             print(f"::error file={file_path}::Missing or empty 'title' field")
@@ -88,6 +106,20 @@ def validate_changelog_yaml(file_path):
                 print(f"::error file={file_path}::Author {i} 'name' must be a 
non-empty string")
                 return False
 
+        # Validate that either 'links' or 'issues' exists (or both)
+        if 'links' not in data and 'issues' not in data:
+            print(f"::error file={file_path}::Must contain either 'links' or 
'issues' key (or both)")
+            return False
+
+        # Validate 'issues' field if present
+        if 'issues' in data:
+            if not isinstance(data['issues'], int):
+                print(f"::error file={file_path}::Field 'issues' must be an 
integer")
+                return False
+            if data['issues'] > 17000:
+                print(f"::error file={file_path}::Field 'issues' value 
{data['issues']} points to a non-existing github PR. Did you intend to 
reference a JIRA issue, please use 'links'.")
+                return False
+
         # All validations passed
         print(f"✓ {file_path} is valid")
         print(f"  Title: {data['title']}")
diff --git a/.github/workflows/validate-changelog.yml 
b/.github/workflows/validate-changelog.yml
index e5b7cf158ac..495b23802db 100644
--- a/.github/workflows/validate-changelog.yml
+++ b/.github/workflows/validate-changelog.yml
@@ -8,6 +8,8 @@ on:
 jobs:
   validate-changelog:
     name: Check changelog entry
+    # Skip validation for Renovate PRs (solrbot) - they get changelog entries 
automatically
+    if: github.event.pull_request.user.login != 'solrbot'
     runs-on: ubuntu-latest
 
     steps:
diff --git a/dev-tools/scripts/releaseWizard.yaml 
b/dev-tools/scripts/releaseWizard.yaml
index 8cf0ab118c6..4f60085e7d9 100644
--- a/dev-tools/scripts/releaseWizard.yaml
+++ b/dev-tools/scripts/releaseWizard.yaml
@@ -674,7 +674,11 @@ groups:
   - !Todo
     id: dependency_updates_changes
     title: Add dependency updates to changelog
-    description: Bulk add all 'solrbot' dependency updates since last release
+    description: |
+      Bulk add all 'solrbot' dependency updates since last release.
+      NOTE: Work in progress to let each Solrbot PR add its own changes to 
changelog.
+      This step will be removed once that is done.
+      Until then, there may be a mix of PRs with and without changes to 
changelog.
     depends: clean_git_checkout
     commands: !Commands
       root_folder: '{{ git_checkout_folder }}'

Reply via email to