Reland "Add in the script to generate the monthly release notes for docker."
This reverts commit 9e1660e00ed725b3011998bcc87d9f1c2453a4c8.
Reason for revert: Checking - some CQ builds passed after the one that failed so perhaps just a flake.
Original change's description:
> Revert "Add in the script to generate the monthly release notes for docker."
>
> This reverts commit 1919dbe46923ec3ad43c27f4cb7feb3cf57b59c7.
>
> Reason for revert: this blocks our CQ
>
> ==================================== ERRORS ====================================
> ______ ERROR collecting servo/tests/unit/test_release_notes_generator.py _______
> ImportError while importing test module '/hdctools/servo/tests/unit/test_release_notes_generator.py'.
> Hint: make sure your test modules/packages have valid Python names.
> Traceback:
> /usr/lib/python3.11/importlib/__init__.py:126: in import_module
> return _bootstrap._gcd_import(name[level:], package, level)
> /hdctools/servo/tests/unit/test_release_notes_generator.py:12: in <module>
> from servo.dockerfiles.release_notes_generator import create_temp_file
> E ModuleNotFoundError: No module named 'servo.dockerfiles'
> =============================== warnings summary ===============================
>
> Original change's description:
> > Add in the script to generate the monthly release notes for docker.
> >
> > BUG=b:416323226
> > TEST=pytest servo/tests/unit/test_release_notes_generator.py
> >
> > Change-Id: Ie96e762979aa43602b4bc543df6dfd4b54ec0bd9
> > Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/hdctools/+/6521039
> > Reviewed-by: Michał Headcrab Barnaś <barnas@google.com>
> > Commit-Queue: Łukasz Hajec <hajec@google.com>
> > Reviewed-by: 488603086791@cloudbuild.gserviceaccount.com <488603086791@cloudbuild.gserviceaccount.com>
> > Reviewed-by: Łukasz Hajec <hajec@google.com>
> > Auto-Submit: Keith Haddow <haddowk@chromium.org>
> > Tested-by: Keith Haddow <haddowk@chromium.org>
>
> BUG=b:416323226
>
> Change-Id: Ia29249176e1255e4a31281b9dca7eff1d5e5d442
> Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/hdctools/+/6641376
> Commit-Queue: Keith Haddow <haddowk@chromium.org>
> Tested-by: Keith Haddow <haddowk@chromium.org>
> Reviewed-by: 488603086791@cloudbuild.gserviceaccount.com <488603086791@cloudbuild.gserviceaccount.com>
> Reviewed-by: Keith Haddow <haddowk@chromium.org>
BUG=b:416323226
Change-Id: I194d3d093b4f6b4ba55787e9444bceb84faaa09a
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/third_party/hdctools/+/6639247
Reviewed-by: 488603086791@cloudbuild.gserviceaccount.com <488603086791@cloudbuild.gserviceaccount.com>
Tested-by: Keith Haddow <haddowk@chromium.org>
Reviewed-by: Łukasz Hajec <hajec@google.com>
Commit-Queue: Łukasz Hajec <hajec@google.com>
Auto-Submit: Keith Haddow <haddowk@chromium.org>
diff --git a/pyproject.toml b/pyproject.toml
index 02dfb0a..613abc4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,3 +10,8 @@
.*/servo/data/.*
)
'''
+
+[tool.pytest.ini_options]
+pythonpath = [
+ ".",
+]
diff --git a/servo/dockerfiles/cloudbuild.yaml b/servo/dockerfiles/cloudbuild.yaml
index 876eddb..a1c41a7 100644
--- a/servo/dockerfiles/cloudbuild.yaml
+++ b/servo/dockerfiles/cloudbuild.yaml
@@ -31,8 +31,23 @@
waitFor: ['src-prep']
- name: 'us-docker.pkg.dev/$PROJECT_ID/servod/servod:$COMMIT_SHA'
+ id: 'test-prep'
+ entrypoint: 'bash'
+ args:
+ - '-c'
+ - |
+ set -x
+ cp /usr/local/lib/python3.11/dist-packages/servo/proto/servo_dev_info.textproto /workspace/servo/proto/
+ cp /usr/local/lib/python3.11/dist-packages/servo/proto/servo_dev_pb2.py /workspace/servo/proto/
+ cp /usr/local/lib/python3.11/dist-packages/servo/data/*_inas.xml /workspace/servo/data/
+
+ waitFor: ['build']
+
+- name: 'us-docker.pkg.dev/$PROJECT_ID/servod/servod:$COMMIT_SHA'
id: 'test-python'
entrypoint: pytest
+ env:
+ - PYTHONPATH=/workspace/
args:
- -n=auto
- --forked
@@ -40,8 +55,9 @@
- --cov=/usr/local/lib/python3.11/dist-packages/servo
- --cov=/usr/local/lib/python3.11/dist-packages/usbkm232
- --cov=/usr/local/lib/python3.11/dist-packages/ec3po
- - /hdctools/
- waitFor: ['build']
+ - /workspace/
+ waitFor: ['test-prep']
+
- name: 'pyfound/black:24.2.0'
id: 'test-black-formatting'
diff --git a/servo/dockerfiles/release_notes_generator.py b/servo/dockerfiles/release_notes_generator.py
new file mode 100644
index 0000000..ebfbcb3
--- /dev/null
+++ b/servo/dockerfiles/release_notes_generator.py
@@ -0,0 +1,287 @@
+# 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 - in a checked out hdctools repo:
+
+git pull
+git switch hdctools-release-$(date +"%m%y").1
+
+python3 servo/dockerfiles/release_notes_generator.py
+
+"""
+
+from collections import namedtuple
+import html
+import re
+import subprocess
+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"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:
+ 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():
+ headers = [
+ "CL List",
+ "Features",
+ "Bug Fixes",
+ "Docs",
+ "Data",
+ "CI / Infrastructure",
+ ]
+
+ new_branch, last_branch = find_branches()
+
+ commits = load_commits(new_branch, last_branch)
+ organized = organize_commits(commits, headers)
+
+ lines = []
+
+ for header, commits in organized.items():
+ lines.append(format_header(header))
+ for c in commits:
+ lines.append(format_commit(c))
+
+ create_temp_file(lines)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/servo/tests/unit/test_release_notes_generator.py b/servo/tests/unit/test_release_notes_generator.py
new file mode 100644
index 0000000..665e0e8
--- /dev/null
+++ b/servo/tests/unit/test_release_notes_generator.py
@@ -0,0 +1,311 @@
+# 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.
+
+from collections import namedtuple
+import html as html_stdlib # Alias to avoid conflict with pytest's html module
+
+import pytest
+
+# Assuming release_notes_generator.py is in the same directory or accessible via
+# PYTHONPATH
+from servo.dockerfiles.release_notes_generator import create_temp_file
+from servo.dockerfiles.release_notes_generator import find_branches
+from servo.dockerfiles.release_notes_generator import format_commit
+from servo.dockerfiles.release_notes_generator import format_header
+from servo.dockerfiles.release_notes_generator import handle_response
+from servo.dockerfiles.release_notes_generator import load_commits
+from servo.dockerfiles.release_notes_generator import organize_commits
+from servo.dockerfiles.release_notes_generator import organize_help
+
+
+# Define a Commit namedtuple similar to the one used in the script for test data
+Commit = namedtuple("Commit", ["hash", "desc", "author"])
+
+
+class TestFindBranches:
+ def test_find_branches_success(self, mocker):
+ """Tests find_branches successfully identifies and sorts branches."""
+ mock_output = (
+ " origin/hdctools-release-0123-foo\n" # year 23, month 01
+ " origin/hdctools-release-1222-bar\n" # year 22, month 12
+ " origin/hdctools-release-0323-baz\n" # year 23, month 03
+ " origin/other-branch\n"
+ ).encode()
+ mock_check_output = mocker.patch(
+ "subprocess.check_output", return_value=mock_output
+ )
+
+ # Sort key is year + month, descending.
+ # "23" + "03" -> "2303"
+ # "23" + "01" -> "2301"
+ # "22" + "12" -> "2212"
+ # Expected order: origin/hdctools-release-0323-baz,
+ # origin/hdctools-release-0123-foo
+ expected_new_branch = "origin/hdctools-release-0323-baz"
+ expected_last_branch = "origin/hdctools-release-0123-foo"
+
+ new_branch, last_branch = find_branches()
+ assert new_branch == expected_new_branch
+ assert last_branch == expected_last_branch
+ mock_check_output.assert_called_once_with(["git", "branch", "-r"])
+
+ def test_find_branches_insufficient_matches(self, mocker):
+ """Tests find_branches raises IndexError if less than two matches."""
+ mock_output_one_match = " origin/hdctools-release-0123-foo\n".encode()
+ mocker.patch("subprocess.check_output", return_value=mock_output_one_match)
+ with pytest.raises(IndexError):
+ find_branches()
+
+ mock_output_no_match = " origin/other-branch\n".encode()
+ mocker.patch("subprocess.check_output", return_value=mock_output_no_match)
+ with pytest.raises(IndexError):
+ find_branches()
+
+ class TestLoadCommits:
+ DELIM = "␟"
+
+ def test_load_commits_success(self, mocker):
+ """Tests load_commits parses git log output correctly."""
+ mock_output = (
+ f"hash1{self.DELIM}Desc 1{self.DELIM}Author One\n"
+ f"hash2{self.DELIM}Desc 2{self.DELIM}Author Two\n"
+ ).encode()
+ mock_subprocess = mocker.patch(
+ "subprocess.check_output", return_value=mock_output
+ )
+
+ commits_list = load_commits("new_branch", "last_branch", cwd=".")
+
+ assert len(commits_list) == 2
+ assert commits_list[0] == Commit(
+ hash="hash1", desc="Desc 1", author="Author One"
+ )
+ assert commits_list[1] == Commit(
+ hash="hash2", desc="Desc 2", author="Author Two"
+ )
+
+ expected_args = [
+ "git",
+ "log",
+ f"--pretty=format:%H{self.DELIM}%s{self.DELIM}%aN",
+ "last_branch...new_branch",
+ ]
+ mock_subprocess.assert_called_once_with(expected_args, cwd=".")
+
+ def test_load_commits_no_output(self, mocker):
+ """Tests load_commits returns an empty list for no git log output."""
+ mocker.patch("subprocess.check_output", return_value=b"")
+ commits_list = load_commits("new", "old")
+ assert not commits_list
+
+ def test_organize_help_prints_correctly(self, capsys):
+ """Tests organize_help prints the expected help message."""
+ headers = ["Features", "Bug Fixes"]
+ organize_help(headers)
+ captured = capsys.readouterr()
+ expected_output = (
+ "Enter the number to organize the commit.\n0:Features\n1:Bug Fixes\n"
+ )
+ assert captured.out == expected_output
+
+ class TestHandleResponse:
+ HEADERS = ["Features", "Bug Fixes", "Docs"]
+
+ def test_valid_input(self, mocker):
+ mocker.patch("builtins.input", return_value="1")
+ response = handle_response(self.HEADERS)
+ assert response == "Bug Fixes"
+
+ def test_invalid_index_too_high(self, mocker, capsys):
+ mocker.patch("builtins.input", return_value="5")
+ response = handle_response(self.HEADERS)
+ assert response is None
+ assert "Invalid response: '5'" in capsys.readouterr().out
+
+ def test_invalid_index_negative(self, mocker, capsys):
+ mocker.patch("builtins.input", return_value="-1")
+ response = handle_response(self.HEADERS)
+ assert response is None
+ assert "Invalid response: '-1'" in capsys.readouterr().out
+
+ def test_non_numeric_input(self, mocker, capsys):
+ mocker.patch("builtins.input", return_value="abc")
+ response = handle_response(self.HEADERS)
+ assert response is None
+ assert "Invalid response: 'abc'" in capsys.readouterr().out
+
+ def test_keyboard_interrupt(self, mocker, capsys):
+ mocker.patch("builtins.input", side_effect=KeyboardInterrupt)
+ response = handle_response(self.HEADERS)
+ assert response is None
+ assert "Invalid response: Invalid" not in capsys.readouterr().out
+
+ class TestOrganizeCommits:
+ HEADERS = ["Features", "Fixes", "Chores"]
+ COMMITS_DATA = [
+ Commit(hash="h1", desc="Feature A", author="User1"),
+ Commit(hash="h2", desc="Fix B", author="User2"),
+ Commit(hash="h3", desc="Chore C", author="User1"),
+ ]
+
+ def test_basic_flow(self, mocker, capsys):
+ mock_org_help = mocker.patch(
+ "servo.dockerfiles.release_notes_generator.organize_help"
+ )
+ # Simulate user inputs: "Features", then "Fixes", then "Chores"
+ mock_handle_resp = mocker.patch(
+ "servo.dockerfiles.release_notes_generator.handle_response",
+ side_effect=["Features", "Fixes", "Chores"],
+ )
+
+ organized = organize_commits(self.COMMITS_DATA, self.HEADERS)
+
+ assert mock_org_help.call_count >= 1 # Called at least at the start
+ mock_org_help.assert_any_call(self.HEADERS)
+
+ assert mock_handle_resp.call_count == 3
+ for unused_header in self.HEADERS:
+ mock_handle_resp.assert_any_call(self.HEADERS)
+
+ captured = capsys.readouterr().out
+ assert f"{'Feature A': <60}\t(User1)" in captured
+ assert f"{'Fix B': <60}\t(User2)" in captured
+ assert f"{'Chore C': <60}\t(User1)" in captured
+
+ assert len(organized["Features"]) == 1
+ assert organized["Features"][0].desc == "Feature A"
+ assert len(organized["Fixes"]) == 1
+ assert organized["Fixes"][0].desc == "Fix B"
+ assert len(organized["Chores"]) == 1
+ assert organized["Chores"][0].desc == "Chore C"
+
+ def test_invalid_input_retry(
+ self, mocker, capsys
+ ): # pylint: disable=unused-argument
+ mock_org_help = mocker.patch(
+ "servo.dockerfiles.release_notes_generator.organize_help"
+ )
+ # Simulate: invalid input, then valid "Features"
+ mock_handle_resp = mocker.patch(
+ "servo.dockerfiles.release_notes_generator.handle_response",
+ side_effect=[None, "Features"],
+ )
+ single_commit = [self.COMMITS_DATA[0]]
+
+ organized = organize_commits(single_commit, self.HEADERS)
+
+ # organize_help called at start, then again after invalid input
+ assert mock_org_help.call_count == 2
+ assert mock_handle_resp.call_count == 2
+
+ assert len(organized["Features"]) == 1
+ assert organized["Features"][0].desc == "Feature A"
+
+ def test_format_header_output(self):
+ """Tests the HTML output of format_header."""
+ name = "New Features & Updates"
+ escaped_name = html_stdlib.escape(name)
+ # This is brittle, but necessary if the exact HTML is required.
+ expected_html = (
+ "<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"{escaped_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>'
+ )
+ assert format_header(name) == expected_html
+
+
+def test_format_commit_output():
+ """Tests the HTML output of format_commit."""
+ commit = Commit(
+ hash="abcdef123456",
+ desc="Implement cool feature & stuff",
+ author="Dev Person <dev@example.com>",
+ )
+
+ short_hash = html_stdlib.escape(commit.hash[:8])
+ url = html_stdlib.escape(
+ f"https://chromium-review.googlesource.com/q/{commit.hash}"
+ )
+ desc = html_stdlib.escape(commit.desc)
+ author_text = html_stdlib.escape(f"({commit.author})")
+
+ expected_html = (
+ '<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_text}"
+ "</span></p>"
+ )
+ assert format_commit(commit) == expected_html
+
+
+def test_create_temp_file_functionality(mocker):
+ """Tests create_temp_file writes to a temp file and calls 'open'."""
+ mock_named_temp_file = mocker.patch("tempfile.NamedTemporaryFile")
+ mock_subprocess_call = mocker.patch("subprocess.call")
+
+ # Mock the file object returned by NamedTemporaryFile
+ mock_file_obj = mocker.MagicMock()
+ mock_file_context_manager = mocker.MagicMock()
+ mock_file_context_manager.__enter__.return_value = mock_file_obj
+ mock_file_context_manager.name = "placeholder_temp_file.html"
+ mock_named_temp_file.return_value = mock_file_context_manager
+
+ report_lines = ["<h1>Title</h1>", "<p>Content</p>"]
+ create_temp_file(report_lines)
+
+ mock_named_temp_file.assert_called_once_with(suffix=".html", delete=False)
+ mock_file_obj.write.assert_called_once_with(
+ "<h1>Title</h1>\n<p>Content</p>".encode()
+ )
+ mock_subprocess_call.assert_called_once_with(["open", "placeholder_temp_file.html"])
+
+ # Note: The main execution block of release_notes_generator.py is not unit tested
+ # here, as it involves direct calls to these functions in sequence.
+ # Testing each function individually provides good coverage.
+ # An integration test could be written for the main block if desired.# In
+ # release_notes_generator.py