blob: 82ac3f23ac5e46c21c1d9a5bb7e123477c3f5f0d [file]
# Copyright 2026 The Pigweed Authors
#
# Licensed 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
#
# https://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.
"""End command for the changelog scripts."""
import common
MONTH_NAMES = {
"01": "January",
"02": "February",
"03": "March",
"04": "April",
"05": "May",
"06": "June",
"07": "July",
"08": "August",
"09": "September",
"10": "October",
"11": "November",
"12": "December",
}
def _get_month_name(month: str) -> str:
"""Returns the full name of the month or the string itself."""
return MONTH_NAMES.get(month, month)
def _build_month_header(year: str, month: str) -> list[str]:
"""Generates the top-level ReST header for the month."""
date_str = f"{_get_month_name(month)} {year}"
title = f"What's new in Pigweed: {date_str}"
rst = []
rst.append("=" * len(title))
rst.append(title)
rst.append("=" * len(title))
rst.append("")
return rst
def _section_id(category: str, story_id: str, year: str, month: str) -> str:
"""Generates a unique ReST section reference ID for a story."""
safe_category = category.replace('_', '-')
safe_story = story_id.replace('_', '-')
return f"changelog-{year}-{month}-{safe_category}-{safe_story}"
def _get_highlights(
data: dict,
ignore_list: list | None = None,
) -> list[dict]:
"""Extracts and sorts the highlighted stories."""
highlight_stories = []
ignore_list = ignore_list or []
stories = data.get("stories", {})
for category, category_stories in stories.items():
if category in ignore_list:
continue
if not isinstance(category_stories, dict):
continue
for story_key, story in category_stories.items():
if not isinstance(story, dict):
continue
highlight = story.get("highlight", "")
final_score = story.get("score", 0)
if highlight and highlight.strip() and final_score >= 750:
highlight_stories.append(
{
"score": final_score,
"category": category,
"story_key": story_key,
"title": story.get("title", story_key),
"highlight": highlight.strip(),
}
)
highlight_stories.sort(key=lambda x: x["score"], reverse=True)
return highlight_stories
def _build_highlights_section(
highlights: list[dict],
year: str,
month: str,
categories_map: dict,
) -> list[str]:
"""Generates the Highlights section of the changelog."""
rst = []
rst.append(".. changelog_highlights_start")
rst.append("")
rst.append("Highlights:")
rst.append("")
for item in highlights:
category_name = categories_map.get(item["category"]) or item["category"]
sid = _section_id(item["category"], item["story_key"], year, month)
lines = item["highlight"].split('\n')
if lines:
rst.append(f"* {category_name}: :ref:`{sid}` - {lines[0].lstrip()}")
for line in lines[1:]:
if line.strip():
rst.append(f" {line.rstrip()}")
else:
rst.append("")
rst.append("")
rst.append(".. changelog_highlights_end")
rst.append("")
return rst
def _category_sort_key(category: str) -> tuple[int, str]:
"""Sorts pw_* categories first, then alphabetically, else alphabetically."""
if category.startswith("pw_"):
return (0, category)
return (1, category)
def _build_story_section(
category: str, story_key: str, story: dict, year: str, month: str
) -> list[str]:
"""Generates ReST for an individual story."""
rst = []
h3_title = story.get("title", story_key)
sid = _section_id(category, story_key, year, month)
rst.append(f".. _{sid}:")
rst.append("")
rst.append(h3_title)
rst.append("=" * len(h3_title))
rst.append("")
body = story.get("body", "")
commits = story.get("commits", {})
links = []
if commits:
for i, (_, commit_info) in enumerate(sorted(commits.items()), 1):
url = commit_info.get("url", "")
url = url.replace(
"https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/",
"https://pwrev.dev/",
)
links.append(f"`{i} <{url}>`__")
if body and body.strip():
if links:
rst.append(f"{body.strip()} CLs: {', '.join(links)}")
else:
rst.append(body.strip())
rst.append("")
elif links:
rst.append(f"CLs: {', '.join(links)}")
rst.append("")
example = story.get("example", "")
if example and example.strip():
rst.append(example.strip('\n'))
rst.append("")
return rst
def _build_category_section(
category: str,
category_stories: dict,
year: str,
month: str,
categories_map: dict,
) -> list[str]:
"""Generates ReST for a category and its underlying stories."""
if not isinstance(category_stories, dict):
return []
# Filter out stories with low user-facing impact.
renderable_stories = {
k: v
for k, v in category_stories.items()
if isinstance(v, dict) and v.get("score", 0) > 250
}
if not renderable_stories:
return []
rst = []
h2_title = categories_map.get(category) or category
rst.append("-" * len(h2_title))
rst.append(h2_title)
rst.append("-" * len(h2_title))
rst.append("")
# Sort primarily by score descending, secondarily by story_key ascending
sorted_story_items = sorted(
renderable_stories.items(),
key=lambda item: (-item[1].get("score", 0), item[0]),
)
for story_key, story in sorted_story_items:
rst.extend(
_build_story_section(category, story_key, story, year, month)
)
return rst
def build_rst_for_month(
year: str,
month: str,
data: dict,
categories_map: dict,
ignore_list: list | None = None,
) -> str:
"""Builds reStructuredText for a single month."""
rst = []
ignore_list = ignore_list or []
rst.extend(_build_month_header(year, month))
highlights = _get_highlights(data, ignore_list)
rst.extend(
_build_highlights_section(highlights, year, month, categories_map)
)
stories = data.get("stories", {})
for category in sorted(stories.keys(), key=_category_sort_key):
if category in ignore_list:
continue
rst.extend(
_build_category_section(
category, stories[category], year, month, categories_map
)
)
return "\n".join(rst) + "\n"
def export_rst(args) -> None:
"""Exports story data to a month-specific ReST file."""
data = common.get_data(args.year, args.month)
if not data or not data.get("stories"):
return
categories_data = common.load_categories_data()
categories_map = {
k: v.get("title", k)
for k, v in categories_data.items()
if v.get("show", True)
}
ignore_list = [
k for k, v in categories_data.items() if not v.get("show", True)
]
month_str = f"{args.month:02d}"
rst_content = build_rst_for_month(
str(args.year),
month_str,
data,
categories_map,
ignore_list,
)
dest_dir = common.get_changelog_dir() / str(args.year)
dest_dir.mkdir(parents=True, exist_ok=True)
dest_file = dest_dir / f"{month_str}.rst"
dest_file.write_text(rst_content)
def end_command(args) -> None:
"""CLI entrypoint for the changelog automation `end` subcommand."""
export_rst(args)