From 6b930a4d4bf84a6e783546a8230f1ae396a77796 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 6 Dec 2025 07:00:44 +0900 Subject: [PATCH 1/2] feat: add ability to generate comment containing reStructured Text from Markdown --- .github/workflows/rst-preview-comment.yml | 46 +++++ scripts/README.md | 110 +++++++++- scripts/auto-pr-helper.py | 203 ++----------------- scripts/generate-rst-comment.py | 112 +++++++++++ scripts/guidelines_utils.py | 234 ++++++++++++++++++++++ 5 files changed, 514 insertions(+), 191 deletions(-) create mode 100644 .github/workflows/rst-preview-comment.yml create mode 100644 scripts/generate-rst-comment.py create mode 100644 scripts/guidelines_utils.py diff --git a/.github/workflows/rst-preview-comment.yml b/.github/workflows/rst-preview-comment.yml new file mode 100644 index 000000000..811e97ff2 --- /dev/null +++ b/.github/workflows/rst-preview-comment.yml @@ -0,0 +1,46 @@ +name: RST Preview Comment + +on: + issues: + types: [opened, edited] + +jobs: + post-rst-preview: + # Only run for issues created from the Coding Guideline template + # The template automatically adds the "coding guideline" label + if: contains(github.event.issue.labels.*.name, 'coding guideline') + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install Pandoc + uses: pandoc/actions/setup@v1 + + - name: Generate RST preview comment + id: generate-comment + run: | + # Generate the comment content + COMMENT=$(cat <<'EOF' | uv run python scripts/generate-rst-comment.py + ${{ toJson(github.event.issue) }} + EOF + ) + + # Write to file to preserve formatting + echo "$COMMENT" > /tmp/comment-body.md + + - name: Post or update comment + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.issue.number }} + body-path: /tmp/comment-body.md + comment-author: 'github-actions[bot]' + # Use a hidden marker to identify and update the same comment + body-includes: '' + edit-mode: replace diff --git a/scripts/README.md b/scripts/README.md index 0f9b3adbc..3e72ef220 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,25 +1,115 @@ -### `auto-pr-helper.py` +# Scripts -This script is a utility for automating the generation of guidelines. It takes a GitHub issue's JSON data from standard input, parses its body (which is expected to follow a specific issue template), and converts it into a formatted reStructuredText (`.rst`) guideline. +This directory contains utility scripts for managing coding guidelines. + +**Location: scripts/README.md (replaces existing file)** + +## Scripts Overview + +| Script | Purpose | +|--------|---------| +| `auto-pr-helper.py` | Transforms issue JSON to RST format (used by auto-PR workflow) | +| `generate-rst-comment.py` | Generates GitHub comment with RST preview | +| `guideline_utils.py` | Shared utility functions for guideline processing | + +--- + +## `guideline_utils.py` + +A shared module containing common functions used by other scripts: + +- `md_to_rst()` - Convert Markdown to reStructuredText using Pandoc +- `normalize_md()` - Fix Markdown formatting issues +- `normalize_list_separation()` - Ensure proper list formatting for Pandoc +- `extract_form_fields()` - Parse issue body into field dictionary +- `guideline_template()` - Generate RST from fields dictionary +- `chapter_to_filename()` - Convert chapter name to filename slug +- `save_guideline_file()` - Append guideline to chapter file --- -### How to Use +## `auto-pr-helper.py` + +This script transforms a GitHub issue's JSON data into reStructuredText format for coding guidelines. + +### Usage + +```bash +# From a local JSON file +cat path/to/issue.json | uv run python scripts/auto-pr-helper.py + +# From GitHub API directly +curl https://api.github.com/repos/rustfoundation/safety-critical-rust-coding-guidelines/issues/123 | uv run python scripts/auto-pr-helper.py + +# Save the output to the appropriate chapter file +cat path/to/issue.json | uv run python scripts/auto-pr-helper.py --save +``` + +### Options + +- `--save`: Save the generated RST content to the appropriate chapter file in `src/coding-guidelines/` + +--- -The script reads a JSON payload from **standard input**. The most common way to provide this input is by using a pipe (`|`) to feed the output of another command into the script. +## `generate-rst-comment.py` -#### 1. Using a Local JSON File +This script generates a formatted GitHub comment containing an RST preview of a coding guideline. It's used by the RST Preview Comment workflow to post helpful comments on coding guideline issues. -For local testing, you can use `cat` to pipe the contents of a saved GitHub issue JSON file into the script. +### Usage ```bash -cat path/to/your_issue.json | uv run scripts/auto-pr-helper.py +# From a local JSON file +cat path/to/issue.json | uv run python scripts/generate-rst-comment.py + +# From GitHub API directly +curl https://api.github.com/repos/rustfoundation/safety-critical-rust-coding-guidelines/issues/123 | uv run python scripts/generate-rst-comment.py ``` -#### 2. Fetching from the GitHub API directly -You can fetch the data for a live issue directly from the GitHub API using curl and pipe it to the script. This is useful for getting the most up-to-date content. +### Output + +The script outputs a Markdown-formatted comment that includes: + +1. **Instructions** on how to use the RST content +2. **Target file path** indicating which chapter file to add the guideline to +3. **Collapsible RST content** that can be copied and pasted + +### Example Output + +```markdown +## 📋 RST Preview for Coding Guideline + +This is an automatically generated preview... + +### 📁 Target File +Add this guideline to: `src/coding-guidelines/concurrency.rst` + +### 📝 How to Use This +1. Fork the repository... +... + +
+📄 Click to expand RST content + +\`\`\`rst +.. guideline:: My Guideline Title + :id: gui_ABC123... +\`\`\` + +
+``` + +--- + +## How to Get Issue JSON from GitHub API + +To work with these scripts locally, you can fetch issue data from the GitHub API: ```bash -curl https://api.github.com/repos/rustfoundation/safety-critical-rust-coding-guidelines/issues/156 | uv run ./scripts/auto-pr-helper.py +curl https://api.github.com/repos/OWNER/REPO/issues/ISSUE_NUMBER > issue.json ``` + +For example: + +```bash +curl https://api.github.com/repos/rustfoundation/safety-critical-rust-coding-guidelines/issues/156 > issue.json ``` diff --git a/scripts/auto-pr-helper.py b/scripts/auto-pr-helper.py index 62d5ca2ee..b1cb1d9e8 100644 --- a/scripts/auto-pr-helper.py +++ b/scripts/auto-pr-helper.py @@ -1,189 +1,30 @@ -import argparse -import json -import os -import re -import sys -from textwrap import dedent, indent - -import pypandoc - -scriptpath = "../" -script_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.abspath(os.path.join(script_dir, "..")) -sys.path.append(parent_dir) - -from generate_guideline_templates import ( - guideline_rst_template, - issue_header_map, -) - - -def md_to_rst(markdown: str) -> str: - return pypandoc.convert_text( - markdown, - 'rst', - format='markdown', - extra_args=['--wrap=none'] - ) - - -def normalize_list_separation(text: str) -> str: - """ - Ensures every new list block is preceded by a blank line, - required for robust parsing by Pandoc when targeting RST - """ - # Regex to identify any line that starts a Markdown list item (* or -) - _list_item_re = re.compile(r"^[ \t]*[*-][ \t]+") - - output_buffer = [] - for line in text.splitlines(): - is_item = bool(_list_item_re.match(line)) - - # Get the last line appended to the output buffer - prev = output_buffer[-1] if output_buffer else "" - - # Check if a blank line needs to be inserted before list - # (Current is item) AND (Prev is not blank) AND (Prev is not an item) - if is_item and prev.strip() and not _list_item_re.match(prev): - # Insert a blank line to clearly separate the new list block - output_buffer.append("") - - output_buffer.append(line) - - return "\n".join(output_buffer) - - -def normalize_md(issue_body: str) -> str: - """ - Fix links and mixed bold/code that confuse Markdown parser - """ - # Fix links with inline-code: [`link`](url) => [link](url) - issue_body = re.sub( - r"\[\s*`([^`]+)`\s*\]\(([^)]+)\)", - r"[\1](\2)", - issue_body - ) - - # Fix mixed bold/code formatting - # **`code`** => `code` - issue_body = re.sub( - r"\*\*`([^`]+)`\*\*", - r"`\1`", - issue_body - ) - - # `**code**` => `code` - issue_body = re.sub( - r"`\*\*([^`]+)\*\*`", - r"`\1`", - issue_body - ) +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-FileCopyrightText: The Coding Guidelines Subcommittee Contributors - return issue_body +""" +This script transforms a GitHub issue JSON into RST format for coding guidelines. +It reads a GitHub issue's JSON data from standard input, parses its body +(which is expected to follow a specific issue template), and converts it +into a formatted reStructuredText (.rst) guideline. -def extract_form_fields(issue_body: str) -> dict: - """ - This function parses issues json into a dict of important fields - """ +Usage: + cat issue.json | uv run python scripts/auto-pr-helper.py + cat issue.json | uv run python scripts/auto-pr-helper.py --save +""" - fields = dict.fromkeys(issue_header_map.values(), "") - - lines = issue_body.splitlines() - current_key = None - current_value_lines = [] - - lines.append("### END") # Sentinel to process last field - - # Look for '###' in every line, ### represent a sections/field in a guideline - for line in lines: - header_match = re.match(r"^### (.+)$", line.strip()) - if header_match: - # Save previous field value if any - if current_key is not None: - value = "\n".join(current_value_lines).strip() - # `_No response_` represents an empty field - if value == "_No response_": - value = "" - if current_key in fields: - fields[current_key] = value - - header = header_match.group(1).strip() - current_key = issue_header_map.get( - header - ) # Map to dict key or None if unknown - current_value_lines = [] - else: - current_value_lines.append(line) - - return fields - - -def save_guideline_file(content: str, chapter: str): - """ - Appends a guideline to a chapter - """ - filename = f"src/coding-guidelines/{chapter.lower().replace(' ', '-')}.rst" - with open(filename, "a", encoding="utf-8") as f: - f.write(content) - print(f"Saved guideline to {filename}") - - -def guideline_template(fields: dict) -> str: - """ - This function turns a dictionary that contains the guideline fields - into a proper .rst guideline format - """ - - def get(key): - return fields.get(key, "").strip() - - def format_code_block(code: str, lang: str = "rust") -> str: - lines = code.strip().splitlines() - if lines and lines[0].strip().startswith("```"): - # Strip the ```rust and ``` lines - lines = lines[1:] - if lines and lines[-1].strip() == "```": - lines = lines[:-1] - - # Dedent before adding indentation - dedented_code = dedent("\n".join(lines)) - - # Add required indentation - indented_code = "\n".join( - f" {line}" for line in dedented_code.splitlines() - ) - - return f"\n\n{indented_code}\n" - - amplification_text = indent(md_to_rst(get("amplification")), " " * 12) - rationale_text = indent(md_to_rst(get("rationale")), " " * 16) - non_compliant_ex_prose_text = indent( - md_to_rst(get("non_compliant_ex_prose")), " " * 16 - ) - compliant_example_prose_text = indent( - md_to_rst(get("compliant_example_prose")), " " * 16 - ) - - guideline_text = guideline_rst_template( - guideline_title=get("guideline_title"), - category=get("category"), - status=get("status"), - release_begin=get("release_begin"), - release_end=get("release_end"), - fls_id=get("fls_id"), - decidability=get("decidability"), - scope=get("scope"), - tags=get("tags"), - amplification=amplification_text, - rationale=rationale_text, - non_compliant_ex_prose=non_compliant_ex_prose_text, - non_compliant_ex=format_code_block(get("non_compliant_ex")), - compliant_example_prose=compliant_example_prose_text, - compliant_example=format_code_block(get("compliant_example")), - ) +import argparse +import json +import sys - return guideline_text +from guideline_utils import ( + extract_form_fields, + guideline_template, + normalize_md, + normalize_list_separation, + save_guideline_file, +) if __name__ == "__main__": diff --git a/scripts/generate-rst-comment.py b/scripts/generate-rst-comment.py new file mode 100644 index 000000000..282a1908e --- /dev/null +++ b/scripts/generate-rst-comment.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-FileCopyrightText: The Coding Guidelines Subcommittee Contributors + +""" +This script generates a GitHub comment containing the RST preview of a coding guideline. +It reads a GitHub issue JSON from stdin and outputs a formatted Markdown comment. + +Usage: + cat issue.json | uv run python scripts/generate-rst-comment.py + curl https://api.github.com/repos/.../issues/123 | uv run python scripts/generate-rst-comment.py + +Location: scripts/generate-rst-comment.py (new file) +""" + +import json +import sys + +from guideline_utils import ( + extract_form_fields, + guideline_template, + normalize_md, + normalize_list_separation, + chapter_to_filename, +) + + +def generate_comment(rst_content: str, chapter: str) -> str: + """ + Generate a formatted GitHub comment with instructions and RST content. + + Args: + rst_content: The generated RST content for the guideline + chapter: The chapter name (e.g., "Concurrency", "Expressions") + + Returns: + Formatted Markdown comment string + """ + chapter_slug = chapter_to_filename(chapter) + target_file = f"src/coding-guidelines/{chapter_slug}.rst" + + comment = f"""## 📋 RST Preview for Coding Guideline + +This is an automatically generated preview of your coding guideline in reStructuredText format. + +### 📁 Target File + +Add this guideline to: `{target_file}` + +### 📝 How to Use This + +1. **Fork the repository** (if you haven't already) and clone it locally +2. **Create a new branch** from `main`: + ```bash + git checkout main + git pull origin main + git checkout -b guideline/your-descriptive-branch-name + ``` +3. **Open the target file** `{target_file}` in your editor +4. **Copy the RST content** below and paste it at the end of the file (before any final directives if present) +5. **Build locally** to verify the guideline renders correctly: + ```bash + ./make.py + ``` +6. **Commit and push** your changes: + ```bash + git add {target_file} + git commit -m "Add guideline: " + git push origin guideline/your-descriptive-branch-name + ``` +7. **Open a Pull Request** against `main` + +
+📄 Click to expand RST content + +```rst +{rst_content} +``` + +
+ +--- +🤖 This comment was automatically generated from the issue content. It will be updated when you edit the issue body. + + +""" + return comment + + +def main(): + # Read JSON from stdin + stdin_issue_json = sys.stdin.read() + json_issue = json.loads(stdin_issue_json) + + issue_body = json_issue["body"] + issue_body = normalize_md(issue_body) + issue_body = normalize_list_separation(issue_body) + + fields = extract_form_fields(issue_body) + chapter = fields["chapter"] + + # Generate RST content + rst_content = guideline_template(fields) + + # Generate the comment + comment = generate_comment(rst_content.strip(), chapter) + + print(comment) + + +if __name__ == "__main__": + main() diff --git a/scripts/guidelines_utils.py b/scripts/guidelines_utils.py new file mode 100644 index 000000000..2be96d193 --- /dev/null +++ b/scripts/guidelines_utils.py @@ -0,0 +1,234 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 +# SPDX-FileCopyrightText: The Coding Guidelines Subcommittee Contributors + +""" +Shared utilities for parsing GitHub issues and generating RST guideline content. + +This module contains common functions used by: +- auto-pr-helper.py (for automated PR generation) +- generate-rst-comment.py (for generating preview comments) +""" + +import re +from textwrap import dedent, indent + +import pypandoc + +# Import the template function and header map from the main module +import sys +import os + +script_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.abspath(os.path.join(script_dir, "..")) +sys.path.append(parent_dir) + +from generate_guideline_templates import ( + guideline_rst_template, + issue_header_map, +) + + +def md_to_rst(markdown: str) -> str: + """Convert Markdown text to reStructuredText using Pandoc.""" + return pypandoc.convert_text( + markdown, + 'rst', + format='markdown', + extra_args=['--wrap=none'] + ) + + +def normalize_list_separation(text: str) -> str: + """ + Ensures every new list block is preceded by a blank line, + required for robust parsing by Pandoc when targeting RST + """ + # Regex to identify any line that starts a Markdown list item (* or -) + _list_item_re = re.compile(r"^[ \t]*[*-][ \t]+") + + output_buffer = [] + for line in text.splitlines(): + is_item = bool(_list_item_re.match(line)) + + # Get the last line appended to the output buffer + prev = output_buffer[-1] if output_buffer else "" + + # Check if a blank line needs to be inserted before list + # (Current is item) AND (Prev is not blank) AND (Prev is not an item) + if is_item and prev.strip() and not _list_item_re.match(prev): + # Insert a blank line to clearly separate the new list block + output_buffer.append("") + + output_buffer.append(line) + + return "\n".join(output_buffer) + + +def normalize_md(issue_body: str) -> str: + """ + Fix links and mixed bold/code that confuse Markdown parser + """ + # Fix links with inline-code: [`link`](url) => [link](url) + issue_body = re.sub( + r"\[\s*`([^`]+)`\s*\]\(([^)]+)\)", + r"[\1](\2)", + issue_body + ) + + # Fix mixed bold/code formatting + # **`code`** => `code` + issue_body = re.sub( + r"\*\*`([^`]+)`\*\*", + r"`\1`", + issue_body + ) + + # `**code**` => `code` + issue_body = re.sub( + r"`\*\*([^`]+)\*\*`", + r"`\1`", + issue_body + ) + + return issue_body + + +def extract_form_fields(issue_body: str) -> dict: + """ + Parse issue body (from GitHub issue template) into a dict of field values. + + Args: + issue_body: The raw body text from a GitHub issue + + Returns: + Dictionary with field names as keys and their values + """ + fields = dict.fromkeys(issue_header_map.values(), "") + + lines = issue_body.splitlines() + current_key = None + current_value_lines = [] + + lines.append("### END") # Sentinel to process last field + + # Look for '###' in every line, ### represent a sections/field in a guideline + for line in lines: + header_match = re.match(r"^### (.+)$", line.strip()) + if header_match: + # Save previous field value if any + if current_key is not None: + value = "\n".join(current_value_lines).strip() + # `_No response_` represents an empty field + if value == "_No response_": + value = "" + if current_key in fields: + fields[current_key] = value + + header = header_match.group(1).strip() + current_key = issue_header_map.get( + header + ) # Map to dict key or None if unknown + current_value_lines = [] + else: + current_value_lines.append(line) + + return fields + + +def format_code_block(code: str, lang: str = "rust") -> str: + """ + Format a code block for RST output, stripping markdown fences if present. + + Args: + code: The code content, possibly wrapped in markdown fences + lang: The language for syntax highlighting (default: rust) + + Returns: + Formatted code block string with proper indentation + """ + lines = code.strip().splitlines() + if lines and lines[0].strip().startswith("```"): + # Strip the ```rust and ``` lines + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + + # Dedent before adding indentation + dedented_code = dedent("\n".join(lines)) + + # Add required indentation + indented_code = "\n".join( + f" {line}" for line in dedented_code.splitlines() + ) + + return f"\n\n{indented_code}\n" + + +def guideline_template(fields: dict) -> str: + """ + Convert a dictionary of guideline fields into proper RST format. + + Args: + fields: Dictionary containing all guideline fields + + Returns: + Formatted RST string for the guideline + """ + def get(key): + return fields.get(key, "").strip() + + amplification_text = indent(md_to_rst(get("amplification")), " " * 12) + rationale_text = indent(md_to_rst(get("rationale")), " " * 16) + non_compliant_ex_prose_text = indent( + md_to_rst(get("non_compliant_ex_prose")), " " * 16 + ) + compliant_example_prose_text = indent( + md_to_rst(get("compliant_example_prose")), " " * 16 + ) + + guideline_text = guideline_rst_template( + guideline_title=get("guideline_title"), + category=get("category"), + status=get("status"), + release_begin=get("release_begin"), + release_end=get("release_end"), + fls_id=get("fls_id"), + decidability=get("decidability"), + scope=get("scope"), + tags=get("tags"), + amplification=amplification_text, + rationale=rationale_text, + non_compliant_ex_prose=non_compliant_ex_prose_text, + non_compliant_ex=format_code_block(get("non_compliant_ex")), + compliant_example_prose=compliant_example_prose_text, + compliant_example=format_code_block(get("compliant_example")), + ) + + return guideline_text + + +def chapter_to_filename(chapter: str) -> str: + """ + Convert chapter name to filename slug. + + Args: + chapter: Chapter name (e.g., "Associated Items", "Concurrency") + + Returns: + Filename slug (e.g., "associated-items", "concurrency") + """ + return chapter.lower().replace(" ", "-") + + +def save_guideline_file(content: str, chapter: str): + """ + Append a guideline to a chapter file. + + Args: + content: The RST content to append + chapter: The chapter name + """ + filename = f"src/coding-guidelines/{chapter_to_filename(chapter)}.rst" + with open(filename, "a", encoding="utf-8") as f: + f.write(content) + print(f"Saved guideline to {filename}") From 9926ced728c891c973a1281f2b4ec227683d01c3 Mon Sep 17 00:00:00 2001 From: Pete LeVasseur Date: Sat, 6 Dec 2025 07:11:53 +0900 Subject: [PATCH 2/2] fix: resolve Python linting issues --- scripts/auto-pr-helper.py | 9 +++++---- scripts/generate-rst-comment.py | 4 ++-- scripts/guidelines_utils.py | 8 ++++---- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/auto-pr-helper.py b/scripts/auto-pr-helper.py index b1cb1d9e8..b55a3d126 100644 --- a/scripts/auto-pr-helper.py +++ b/scripts/auto-pr-helper.py @@ -5,13 +5,15 @@ """ This script transforms a GitHub issue JSON into RST format for coding guidelines. -It reads a GitHub issue's JSON data from standard input, parses its body -(which is expected to follow a specific issue template), and converts it +It reads a GitHub issue's JSON data from standard input, parses its body +(which is expected to follow a specific issue template), and converts it into a formatted reStructuredText (.rst) guideline. Usage: cat issue.json | uv run python scripts/auto-pr-helper.py cat issue.json | uv run python scripts/auto-pr-helper.py --save + +Location: scripts/auto-pr-helper.py (replaces existing file) """ import argparse @@ -21,12 +23,11 @@ from guideline_utils import ( extract_form_fields, guideline_template, - normalize_md, normalize_list_separation, + normalize_md, save_guideline_file, ) - if __name__ == "__main__": # parse arguments parser = argparse.ArgumentParser( diff --git a/scripts/generate-rst-comment.py b/scripts/generate-rst-comment.py index 282a1908e..6c4210797 100644 --- a/scripts/generate-rst-comment.py +++ b/scripts/generate-rst-comment.py @@ -17,11 +17,11 @@ import sys from guideline_utils import ( + chapter_to_filename, extract_form_fields, guideline_template, - normalize_md, normalize_list_separation, - chapter_to_filename, + normalize_md, ) diff --git a/scripts/guidelines_utils.py b/scripts/guidelines_utils.py index 2be96d193..90cad97b2 100644 --- a/scripts/guidelines_utils.py +++ b/scripts/guidelines_utils.py @@ -7,17 +7,17 @@ This module contains common functions used by: - auto-pr-helper.py (for automated PR generation) - generate-rst-comment.py (for generating preview comments) + +Location: scripts/guideline_utils.py """ +import os import re +import sys from textwrap import dedent, indent import pypandoc -# Import the template function and header map from the main module -import sys -import os - script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.abspath(os.path.join(script_dir, "..")) sys.path.append(parent_dir)