Skip to content

feat: support custom PR body template via .pr-split/template.md#23

Merged
vitali87 merged 5 commits intomainfrom
feat/pr-templates
Mar 24, 2026
Merged

feat: support custom PR body template via .pr-split/template.md#23
vitali87 merged 5 commits intomainfrom
feat/pr-templates

Conversation

@vitali87
Copy link
Copy Markdown
Owner

Summary

  • Users can create .pr-split/template.md with Python format placeholders to customize PR body
  • Available variables: {description}, {files}, {added}, {removed}, {loc}, {dependencies}, {dag}, {id}, {title}
  • Falls back to the default built-in format if no template file exists
  • Template is read at PR creation time, so it can be changed between runs

Example template:

{description}

### Changed files
{files}

**Stats:** +{added}/-{removed} ({loc} lines)

{dag}

Test plan

  • All 293 tests pass
  • Manual: create .pr-split/template.md, run split, verify custom PR body
  • Manual: run split without template, verify default format unchanged

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the flexibility of pull request generation by enabling users to define their own custom templates for PR bodies. This allows for greater control over the presentation of PR information, promoting consistency and tailored communication without altering the core logic. The change introduces a new configuration point and a robust templating engine that integrates seamlessly with existing PR data.

Highlights

  • Custom PR Body Template: Introduced support for a custom PR body template by allowing users to create a .pr-split/template.md file in the repository.
  • Template Variables: The custom template supports Python format placeholders, providing access to various PR-related variables such as description, files, added/removed lines, dependencies, and DAG visualization.
  • Fallback Mechanism: The system gracefully falls back to the default built-in PR body format if no custom template file is found, ensuring backward compatibility.
  • Dynamic Template Reading: The template file is read at the time of PR creation, allowing for changes to the template between runs without requiring code modifications.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a valuable feature for customizing PR body content using a template file. The implementation is clean and correctly falls back to the default format if no template is provided. My main suggestion is to improve the robustness of the template processing by adding error handling for invalid placeholders. This will prevent crashes and provide better feedback to users when they make a mistake in their template file.

pr_split/cli.py Outdated
Comment on lines +281 to +283
if _PR_TEMPLATE_PATH.exists():
template = _PR_TEMPLATE_PATH.read_text(encoding="utf-8")
return template.format(**template_vars)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using template.format(**template_vars) can lead to a KeyError if the user's template file contains an invalid or misspelled placeholder. This would crash the program with an unfriendly traceback. It's better to handle this potential error and provide a helpful message to the user, guiding them on how to fix their template.

I suggest wrapping the format call in a try...except block to catch KeyError and raise a PRSplitError with a clear error message listing the available placeholders.

    if _PR_TEMPLATE_PATH.exists():
        template = _PR_TEMPLATE_PATH.read_text(encoding="utf-8")
        try:
            return template.format(**template_vars)
        except KeyError as e:
            available_keys = ", ".join(f"{{{k}}}" for k in sorted(template_vars.keys()))
            msg = (
                f"Error in PR template '{_PR_TEMPLATE_PATH}': invalid placeholder {e}.\n"
                f"Available placeholders are: {available_keys}"
            )
            raise PRSplitError(msg) from e

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 24, 2026

Greptile Summary

This PR adds support for a user-defined PR body template at .pr-split/template.md, with Python-style {placeholder} substitution and graceful PRSplitError fallback on format errors. A secondary change swaps shutil.rmtree(plan_dir) for plan_path.unlink() in _cleanup_git_state to avoid deleting a user's template during clean.

  • Template feature (pr_split/cli.py lines 267–295): well-structured — template_vars is built eagerly, the file is read inside a try, and KeyError, ValueError, IndexError, and OSError are all caught and re-raised as user-friendly PRSplitError messages.
  • Cleanup regression (pr_split/cli.py lines 571–573): switching from rmtree to unlink is correct motivation-wise, but leaves an empty .pr-split/ directory on disk when no template.md exists — a silent behavioural change for users without a template.
  • Dead test mocks (tests/test_cli_new_features.py lines 120–176): both TestCleanupGitState tests still patch shutil.rmtree (now unused) and do not assert that unlink() is called, leaving the new cleanup behaviour effectively untested.
  • Missing OSError test coverage: the except OSError branch in _build_pr_body has no corresponding test.

Confidence Score: 4/5

  • Safe to merge with minor test cleanup and an optional guard for the orphaned directory edge case.
  • The template feature itself is well-implemented with solid error handling that addressed all issues raised in earlier review rounds. The only real concern is a behavioural regression in cleanup (empty .pr-split/ left behind) and stale mocks in the test suite that leave the new unlink() path untested — neither is a runtime crash or data-loss risk.
  • tests/test_cli_new_features.py — dead shutil.rmtree mocks and missing coverage for the OSError branch.

Important Files Changed

