blob: 3286d03cac403d81d3c9d303208c956ba3209241 [file]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Script to create changelog YAML entries for solrbot dependency updates
"""
import os
import sys
sys.path.append(os.path.dirname(__file__))
from scriptutil import *
import argparse
import re
import yaml
from pathlib import Path
line_re = re.compile(r"(.*?) (\(branch_\d+x\) )?\(#(\d+)\)$")
class ChangeEntry:
"""
Represents one dependency change entry.
Fields:
- pr_num: string PR number (e.g., '3605')
- message: cleaned commit message text to be shown in CHANGES (noise removed)
- author: Git author (e.g., 'solrbot')
"""
def __init__(self, pr_num: str, message: str, author: str):
self.pr_num = pr_num
self.message = message
self.author = author
def dep_key(self) -> str:
"""
Extract a dependency key from the message after 'Update ' and before ' to', '(' or end.
This is used for de-duplication and sorting. Case-insensitive.
"""
m = re.search(r"(?i)update\s+(.+?)(?:\s+to\b|\s*\(|$)", self.message)
if m:
return m.group(1).strip()
return self.message.strip()
def __str__(self) -> str:
# Keep trailing newline to preserve existing blank-line formatting by update_changes
return f"* PR#{self.pr_num}: {self.message} ({self.author})\n"
def to_yaml_dict(self) -> dict:
"""
Convert to a dictionary suitable for YAML serialization.
Extracts JIRA IDs from the title and adds them to links.
"""
# Extract JIRA IDs from the message
title, jira_links = extract_jira_issues_from_title(self.message)
# Build links: JIRA issues first, then PR
links = jira_links.copy() # Start with JIRA links
links.append({
'name': f'PR#{self.pr_num}',
'url': f'https://github.com/apache/solr/pull/{self.pr_num}'
})
return {
'title': title,
'type': 'dependency_update',
'authors': [
{
'name': self.author
}
],
'links': links
}
def yaml_filename(self) -> str:
"""
Generate a filesystem-safe filename for this entry.
Format: PR#####-slug.yaml
Truncates slug on whitespace boundaries, allowing up to 255 chars total.
"""
# Clean message for slug
slug = self.message.lower()
# Replace whitespace with single space
slug = re.sub(r'\s+', ' ', slug)
# Remove non-alphanumeric except dashes
slug = re.sub(r'[^a-z0-9-._ ]', '', slug)
# Calculate available space for slug
# Format: "PR" + pr_num + "-" + slug + ".yaml"
# Typical PR#1234 = 8 chars + "-" = 9 chars, ".yaml" = 5 chars, total overhead = 14 chars
# Most filesystems limit filenames to 255 chars
max_filename_length = 255
overhead = len(f"PR{self.pr_num}-.yaml")
max_slug_length = max_filename_length - overhead
# Truncate to max length on word boundaries if necessary
if len(slug) > max_slug_length:
# Find the last space within the limit
truncated = slug[:max_slug_length]
last_dash = truncated.rfind(' ')
if last_dash > max_slug_length // 2: # Keep at least half the available space
slug = truncated[:last_dash]
else:
# If no good word boundary, use hard limit and clean up trailing spaces
slug = truncated.rstrip(' ')
else:
# Remove trailing spaces
slug = slug.rstrip(' ')
return f"PR{self.pr_num}-{slug}.yaml"
def get_prev_release_tag(ver):
"""
Based on a given version, compute the git tag for the "previous" version to calculate changes since.
For a major version, we want all solrbot commits since last release, i.e. X-1.Y.Z
For a minor version, we want all solrbot commits since X.Y-1.0
For a patch version, we want all solrbot commits since X.Y.Z-1
"""
releases_arr = run('git tag |grep "releases/solr" | cut -c 15-').strip().split("\n")
releases = list(map(lambda x: Version.parse(x), releases_arr))
if ver.is_major_release():
last = releases.pop()
return "releases/solr/%s" % last.dot
if ver.is_minor_release():
return "releases/solr/%s.%s.0" % (ver.major, ver.minor - 1)
if ver.is_bugfix_release():
return "releases/solr/%s.%s.%s" % (ver.major, ver.minor, ver.bugfix - 1)
return None
def read_config():
parser = argparse.ArgumentParser(description='Adds changelog entries in changelog/ folder')
parser.add_argument('--version', type=Version.parse, help='Solr version to add changes to', required=True)
parser.add_argument('--user', default='solrbot', help='Git user to get changes for. Defaults to solrbot')
newconf = parser.parse_args()
return newconf
def get_gitlog_lines(user: str, prev_tag: str):
"""
Run git log and return a list of raw subject lines (%s), newest first.
"""
output = run('git log --author=' + user + ' --oneline --no-merges --pretty=format:"%s" ' + prev_tag + '..')
return list(filter(None, output.split("\n")))
def parse_gitlog_lines(lines, author: str):
"""
Parse raw git log subject lines into ChangeEntry objects.
- Extract pr_num and message
- Strip '(branch_Nx)' noise
- Remove optional leading 'chore(deps): '
- Normalize 'update dependency X' to 'Update X' (case-insensitive)
"""
entries = []
for line in lines:
match = line_re.search(line)
if not match:
print("Skipped un-parsable line: %s" % line)
continue
text = match.group(1)
pr_num = match.group(3)
# Clean message noise
msg = text
msg = re.sub(r"^chore\(deps\):\s*", "", msg, flags=re.IGNORECASE)
# Normalize 'update dependency' to 'Update '
msg = re.sub(r"(?i)^update\s+dependenc(y|ies)\s+", "Update ", msg)
entries.append(ChangeEntry(pr_num=pr_num, message=msg, author=author))
return entries
def write_changelog_yaml(entries):
"""
Write each ChangeEntry to a YAML file in changelog/unreleased/
"""
changelog_dir = Path('changelog/unreleased')
# Create directory if it doesn't exist
changelog_dir.mkdir(parents=True, exist_ok=True)
count = 0
for entry in entries:
filename = changelog_dir / entry.yaml_filename()
yaml_data = entry.to_yaml_dict()
# Write YAML file with proper formatting
with open(filename, 'w') as f:
yaml.dump(yaml_data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
print(f"Created: {filename}")
count += 1
return count
def dedupe_entries(entries):
"""
De-duplicate dependency update entries (newest first input) by dep_key.
Keeps the first occurrence for each dependency key.
"""
seen = set()
result = []
for e in entries:
key = e.dep_key().lower()
if key not in seen:
seen.add(key)
result.append(e)
return result
def sort_entries(entries):
"""Return a new list sorted alphabetically by dependency key (case-insensitive)."""
return sorted(entries, key=lambda e: e.dep_key().lower())
def main():
if not os.path.exists('CHANGELOG.md'):
sys.exit("Tool must be run from the root of a source checkout.")
newconf = read_config()
prev_tag = get_prev_release_tag(newconf.version)
print("Creating changelog YAML entries for dependency updates since git tag %s" % prev_tag)
try:
gitlog_lines = get_gitlog_lines(newconf.user, prev_tag)
entries = parse_gitlog_lines(gitlog_lines, author=newconf.user)
if entries:
deduped = dedupe_entries(entries)
sorted_entries = sort_entries(deduped)
count = write_changelog_yaml(sorted_entries)
print(f"Successfully created {count} changelog YAML entries")
else:
print("No changes found for version %s" % newconf.version.dot)
print("Done")
except subprocess.CalledProcessError:
print("Error running git log - check your --version")
sys.exit(1)
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print('\nReceived Ctrl-C, exiting early')