| # 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) |