blob: e9d123b69808fe6ebae37ee77686dc1e7204a343 [file] [log] [blame]
# Copyright 2025 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Automates the generation of release notes from version control history.
1) Finds the 2 most recent release branches and extracts the git commits
and parses them.
2) Asks you where they are categorized as.
3) Generates an html page using a few templates.
4) Opens the browser.
You can then just copy-paste it into the email..
To run the command - inside a checked out hdctools repo:
python3 servo/dockerfiles/release_notes_generator.py
"""
from collections import namedtuple
import datetime
import html
import re
import subprocess
import sys
import tempfile
def find_branches():
"""Finds the two most recent hdctools-release branches from git.
It fetches all remote branches, filters for those matching the pattern
"origin/hdctools-release-MMYY...", sorts them by year and month in
descending order, and returns the names of the two most recent ones.
Returns:
tuple: A tuple containing two strings:
(newest_branch_name, second_newest_branch_name)
"""
args = ["git", "branch", "-r"]
git_branch_lines = subprocess.check_output(args).decode().splitlines()
reg = r"(cros|origin)/hdctools-release-(?P<month>\d\d)(?P<year>\d\d)\S*"
values = [re.search(reg, x) for x in git_branch_lines]
values = [x for x in values if x]
values.sort(reverse=True, key=lambda x: x.group("year") + x.group("month"))
values = [x.group() for x in values]
return values[0], values[1]
def load_commits(commits_new_branch, commits_last_branch, cwd=None):
"""Loads git commits between two specified branches.
It uses `git log` to retrieve the commit hash, subject (description),
and author name for commits in the range `last_branch...new_branch`.
Each commit's information is parsed and stored in a Commit namedtuple.
Args:
commits_new_branch (str): The newer branch name (or commit reference) to
end the log at (inclusive).
commits_last_branch (str): The older branch name (or commit reference) to
start the log from (exclusive).
cwd (str, optional): The current working directory to run git commands.
Defaults to None.
Returns:
list: A list of Commit namedtuples, where each tuple contains
(hash, desc, author).
"""
delim = "␟"
args = [
"git",
"log",
f"--pretty=format:%H{delim}%s{delim}%aN",
f"{commits_last_branch}...{commits_new_branch}",
]
response = subprocess.check_output(args, cwd=cwd).decode()
Commit = namedtuple("Commit", ["hash", "desc", "author"])
loaded_commits = []
for line in response.splitlines():
loaded_commits.append(Commit(*line.split(delim)))
return loaded_commits
def organize_help(help_headers):
"""Prints help message showing available categories for organizing commits.
Args:
help_headers (list): A list of strings, where each string is a category name.
"""
print("Enter the number to organize the commit.")
for i, name in enumerate(help_headers):
print(f"{i}:{name}")
def handle_response(response_headers):
"""Handles user input for selecting a commit category.
Prompts the user for input, attempts to convert it to an integer,
and validates if it's a valid index for the provided headers list.
Args:
response_headers (list): A list of strings, representing available category
names.
Returns:
str or None: The selected category name (string) if the input is valid,
otherwise None.
"""
response = "Invalid"
try:
response = input()
index = int(response)
if 0 <= index < len(response_headers):
return response_headers[index]
except (ValueError, KeyboardInterrupt):
pass
print(f"Invalid response: {repr(response)}")
return None
def organize_commits(loaded_commits, commit_headers):
"""Interactively organizes a list of commits into predefined categories.
For each commit, it prints the commit description and author, then prompts
the user to select a category from the provided headers. This process
repeats until all commits are categorized.
Args:
loaded_commits (list): A list of Commit namedtuples to be organized.
commit_headers (list): A list of strings, where each string is a category name
to organize commits into.
Returns:
dict: A dictionary where keys are category names (from headers) and
values are lists of Commit namedtuples belonging to that category.
"""
organized_commits = {x: [] for x in commit_headers}
organize_help(commit_headers)
for loaded_commit in loaded_commits:
while True:
print(f"{loaded_commit.desc: <60}\t({loaded_commit.author})")
key = handle_response(commit_headers)
if key is not None:
organized_commits[key].append(loaded_commit)
break
organize_help(commit_headers)
return organized_commits
def format_header(name):
"""Formats a category header name into an HTML string.
Escapes the name for HTML safety and wraps it in specific HTML
tags with styling for display in a document.
Args:
name (str): The category header name.
Returns:
str: An HTML string representing the formatted header.
"""
name = html.escape(name)
template = (
"<br>"
'<p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;'
'"><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
"color:#000000;background-color:transparent;font-weight:700;"
"font-style:normal;font-variant:normal;text-decoration:none;"
'vertical-align:baseline;white-space:pre;white-space:pre-wrap;">'
f"{name}"
'</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
"color:#000000;background-color:transparent;font-weight:400;"
"font-style:normal;font-variant:normal;text-decoration:none;"
"vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
'"><span class="Apple-tab-span" style="white-space:pre;">'
' </span></span><span style="font-size:10.5pt;'
"font-family:Roboto,sans-serif;color:#000000;"
"background-color:transparent;font-weight:400;font-style:normal;"
"font-variant:normal;text-decoration:none;vertical-align:baseline;"
'white-space:pre;white-space:pre-wrap;"><span class="Apple-tab-span" '
'style="white-space:pre;"> </span></span></p>'
)
return template
def format_commit(commit):
"""Formats a single commit's details into an HTML string.
Extracts the short hash, description, and author from the commit.
It creates a clickable link to the commit on googlesource.com using
the full hash. All parts are HTML-escaped and styled.
Args:
commit (namedtuple): A Commit namedtuple with 'hash', 'desc',
and 'author' attributes.
Returns:
str: An HTML string representing the formatted commit.
"""
short_hash = html.escape(f"{commit.hash[:8]}")
url = html.escape(f"https://chromium-review.googlesource.com/q/{commit.hash}")
desc = html.escape(commit.desc)
author = html.escape(f"({commit.author})")
template = (
'<p dir="ltr" style="line-height:1.38;margin-top:0pt;margin-bottom:0pt;'
'"><a href="'
f"{url}"
'" style="text-decoration:none;"><span style="font-size:9pt;'
"font-family:'Roboto Mono',monospace;color:#1155cc;"
"background-color:transparent;font-weight:400;font-style:normal;"
"font-variant:normal;text-decoration:underline;"
"-webkit-text-decoration-skip:none;text-decoration-skip-ink:none;"
'vertical-align:baseline;white-space:pre;white-space:pre-wrap;">'
f"{short_hash}"
'</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
"color:#000000;background-color:transparent;font-weight:400;"
"font-style:normal;font-variant:normal;text-decoration:none;"
"vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
'"><span class="Apple-tab-span" style="white-space:pre;'
'"> </span></span></a><span style="font-size:10.5pt;'
"font-family:Roboto,sans-serif;color:#000000;"
"background-color:transparent;font-weight:400;font-style:normal;"
"font-variant:normal;text-decoration:none;vertical-align:baseline;"
'white-space:pre;white-space:pre-wrap;">'
f"{desc}"
'</span><span style="font-size:10.5pt;font-family:Roboto,sans-serif;'
"color:#000000;background-color:transparent;font-weight:400;"
"font-style:normal;font-variant:normal;text-decoration:none;"
"vertical-align:baseline;white-space:pre;white-space:pre-wrap;"
'"><span class="Apple-tab-span" style="white-space:pre;'
'"> </span></span><span style="font-size:10.5pt;'
"font-family:Roboto,sans-serif;color:#000000;"
"background-color:transparent;font-weight:400;font-style:normal;"
"font-variant:normal;text-decoration:none;vertical-align:baseline;"
'white-space:pre;white-space:pre-wrap;">'
f"{author}"
"</span></p>"
)
return template
def create_temp_file(report_lines):
"""Creates a temporary HTML file, writes lines to it, and opens it.
The file is created with a '.html' suffix and is not deleted automatically
on close (delete=False), allowing an external program ('open') to access it.
Args:
report_lines (list): A list of strings, where each string is a line of HTML
content.
"""
tmp = tempfile.NamedTemporaryFile(suffix=".html", delete=False)
with tmp as f:
f.write("\n".join(report_lines).encode())
subprocess.call(["open", tmp.name])
def main():
"""Main function to generate release notes."""
headers = [
"CL List",
"Features",
"Bug Fixes",
"Docs",
"Data",
"CI / Infrastructure",
]
print("Fetching the latest remote branches...")
subprocess.run(["git", "fetch"], check=True)
new_branch, last_branch = find_branches()
today = datetime.date.today()
branch_date_match = re.search(
r"hdctools-release-(?P<month>\d\d)(?P<year>\d\d)", new_branch
)
if branch_date_match:
branch_month = int(branch_date_match.group("month"))
branch_year = int(branch_date_match.group("year"))
current_month = today.month
current_year = int(today.strftime("%y"))
if branch_month != current_month or branch_year != current_year:
print(
f"\nError: The newest branch '{new_branch}' does not match the "
f"current month and year ({current_month:02d}{current_year:02d})."
)
print(
"Please ensure a new release branch has been created and 'git fetch' was successful."
)
sys.exit(1)
commits = load_commits(new_branch, last_branch)
organized = organize_commits(commits, headers)
lines = []
for header, commits_list in organized.items():
if commits_list: # Only add header if there are commits in the category
lines.append(format_header(header))
for c in commits_list:
lines.append(format_commit(c))
create_temp_file(lines)
print("\nRelease notes HTML file has been generated and opened in your browser.")
if __name__ == "__main__":
main()