Filename Overview
pr_split/cli.py Adds custom PR body template support via _PR_TEMPLATE_PATH; template errors are properly caught and wrapped in PRSplitError. The _cleanup_git_state refactor from shutil.rmtree to plan_path.unlink() silently leaves an empty .pr-split/ directory when no template file exists.
tests/test_cli_new_features.py Adds two new tests for the template feature (happy path and invalid placeholder), but TestCleanupGitState still patches the now-unused shutil.rmtree and lacks assertions on the new unlink() call; the OSError branch of _build_pr_body is also untested.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["_build_pr_body(group, all_groups)"] --> B{{"_PR_TEMPLATE_PATH.exists()?"}}
    B -- Yes --> C["Build template_vars dict\n(description, files, added, removed,\nloc, dependencies, dag, id, title)"]
    C --> D["_PR_TEMPLATE_PATH.read_text()"]
    D -- OSError --> E["raise PRSplitError\n'Could not read PR template'"]
    D -- success --> F["template.format(**template_vars)"]
    F -- KeyError / ValueError\n/ IndexError --> G["raise PRSplitError\n'Invalid PR template'\n+ list available placeholders"]
    F -- success --> H["return formatted body"]
    B -- No --> I["Build default sections list\n(description, files, diff stats,\ndependencies, DAG)"]
    I --> J["return '\\n\\n'.join(sections)"]
Loading

Comments Outside Diff (1)

  1. tests/test_cli_new_features.py, line 120-148 (link)

    P2 Dead shutil.rmtree mocks after cleanup refactor

    Both test_closes_prs_and_deletes_branches and test_handles_partial_failures still patch pr_split.cli.shutil.rmtree and accept a mock_rmtree parameter, but _cleanup_git_state no longer calls shutil.rmtree — it now calls plan_path.unlink(). The mock_rmtree argument is never used or asserted, making these patches dead.

    As a result, the new unlink() behaviour is untested: there's no assertion that the plan file is actually deleted during cleanup. If someone later accidentally reverts or breaks the unlink() call, neither test would catch it.

    The mocks should be removed and an assertion added for the new behaviour:

    @patch("pr_split.cli.delete_branch")
    @patch("pr_split.cli.close_pr")
    def test_closes_prs_and_deletes_branches(
        self,
        mock_close: MagicMock,
        mock_delete: MagicMock,
    ) -> None:
        with patch("pr_split.cli.Path") as mock_path:
            mock_path.return_value.exists.return_value = True
            git_state = GitState(...)
            closed, deleted = _cleanup_git_state(git_state)
            mock_path.return_value.unlink.assert_called_once()
        ...

    The same fix applies to test_handles_partial_failures.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: tests/test_cli_new_features.py
    Line: 120-148
    
    Comment:
    **Dead `shutil.rmtree` mocks after cleanup refactor**
    
    Both `test_closes_prs_and_deletes_branches` and `test_handles_partial_failures` still patch `pr_split.cli.shutil.rmtree` and accept a `mock_rmtree` parameter, but `_cleanup_git_state` no longer calls `shutil.rmtree` — it now calls `plan_path.unlink()`. The `mock_rmtree` argument is never used or asserted, making these patches dead.
    
    As a result, the new `unlink()` behaviour is untested: there's no assertion that the plan file is actually deleted during cleanup. If someone later accidentally reverts or breaks the `unlink()` call, neither test would catch it.
    
    The mocks should be removed and an assertion added for the new behaviour:
    
    ```python
    @patch("pr_split.cli.delete_branch")
    @patch("pr_split.cli.close_pr")
    def test_closes_prs_and_deletes_branches(
        self,
        mock_close: MagicMock,
        mock_delete: MagicMock,
    ) -> None:
        with patch("pr_split.cli.Path") as mock_path:
            mock_path.return_value.exists.return_value = True
            git_state = GitState(...)
            closed, deleted = _cleanup_git_state(git_state)
            mock_path.return_value.unlink.assert_called_once()
        ...
    ```
    
    The same fix applies to `test_handles_partial_failures`.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: tests/test_cli_new_features.py
Line: 120-148

Comment:
**Dead `shutil.rmtree` mocks after cleanup refactor**

Both `test_closes_prs_and_deletes_branches` and `test_handles_partial_failures` still patch `pr_split.cli.shutil.rmtree` and accept a `mock_rmtree` parameter, but `_cleanup_git_state` no longer calls `shutil.rmtree` — it now calls `plan_path.unlink()`. The `mock_rmtree` argument is never used or asserted, making these patches dead.

As a result, the new `unlink()` behaviour is untested: there's no assertion that the plan file is actually deleted during cleanup. If someone later accidentally reverts or breaks the `unlink()` call, neither test would catch it.

The mocks should be removed and an assertion added for the new behaviour:

```python
@patch("pr_split.cli.delete_branch")
@patch("pr_split.cli.close_pr")
def test_closes_prs_and_deletes_branches(
    self,
    mock_close: MagicMock,
    mock_delete: MagicMock,
) -> None:
    with patch("pr_split.cli.Path") as mock_path:
        mock_path.return_value.exists.return_value = True
        git_state = GitState(...)
        closed, deleted = _cleanup_git_state(git_state)
        mock_path.return_value.unlink.assert_called_once()
    ...
```

The same fix applies to `test_handles_partial_failures`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: pr_split/cli.py
Line: 571-573

