repomatic.github.pr_body module

Generate PR body with workflow metadata for auto-created pull requests.

Callers inject a Metadata instance for CI context to produce a collapsible <details> block containing a metadata table (the injection keeps this module import-cycle-free: Metadata pulls in half the package). Template prefixes are loaded from markdown files in repomatic/templates/, optionally with YAML frontmatter for templates that require arguments.

Note

load_template() and the render_* helpers also accept a Path to read a template from disk. Downstream repos can ship project-specific templates and feed them through repomatic pr-body –template-file path/to/template.md (paired with one or more --template-arg KEY=VALUE entries to fill the placeholders) without forking repomatic. External templates should set footer: false in their frontmatter to avoid duplicating the attribution footer that already ships with the metadata block.

Also provides two helpers for embedding externally-sourced markdown in PR or issue bodies: sanitize_markdown_mentions() neutralizes @mentions, #issue refs, and GitHub URLs, and demote_markdown_headings() pushes the embedded content’s headings below the embedding document’s own sections.

repomatic.github.pr_body.GITHUB_BODY_MAX_CHARS = 65536

GitHub’s maximum PR and issue body size, in UTF-16 code units.

GitHub’s API rejects longer bodies outright, so an oversized body has to be trimmed before it is posted. The obvious trim is the wrong one: cutting the end (what a plain body[:65536] does, and what peter-evans/create-pull-request did before repomatic.github.pr replaced it) drops whatever sits last, which here is the refresh tip, the metadata block and the attribution footer: the navigational parts a reader needs most when a report is too long to read. build_pr_body() (PRs) and fit_github_body() (issues) therefore trim the leading content instead, so the tail always survives.

repomatic.github.pr_body.sanitize_markdown_mentions(text)[source]

Neutralize @mentions, #issue refs, and GitHub URLs in markdown.

Prevents GitHub from auto-linking mentions and issue references in externally-sourced markdown (upstream release notes, third-party tool output) that would cause notification spam or accidental issue closure.

Uses a placeholder extraction approach: fenced code blocks and inline code spans are temporarily replaced with unique placeholders before sanitization, then restored afterward. This avoids the fragile “sanitize then restore” pattern that caused bugs in both Dependabot (2019 code-fence regression, dependabot/dependabot-core#1421) and Renovate (ongoing restoration pass edge cases, renovatebot/renovate#8823, renovatebot/renovate#2554).

Inserts a Unicode zero-width space (U+200B) after @ and # to break GitHub’s mention and issue parsers without affecting visual rendering. Rewrites github.com URLs to redirect.github.com to prevent backlink cross-references on upstream issues.

Parameters:

text (str) – Raw markdown text from an external source.

Return type:

str

Returns:

Sanitized markdown safe for embedding in a GitHub PR or issue body.

Note

Only call this on externally-sourced content (upstream release notes, third-party tool output). Do not call on content authored by the repository owner where mentions are intentional.

repomatic.github.pr_body.demote_markdown_headings(text, floor)[source]

Demote ATX headings so the shallowest one lands at level floor.

Externally-sourced markdown (upstream release notes) carries its own # and ## headings, which GitHub renders at full size even inside a <details> block, so they compete with the embedding document’s section hierarchy. All headings are shifted deeper by the uniform offset that puts the shallowest at floor, preserving the body’s internal structure; levels past ###### (h6, markdown’s deepest) are clamped. Headings already at or below floor are left alone: this function never promotes.

Fenced code blocks are shielded with the same placeholder extraction as sanitize_markdown_mentions(), so # comments in shell samples survive. Only ATX headings are rewritten: setext headings (underlined with === or ---), rare in release notes, pass through unchanged.

Parameters:
  • text (str) – Raw markdown text from an external source.

  • floor (int) – Target level (1-6) for the shallowest heading.

Return type:

str

Returns:

Markdown with headings demoted.

repomatic.github.pr_body.load_template(name)[source]

Load a PR body template by name or filesystem path.

Dispatch is type-based:

  • str (e.g. "bump-version"): looked up as a packaged resource under repomatic.templates, cached for the process (see _load_bundled_template()). Tries {name}.md.noformat first, then {name}.md. The .md.noformat extension is used for templates whose string.Template placeholders confuse mdformat (e.g. $rerun_entry prefixed to a list line is parsed as literal text, breaking the list structure). See pr-metadata.md.noformat for the canonical example.

  • Path: read directly from the filesystem, never cached, so a downstream repo iterating on a project-specific template sees each edit.

Parameters:

name (str | Path) – Template name without extension, or a Path pointing to a template file.

Return type:

tuple[dict[str, object], str]

Returns:

A tuple of (frontmatter metadata dict, template body string).

Raises:

FileNotFoundError – If neither resource nor file exists.

repomatic.github.pr_body.render_template(*names, **kwargs)[source]

Load and render one or more templates with variable substitution.

When multiple template names are given, each is rendered and joined with a blank line. The generated-footer attribution is appended once at the end if any of the templates wants it (i.e. does not have footer: false in its frontmatter).

Static templates (no $variable placeholders) are returned as-is. Dynamic templates use string.Template ($variable syntax) to avoid conflicts with markdown braces like [tool.repomatic].

Consecutive blank lines left by empty variables are collapsed to a single blank line.

Note

The footer’s version is __version__ as read from the working tree, which makes it cosmetically wrong on the version-machinery PRs. That is accepted rather than fixed. Upstream runs the CLI from its own checkout (LOCAL_CLI_INVOCATION), and both the bump-version and prepare-release jobs rewrite __version__ with bump-my-version before this renders, so each of those bodies advertises the version its own PR produces (the minor or major bump target, or the post-release patch bump) instead of the identical code that rendered it. Both fixes cost more than the tag is worth: rendering the body before the bump means splitting pr-body back out of pr-sync, and feeding the pre-bump version in means a CLI option that exists only to work around step ordering. Downstream never sees it, since a frozen workflow runs uvx 'repomatic==X.Y.Z' and takes its version from the installed distribution.

Parameters:
  • names (str | Path) – One or more template names (without .md extension) or Path objects pointing to template files.

  • kwargs (str | None) – Variables to substitute into all templates.

Return type:

str

Returns:

The rendered markdown string.

repomatic.github.pr_body.render_title(name, **kwargs)[source]

Load and render a template’s PR title with variable substitution.

Parameters:
  • name (str | Path) – Template name without .md extension, or a Path pointing to a template file.

  • kwargs (str | None) – Variables to substitute into the title.

Return type:

str

Returns:

The rendered title string, or an empty string when the template has no title field in its frontmatter.

repomatic.github.pr_body.render_commit_message(name, **kwargs)[source]

Load and render a template’s commit message with variable substitution.

Falls back to the title if no commit_message is defined, and to an empty string if neither is set (templates without a title or commit message render only their body).

Parameters:
  • name (str | Path) – Template name without .md extension, or a Path pointing to a template file.

  • kwargs (str | None) – Variables to substitute into the commit message.

Return type:

str

Returns:

The rendered commit message string, or an empty string when the template defines neither commit_message nor title.

repomatic.github.pr_body.template_args(name)[source]

Return the list of required arguments for a template.

Parameters:

name (str | Path) – Template name without .md extension, or a Path pointing to a template file.

Return type:

list[str]

Returns:

List of argument names from the frontmatter args field.

repomatic.github.pr_body.template_labels(name)[source]

Return the labels a template’s pull request should carry.

Read from the labels: frontmatter key, accepting a YAML list or a single string. The template is the one place that knows which operation it fronts, so its labels live beside its title and commit message rather than being repeated in every workflow step that opens the PR.

Parameters:

name (str | Path) – Template name without extension, or a template file path.

Return type:

list[str]

Returns:

The labels, empty when the frontmatter declares none.

repomatic.github.pr_body.template_draft(name)[source]

Return whether a template’s pull request should be held in draft.

Read from the draft: frontmatter key. Both the YAML boolean and its quoted spelling are honored, mirroring the footer: key.

Parameters:

name (str | Path) – Template name without extension, or a template file path.

Return type:

bool

repomatic.github.pr_body.template_docs_url(name)[source]

Return a template’s documentation deep link, if it declares one.

PR templates carry a docs: frontmatter field pointing at their job’s section of the hosted workflows reference, surfaced as the Documentation entry of the metadata block now that PR bodies have no description section.

Parameters:

name (str | Path) – Template name without .md extension, or a Path pointing to a template file.

Return type:

str

Returns:

The URL from the frontmatter docs field, or an empty string.

repomatic.github.pr_body.template_stem(filename)[source]

Return a template’s name, shorn of its .md or .md.noformat extension.

The one place that knows how template filenames decompose: .md.noformat files are renamed .md files hidden from mdformat (see load_template()), so both extensions strip down to the same name. Callers derive a PR branch or a documentation label from a --template-file path with it, and get_template_names() names the bundled templates through it.

Parameters:

filename (str) – A template file’s basename.

Return type:

str

Returns:

The name with neither extension.

repomatic.github.pr_body.get_template_names()[source]

Discover all available template names from the templates package.

Return type:

list[str]

Returns:

Sorted list of template names (without .md extension).

repomatic.github.pr_body.generate_pr_metadata_block(md, docs_url='', docs_name='')[source]

Generate a collapsible metadata block from CI context.

Reads the GITHUB_* environment context from md and returns a markdown <details> block listing the workflow metadata fields.

Parameters:
  • md (Metadata) – The Metadata instance to read CI context from.

  • docs_url (str) – Optional deep link to the job’s section of the hosted workflows reference, rendered as the leading Documentation entry. Comes from the PR template’s docs: frontmatter field (see template_docs_url()).

  • docs_name (str) – Label for the documentation link, usually the template (operation) name. Without it the raw URL renders as an autolink.

Return type:

str

Returns:

A markdown string with the metadata block.

repomatic.github.pr_body.generate_refresh_tip(md)[source]

Generate a tip admonition inviting users to refresh the PR manually.

Reads the repository URL and GITHUB_WORKFLOW_REF from md to build the workflow dispatch URL.

Parameters:

md (Metadata) – The Metadata instance to read CI context from.

Return type:

str

Returns:

A GitHub-flavored markdown [!TIP] blockquote, or an empty string if the workflow reference is unavailable.

repomatic.github.pr_body.build_release_review_steps(md, version)[source]

Build the two optional review steps of the prepare-release checklist.

The How-to release list opens with two review steps that render only when their GitHub data is reachable, so the checklist degrades to the bare merge instructions offline (or when the dev pre-release is disabled):

  • Dev pre-release review: links the rolling v{version}.dev0 draft through its html_url (drafts have no public tag URL). Omitted when no such draft is visible.

  • Full-changes review: links the v{previous}...main comparison. Omitted when no prior release exists to compare against.

Both come from a single dev_release_url_and_previous_version() lookup. Each returned string is a complete ordered-list line ending in a newline (or empty), written with a 1. marker so the surrounding lazily numbered list renumbers correctly however many steps survive.

Parameters:
  • md (Metadata) – CI context, read for the repository URL.

  • version (str) – The release version being prepared (e.g. 1.2.3).

Return type:

tuple[str, str]

Returns:

A (dev_release_review, changes_review) pair of list-item lines.

repomatic.github.pr_body.build_pr_body(prefix, metadata_block, refresh_tip='')[source]

Concatenate prefix, refresh tip, and metadata block into a PR body.

The metadata_block already includes the attribution footer (appended automatically by render_template()); the refresh_tip comes pre-rendered from generate_refresh_tip() (empty to omit it).

Bodies over GITHUB_BODY_MAX_CHARS have their prefix trimmed to fit, replacing the dropped lines with a caution admonition, so the refresh tip, metadata block, and attribution footer always survive. Left alone, GitHub-side truncation would chop the body from the end instead.

Parameters:
  • prefix (str) – Content to prepend before the metadata block. Can be empty.

  • metadata_block (str) – The collapsible metadata block from generate_pr_metadata_block(), with footer.

  • refresh_tip (str) – Pre-rendered refresh-tip admonition, or empty.

Return type:

str

Returns:

The complete PR body string.

repomatic.github.pr_body.fit_github_body(body)[source]

Trim an oversized issue or pull-request body, keeping the footer.

The build_pr_body() counterpart for bodies rendered straight from a footer-carrying template (broken-links report, setup guide), and the safety net upsert_pr() runs over an explicit --body that never went through build_pr_body(). The GitHub API rejects oversized bodies outright (gh issue create, gh issue edit and gh pr create all fail), so the content above the attribution footer is trimmed on line boundaries, with a caution admonition marking the cut.

Parameters:

body (str) – The rendered body, attribution footer included.

Return type:

str

Returns:

The body unchanged when it fits, else trimmed to fit.

repomatic.github.pr_body.temp_body_file(body)[source]

Materialize a rendered body as a temporary file, then remove it.

The gh CLI takes a body only through --body-file, so every write path against an issue or a pull request needs one on disk. Owning the temp file at this layer keeps callers working in the currency they actually produce (rendered markdown) instead of each repeating the same write / try / unlink envelope.

Return type:

Iterator[Path]