Comment:
**Orphaned `.pr-split/` directory after `clean`**

The previous implementation removed the entire `.pr-split/` directory with `shutil.rmtree`. The new code only deletes `plan.json`, which preserves any user `template.md` — but when no template exists, an empty `.pr-split/` directory is left on disk after `clean`.

This is a silent behavioural regression for users who don't have a template: they now get a stale empty directory rather than a clean slate. The directory will be silently recreated on the next run (`mkdir(exist_ok=True)` in `save_plan`), so it's not harmful, but it's surprising and potentially confusing if users track `.pr-split/` in their `.gitignore` or inspect it manually.

Consider removing the directory if it would be left empty after the plan file is deleted:

```python
plan_path = Path(PLAN_FILE)
if plan_path.exists():
    plan_path.unlink()

plan_dir = Path(PLAN_DIR)
if plan_dir.exists() and not any(plan_dir.iterdir()):
    plan_dir.rmdir()
```

This preserves a `template.md` (directory is non-empty → not removed) while still giving a clean state when no template exists.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: tests/test_cli_new_features.py
Line: 105-116

Comment:
**Missing test coverage for `OSError` on template read**

The `except OSError` branch in `_build_pr_body` (which handles `PermissionError`, the TOCTOU `FileNotFoundError`, etc.) has no corresponding test. A test like the one below would cover this path and prevent regressions if the error handling is ever changed:

```python
def test_custom_template_unreadable(
    self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    template_file = tmp_path / "template.md"
    template_file.write_text("{description}")
    monkeypatch.setattr("pr_split.cli._PR_TEMPLATE_PATH", template_file)
    template_file.unlink()  # simulate TOCTOU / permission error

    group = _group("pr-1", "feat: auth", files=["auth.py"])
    with pytest.raises(PRSplitError, match="Could not read PR template"):
        _build_pr_body(group, [group])
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (6): Last reviewed commit: "fix: preserve template.md during clean a..." | Re-trigger Greptile

@vitali87 vitali87 force-pushed the feat/pr-templates branch from 2a7bc8a to 333a711 Compare March 24, 2026 00:56
@vitali87
Copy link
Copy Markdown
Owner Author

@greptile

@vitali87
Copy link
Copy Markdown
Owner Author

@greptile

1 similar comment
@vitali87
Copy link
Copy Markdown
Owner Author

@greptile

@vitali87
Copy link
Copy Markdown
Owner Author

@greptile

@vitali87
Copy link
Copy Markdown
Owner Author

@greptile

@vitali87
Copy link
Copy Markdown
Owner Author

@greptileai review

Comment on lines +571 to +573
plan_path = Path(PLAN_FILE)
if plan_path.exists():
plan_path.unlink()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Orphaned .pr-split/ directory after clean

The previous implementation removed the entire .pr-split/ directory with shutil.rmtree. The new code only deletes plan.json, which preserves any user template.md — but when no template exists, an empty .pr-split/ directory is left on disk after clean.

This is a silent behavioural regression for users who don't have a template: they now get a stale empty directory rather than a clean slate. The directory will be silently recreated on the next run (mkdir(exist_ok=True) in save_plan), so it's not harmful, but it's surprising and potentially confusing if users track .pr-split/ in their .gitignore or inspect it manually.

Consider removing the directory if it would be left empty after the plan file is deleted:

plan_path = Path(PLAN_FILE)
if plan_path.exists():
    plan_path.unlink()

plan_dir = Path(PLAN_DIR)
if plan_dir.exists() and not any(plan_dir.iterdir()):
    plan_dir.rmdir()

This preserves a template.md (directory is non-empty → not removed) while still giving a clean state when no template exists.

Prompt To Fix With AI
This is a comment left during a code review.
Path: pr_split/cli.py
Line: 571-573

Comment:
**Orphaned `.pr-split/` directory after `clean`**

The previous implementation removed the entire `.pr-split/` directory with `shutil.rmtree`. The new code only deletes `plan.json`, which preserves any user `template.md` — but when no template exists, an empty `.pr-split/` directory is left on disk after `clean`.

This is a silent behavioural regression for users who don't have a template: they now get a stale empty directory rather than a clean slate. The directory will be silently recreated on the next run (`mkdir(exist_ok=True)` in `save_plan`), so it's not harmful, but it's surprising and potentially confusing if users track `.pr-split/` in their `.gitignore` or inspect it manually.

Consider removing the directory if it would be left empty after the plan file is deleted:

```python
plan_path = Path(PLAN_FILE)
if plan_path.exists():
    plan_path.unlink()

plan_dir = Path(PLAN_DIR)
if plan_dir.exists() and not any(plan_dir.iterdir()):
    plan_dir.rmdir()
```

This preserves a `template.md` (directory is non-empty → not removed) while still giving a clean state when no template exists.

How can I resolve this? If you propose a fix, please make it concise.

@vitali87 vitali87 merged commit e7feebd into main Mar 24, 2026
4 checks passed
@vitali87 vitali87 deleted the feat/pr-templates branch March 27, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant