From 0e2e719057cb94e5bfad171efda62563a7b07357 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Fri, 24 Jul 2026 19:34:35 +0200 Subject: [PATCH 1/4] Automate bot PR maintenance: weekly grouped updates, skip-news labeling, auto-merge --- .github/dependabot.yml | 23 +++++++++--- .github/workflows/bot-prs.yml | 54 +++++++++++++++++++++++++++++ cicd_utils/find-unmentioned-prs.sh | 9 +++++ docs/development/release_process.md | 2 +- 4 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/bot-prs.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fe105d09..5c673748 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,14 @@ # Dependabot configuration # +# Notes: +# - Updates are grouped into a single weekly PR per ecosystem to reduce +# review noise and avoid changelog merge conflicts between bot PRs. +# - The `skip news` label exempts bot PRs from the changelog check +# (see .github/workflows/check-release-notes.yml). Their changelog +# entries are added in bulk at release time with the help of the +# ./cicd_utils/find-unmentioned-prs.sh script. +# - Bot PRs are auto-merged once approved (see .github/workflows/bot-prs.yml). +# # References: # - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file # @@ -8,10 +17,16 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" - labels: [ "github_actions" ] + interval: "weekly" + labels: [ "github_actions", "skip news" ] + groups: + github-actions: + patterns: [ "*" ] - package-ecosystem: "pip" directory: "/" schedule: - interval: "daily" - labels: [ "dependencies" ] + interval: "weekly" + labels: [ "dependencies", "skip news" ] + groups: + pip: + patterns: [ "*" ] diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml new file mode 100644 index 00000000..7135a623 --- /dev/null +++ b/.github/workflows/bot-prs.yml @@ -0,0 +1,54 @@ +# Automation for PRs opened by trusted bots (dependabot and pre-commit.ci). +# +# For every bot PR, this workflow: +# 1. Adds the `skip news` label, which exempts the PR from the changelog +# check (dependabot PRs already get it via .github/dependabot.yml). +# The corresponding changelog entries are added in bulk at release +# time with the help of ./cicd_utils/find-unmentioned-prs.sh +# 2. Enables GitHub's native auto-merge (squash). The PR will then merge +# automatically as soon as the branch protection requirements are met +# (i.e., an approving review plus all required status checks passing). +# +# The only human action left is the review itself. +# +# Note: If auto-merge is enabled with the default GITHUB_TOKEN, the resulting +# merge commit will not trigger other workflows (e.g., the CI run on main or +# the TestPyPI publish). To get those post-merge runs, create a fine-grained +# PAT with contents:write and pull-requests:write scoped to this repository +# and store it as the AUTO_MERGE_PAT secret. This workflow falls back to +# GITHUB_TOKEN when the secret is not set. +# +# Security: this workflow runs on pull_request_target but never checks out +# or executes code from the PR branch. +# +name: Bot PRs + +on: + pull_request_target: + types: [ opened, reopened ] + +permissions: {} + +jobs: + automate: + name: Label and enable auto-merge + if: >- + github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'pre-commit-ci[bot]' + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: write + pull-requests: write + steps: + - name: Add 'skip news' label + run: gh pr edit "$PR_URL" --add-label 'skip news' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge (squash) + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} diff --git a/cicd_utils/find-unmentioned-prs.sh b/cicd_utils/find-unmentioned-prs.sh index 4f54ab3d..1c4df0ee 100755 --- a/cicd_utils/find-unmentioned-prs.sh +++ b/cicd_utils/find-unmentioned-prs.sh @@ -68,6 +68,7 @@ else echo "📋 PRs not mentioned in changelog (${#unmentioned_prs[@]} total):" echo + suggested_entries=() for pr in "${unmentioned_prs[@]}"; do # Get PR title and URL for better readability pr_info=$(gh pr view "$pr" --json title,url --jq '{title: .title, url: .url}') @@ -77,5 +78,13 @@ else echo " #$pr: $pr_title" echo " $pr_url" echo + + suggested_entries+=("- ${pr_title} ({gh-pr}\`${pr}\`)") + done + + echo "📝 Suggested changelog entries (review and paste under 'Unreleased changes'):" + echo + for entry in "${suggested_entries[@]}"; do + echo "$entry" done fi diff --git a/docs/development/release_process.md b/docs/development/release_process.md index 2d2f4927..4472de0b 100644 --- a/docs/development/release_process.md +++ b/docs/development/release_process.md @@ -6,7 +6,7 @@ You need to have push-access to the project's repository to make releases. Therefore, the following release steps are intended to be used as a reference for maintainers or [collaborators](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/permission-levels-for-a-personal-account-repository#collaborator-access-for-a-repository-owned-by-a-personal-account) with push-access to the repository. ::: -1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet. +1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet. Note that PRs opened by trusted bots (e.g., dependabot and pre-commit.ci) are automatically labeled with `skip news` and auto-merged once approved (see {repo-file}`.github/workflows/bot-prs.yml`), so their changelog entries are intentionally deferred to this step: the helper script prints ready-to-paste entries for them (these typically belong under a _CI/CD_ subsection). 2. [Review](https://github.com/tpvasconcelos/ridgeplot/compare) new usages of `.. versionadded::`, `.. versionchanged::`, and `.. deprecated::` directives that were added to the documentation since the last release. If necessary, update the version numbers in these directives to reflect the new release version. * You can determine the latest release version by running `git describe --tags --abbrev=0` on the `main` branch. Based on this, you can determine the next release version by incrementing the relevant _MAJOR_, _MINOR_, or _PATCH_ numbers. 3. **IMPORTANT:** Remember to switch to the `main` branch and pull the latest changes before proceeding. From 43fbf03bc9de0a083c1770a277de5d20f4d6a41e Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Fri, 24 Jul 2026 19:37:55 +0200 Subject: [PATCH 2/4] Add changelog entry for bot PR automation --- docs/reference/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index 69f0e29e..89477376 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -19,6 +19,7 @@ Unreleased changes - Bump actions/checkout from 6 to 7 ({gh-pr}`383`) - pre-commit autoupdate ({gh-pr}`379`) - Fix the test suite's compatibility with the latest pytest release ({gh-pr}`384`) +- Automate bot PR maintenance: weekly grouped dependabot updates, automatic `skip news` labeling, and auto-merge once approved ({gh-pr}`385`) --- From d52675d80d5f0a0d3289727af89bcc8cab7aa18c Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos Date: Sat, 25 Jul 2026 07:40:04 +0200 Subject: [PATCH 3/4] Rework bot PR automation: commit changelog entries directly to bot PRs --- .github/dependabot.yml | 11 +- .github/workflows/bot-prs.yml | 157 ++++++++-- .../cicd/scripts/add_changelog_entry.py | 193 ++++++++++++ docs/development/release_process.md | 2 +- docs/reference/changelog.md | 2 +- .../test_scripts/test_add_changelog_entry.py | 295 ++++++++++++++++++ 6 files changed, 628 insertions(+), 32 deletions(-) create mode 100755 cicd_utils/cicd/scripts/add_changelog_entry.py create mode 100644 tests/cicd_utils/test_scripts/test_add_changelog_entry.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5c673748..12f3204a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,11 +3,8 @@ # Notes: # - Updates are grouped into a single weekly PR per ecosystem to reduce # review noise and avoid changelog merge conflicts between bot PRs. -# - The `skip news` label exempts bot PRs from the changelog check -# (see .github/workflows/check-release-notes.yml). Their changelog -# entries are added in bulk at release time with the help of the -# ./cicd_utils/find-unmentioned-prs.sh script. -# - Bot PRs are auto-merged once approved (see .github/workflows/bot-prs.yml). +# - Bot PRs get an automated changelog entry commit and are auto-merged +# once approved (see .github/workflows/bot-prs.yml). # # References: # - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file @@ -18,7 +15,7 @@ updates: directory: "/" schedule: interval: "weekly" - labels: [ "github_actions", "skip news" ] + labels: [ "github_actions" ] groups: github-actions: patterns: [ "*" ] @@ -26,7 +23,7 @@ updates: directory: "/" schedule: interval: "weekly" - labels: [ "dependencies", "skip news" ] + labels: [ "dependencies" ] groups: pip: patterns: [ "*" ] diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml index 7135a623..7e036178 100644 --- a/.github/workflows/bot-prs.yml +++ b/.github/workflows/bot-prs.yml @@ -1,54 +1,165 @@ # Automation for PRs opened by trusted bots (dependabot and pre-commit.ci). # # For every bot PR, this workflow: -# 1. Adds the `skip news` label, which exempts the PR from the changelog -# check (dependabot PRs already get it via .github/dependabot.yml). -# The corresponding changelog entries are added in bulk at release -# time with the help of ./cicd_utils/find-unmentioned-prs.sh +# 1. Commits a changelog entry for the PR (generated from the PR's title) +# directly to the PR branch, so that the "Check for entry in Changelog" +# requirement is satisfied and the entry can be reviewed as part of the +# PR itself. This step is idempotent and self-healing: if a bot +# force-pushes its branch (wiping our commit), the resulting +# `synchronize` event simply re-adds the entry. # 2. Enables GitHub's native auto-merge (squash). The PR will then merge # automatically as soon as the branch protection requirements are met # (i.e., an approving review plus all required status checks passing). # +# Additionally, on every push to main, the `heal` job checks whether any +# open bot PRs became conflicted (e.g., two bot PRs adding changelog +# entries at the same location: the first one to merge conflicts the +# other) and resolves them by merging main into the PR branch and +# regenerating the changelog entry. +# # The only human action left is the review itself. # -# Note: If auto-merge is enabled with the default GITHUB_TOKEN, the resulting -# merge commit will not trigger other workflows (e.g., the CI run on main or -# the TestPyPI publish). To get those post-merge runs, create a fine-grained -# PAT with contents:write and pull-requests:write scoped to this repository -# and store it as the AUTO_MERGE_PAT secret. This workflow falls back to -# GITHUB_TOKEN when the secret is not set. +# Note: If the AUTO_MERGE_PAT secret is not set, this workflow falls back to +# the default GITHUB_TOKEN. This comes with a significant caveat: pushes and +# merges performed with GITHUB_TOKEN do not trigger other workflows, meaning +# that (a) CI checks will not run against the changelog commits pushed to +# bot PRs, and (b) post-merge workflows on main (CI run, TestPyPI publish) +# will not be triggered. For the full experience, create a fine-grained PAT +# with contents:write and pull-requests:write scoped to this repository and +# store it as the AUTO_MERGE_PAT secret. # -# Security: this workflow runs on pull_request_target but never checks out -# or executes code from the PR branch. +# Security: this workflow runs on pull_request_target with write +# permissions, but only ever acts on same-repository PRs opened by +# trusted bots, and never executes code from the PR branch. # name: Bot PRs on: pull_request_target: - types: [ opened, reopened ] + types: [ opened, reopened, synchronize ] + push: + branches: [ main ] permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + jobs: - automate: - name: Label and enable auto-merge + changelog: + name: Add changelog entry if: >- - github.event.pull_request.user.login == 'dependabot[bot]' || - github.event.pull_request.user.login == 'pre-commit-ci[bot]' + github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name == github.repository && + (github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'pre-commit-ci[bot]') runs-on: ubuntu-latest - timeout-minutes: 2 + timeout-minutes: 3 permissions: contents: write - pull-requests: write steps: - - name: Add 'skip news' label - run: gh pr edit "$PR_URL" --add-label 'skip news' + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + + - name: Add changelog entry and push env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$PR_NUMBER" "$PR_TITLE" + if git diff --quiet -- docs/reference/changelog.md; then + echo "Changelog entry already present; nothing to do." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/reference/changelog.md + git commit -m "Add changelog entry for #${PR_NUMBER}" + git push + automerge: + name: Enable auto-merge + if: >- + github.event_name == 'pull_request_target' && + github.event.action != 'synchronize' && + github.event.pull_request.head.repo.full_name == github.repository && + (github.event.pull_request.user.login == 'dependabot[bot]' || + github.event.pull_request.user.login == 'pre-commit-ci[bot]') + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: write + pull-requests: write + steps: - name: Enable auto-merge (squash) run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + + heal: + name: Heal conflicted bot PRs + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + + - name: Merge main into conflicted bot PRs + env: + GH_TOKEN: ${{ secrets.AUTO_MERGE_PAT || secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + prs=$( + gh pr list --author 'app/dependabot' --json number,headRefName,title --jq '.[] | [.number, .headRefName, .title] | @tsv' + gh pr list --author 'app/pre-commit-ci' --json number,headRefName,title --jq '.[] | [.number, .headRefName, .title] | @tsv' + ) + [ -n "$prs" ] || { echo "No open bot PRs."; exit 0; } + + while IFS=$'\t' read -r number head_ref title; do + [ -n "$number" ] || continue + + # Wait for GitHub to finish computing the mergeable state + mergeable=UNKNOWN + for _ in 1 2 3 4 5; do + mergeable=$(gh pr view "$number" --json mergeable --jq .mergeable) + [ "$mergeable" = "UNKNOWN" ] || break + sleep 10 + done + echo "PR #${number} (${head_ref}) is ${mergeable}" + [ "$mergeable" = "CONFLICTING" ] || continue + + git fetch origin "$head_ref" < /dev/null + git checkout -B "$head_ref" "origin/$head_ref" < /dev/null + + if git merge --no-edit origin/main < /dev/null; then + git push origin "HEAD:$head_ref" < /dev/null + continue + fi + + conflicts=$(git diff --name-only --diff-filter=U) + if [ "$conflicts" != "docs/reference/changelog.md" ]; then + git merge --abort < /dev/null + echo "::warning::PR #${number} has conflicts beyond the changelog; skipping." + continue + fi + + # Resolve the changelog conflict by taking main's version + # and re-generating this PR's changelog entry on top of it + git checkout --theirs docs/reference/changelog.md < /dev/null + python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$number" "$title" + git add docs/reference/changelog.md + git commit --no-edit < /dev/null + git push origin "HEAD:$head_ref" < /dev/null + done <<< "$prs" diff --git a/cicd_utils/cicd/scripts/add_changelog_entry.py b/cicd_utils/cicd/scripts/add_changelog_entry.py new file mode 100755 index 00000000..b1c42492 --- /dev/null +++ b/cicd_utils/cicd/scripts/add_changelog_entry.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python +"""Add a changelog entry for a given pull request. + +Inserts a ``- ({gh-pr}`<number>`)`` entry into the ``### CI/CD`` +subsection of the ``Unreleased changes`` section of the changelog, creating +the subsection (or the whole section) if it doesn't exist yet. + +This script is idempotent: if the changelog already references the given +PR number, the file is left unchanged. + +Used by the ``.github/workflows/bot-prs.yml`` workflow to automatically add +changelog entries to pull requests opened by trusted bots (e.g., dependabot +and pre-commit.ci). + +Usage: + python cicd_utils/cicd/scripts/add_changelog_entry.py <pr-number> <pr-title> +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +PATH_ROOT_DIR = Path(__file__).parents[3] +PATH_TO_CHANGELOG = PATH_ROOT_DIR.joinpath("docs/reference/changelog.md") + +UNRELEASED_HEADING = "Unreleased changes" +CICD_HEADING = "### CI/CD" + +# Known bot prefixes that should be stripped from PR titles to +# match the changelog's entry conventions (e.g., the convention +# for pre-commit.ci PRs is simply "pre-commit autoupdate"). +STRIP_TITLE_PREFIXES = ("[pre-commit.ci] ",) + + +def format_entry(pr_number: int, pr_title: str) -> str: + """Format a changelog entry for the given PR number and title.""" + title = pr_title.strip() + for prefix in STRIP_TITLE_PREFIXES: + title = title.removeprefix(prefix) + return f"- {title} ({{gh-pr}}`{pr_number}`)" + + +def _is_setext_underline(lines: list[str], i: int) -> bool: + """Whether ``lines[i]`` is a setext heading underline (e.g., ``-----``). + + A dash-only line is a setext underline if it directly follows a + non-blank line. Otherwise, it is a thematic break (e.g., ``---``). + """ + if not re.fullmatch(r"-{2,}", lines[i].strip()): + return False + return i > 0 and bool(lines[i - 1].strip()) + + +def _is_thematic_break(lines: list[str], i: int) -> bool: + """Whether ``lines[i]`` is a thematic break (e.g., ``---``).""" + return bool(re.fullmatch(r"-{3,}", lines[i].strip())) and not _is_setext_underline(lines, i) + + +def _find_unreleased_section(lines: list[str]) -> tuple[int, int] | None: + """Find the (start, end) line indices of the 'Unreleased changes' section. + + ``start`` points at the section's heading text line and ``end`` at the + heading text line of the next section (or one past the last line). + Returns :data:`None` if the section doesn't exist. + """ + start = None + for i in range(len(lines) - 1): + if lines[i].strip() == UNRELEASED_HEADING and _is_setext_underline(lines, i + 1): + start = i + break + if start is None: + return None + for j in range(start + 2, len(lines)): + if _is_setext_underline(lines, j): + return start, j - 1 + return start, len(lines) + + +def _new_unreleased_section(entry: str) -> list[str]: + return [ + UNRELEASED_HEADING, + "-" * len(UNRELEASED_HEADING), + "", + CICD_HEADING, + "", + entry, + "", + "---", + "", + ] + + +def _insert_unreleased_section(lines: list[str], entry: str) -> list[str]: + """Insert a whole new 'Unreleased changes' section before the first section.""" + for i in range(len(lines)): + if _is_setext_underline(lines, i): + first_heading = i - 1 + return [ + *lines[:first_heading], + *_new_unreleased_section(entry), + *lines[first_heading:], + ] + # No sections yet (e.g., an empty changelog): append at the end, making + # sure that the new section's heading is preceded by a blank line + # (otherwise the preceding paragraph would absorb the setext heading) + if lines and lines[-1].strip(): + lines = [*lines, ""] + return [*lines, *_new_unreleased_section(entry)] + + +def _insert_cicd_subsection(lines: list[str], start: int, end: int, entry: str) -> list[str]: + """Insert a new '### CI/CD' subsection at the end of the 'Unreleased changes' section.""" + # Insert before the section's trailing thematic break (if any) + insert_at = end + for i in range(start + 2, end): + if _is_thematic_break(lines, i): + insert_at = i + break + # Walk back over any blank lines + while insert_at > start + 2 and not lines[insert_at - 1].strip(): + insert_at -= 1 + return [*lines[:insert_at], "", CICD_HEADING, "", entry, *lines[insert_at:]] + + +def _append_to_cicd_subsection(lines: list[str], cicd_at: int, end: int, entry: str) -> list[str]: + """Append an entry at the end of an existing '### CI/CD' subsection.""" + # The subsection ends at the next subsection heading, the section's + # trailing thematic break, or the end of the section (whichever + # comes first). + insert_at = end + for i in range(cicd_at + 1, end): + if lines[i].startswith("### ") or _is_thematic_break(lines, i): + insert_at = i + break + # Walk back over any blank lines + while insert_at > cicd_at + 1 and not lines[insert_at - 1].strip(): + insert_at -= 1 + return [*lines[:insert_at], entry, *lines[insert_at:]] + + +def add_changelog_entry(changelog: Path, pr_number: int, pr_title: str) -> bool: + """Add a changelog entry for the given PR. Returns whether the file was changed.""" + text = changelog.read_text() + if f"{{gh-pr}}`{pr_number}`" in text: + print(f"Changelog already references PR #{pr_number}; nothing to do.") + return False + + entry = format_entry(pr_number, pr_title) + lines = text.splitlines() + + section = _find_unreleased_section(lines) + if section is None: + lines = _insert_unreleased_section(lines, entry) + else: + start, end = section + cicd_at = next( + (i for i in range(start + 2, end) if lines[i].strip() == CICD_HEADING), + None, + ) + if cicd_at is None: + lines = _insert_cicd_subsection(lines, start, end, entry) + else: + lines = _append_to_cicd_subsection(lines, cicd_at, end, entry) + + while lines and not lines[-1].strip(): + lines.pop() + changelog.write_text("\n".join(lines) + "\n") + print(f"Added changelog entry: {entry}") + return True + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("pr_number", type=int, help="The pull request number.") + parser.add_argument("pr_title", type=str, help="The pull request title.") + parser.add_argument( + "--changelog", + type=Path, + default=PATH_TO_CHANGELOG, + help="Path to the changelog file.", + ) + args = parser.parse_args() + add_changelog_entry( + changelog=args.changelog, + pr_number=args.pr_number, + pr_title=args.pr_title, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/development/release_process.md b/docs/development/release_process.md index 4472de0b..d7b81bb9 100644 --- a/docs/development/release_process.md +++ b/docs/development/release_process.md @@ -6,7 +6,7 @@ You need to have push-access to the project's repository to make releases. Therefore, the following release steps are intended to be used as a reference for maintainers or [collaborators](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/permission-levels-for-a-personal-account-repository#collaborator-access-for-a-repository-owned-by-a-personal-account) with push-access to the repository. ::: -1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet. Note that PRs opened by trusted bots (e.g., dependabot and pre-commit.ci) are automatically labeled with `skip news` and auto-merged once approved (see {repo-file}`.github/workflows/bot-prs.yml`), so their changelog entries are intentionally deferred to this step: the helper script prints ready-to-paste entries for them (these typically belong under a _CI/CD_ subsection). +1. Review the **`## Unreleased changes`** section at the top of the {repo-file}`docs/reference/changelog.md` file and, if necessary, group and/or split entries into relevant subsections (e.g., _Features_, _Docs_, _Bugfixes_, _Security_, etc.). Take a look at previous release notes for guidance and try to keep the format consistent. You can also use the `./cicd_utils/find-unmentioned-prs.sh` helper script to find merged PRs that were not mentioned in the changelog yet (if any are found, it prints ready-to-paste changelog entries for them). Note that PRs opened by trusted bots (e.g., dependabot and pre-commit.ci) get an automated changelog entry commit and are auto-merged once approved (see {repo-file}`.github/workflows/bot-prs.yml`), so they should already be covered in the changelog. 2. [Review](https://github.com/tpvasconcelos/ridgeplot/compare) new usages of `.. versionadded::`, `.. versionchanged::`, and `.. deprecated::` directives that were added to the documentation since the last release. If necessary, update the version numbers in these directives to reflect the new release version. * You can determine the latest release version by running `git describe --tags --abbrev=0` on the `main` branch. Based on this, you can determine the next release version by incrementing the relevant _MAJOR_, _MINOR_, or _PATCH_ numbers. 3. **IMPORTANT:** Remember to switch to the `main` branch and pull the latest changes before proceeding. diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index 44a455c4..3c885a10 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -30,7 +30,7 @@ Unreleased changes - Bump actions/checkout from 6 to 7 ({gh-pr}`383`) - pre-commit autoupdate ({gh-pr}`379`) - Fix the test suite's compatibility with the latest pytest release ({gh-pr}`384`) -- Automate bot PR maintenance: weekly grouped dependabot updates, automatic `skip news` labeling, and auto-merge once approved ({gh-pr}`385`) +- Automate bot PR maintenance: weekly grouped dependabot updates, automated changelog entries, and auto-merge once approved ({gh-pr}`385`) - Use the official `astral-sh/setup-uv` action to install and cache `uv` in CI ({gh-pr}`386`) --- diff --git a/tests/cicd_utils/test_scripts/test_add_changelog_entry.py b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py new file mode 100644 index 00000000..9d438bb9 --- /dev/null +++ b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from cicd.scripts.add_changelog_entry import ( + PATH_TO_CHANGELOG, + add_changelog_entry, + format_entry, + main, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def test_path_to_changelog_exists() -> None: + assert PATH_TO_CHANGELOG.exists() + assert PATH_TO_CHANGELOG.is_file() + + +@pytest.mark.parametrize( + ("pr_number", "pr_title", "expected"), + [ + ( + 383, + "Bump actions/checkout from 6 to 7", + "- Bump actions/checkout from 6 to 7 ({gh-pr}`383`)", + ), + (379, "[pre-commit.ci] pre-commit autoupdate", "- pre-commit autoupdate ({gh-pr}`379`)"), + (42, " Padded title ", "- Padded title ({gh-pr}`42`)"), + ], +) +def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: + assert format_entry(pr_number, pr_title) == expected + + +CHANGELOG_WITH_CICD_SUBSECTION = """\ +# Release Notes + +Intro paragraph... + +Unreleased changes +------------------ + +### CI/CD + +- Old entry ({gh-pr}`100`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITH_CICD_SUBSECTION = """\ +# Release Notes + +Intro paragraph... + +Unreleased changes +------------------ + +### CI/CD + +- Old entry ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITHOUT_CICD_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### Bug fixes + +- Fix something ({gh-pr}`101`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITHOUT_CICD_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### Bug fixes + +- Fix something ({gh-pr}`101`) + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITHOUT_UNRELEASED_SECTION = """\ +# Release Notes + +Intro paragraph... + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITHOUT_UNRELEASED_SECTION = """\ +# Release Notes + +Intro paragraph... + +Unreleased changes +------------------ + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITHOUT_ANY_SECTIONS = """\ +# Release Notes + +Intro paragraph... +""" + +EXPECTED_WITHOUT_ANY_SECTIONS = """\ +# Release Notes + +Intro paragraph... + +Unreleased changes +------------------ + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- +""" + +CHANGELOG_WITH_TRAILING_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### CI/CD + +- Old entry ({gh-pr}`100`) + +### Documentation + +- Documentation change ({gh-pr}`101`) +""" + +EXPECTED_WITH_TRAILING_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### CI/CD + +- Old entry ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) + +### Documentation + +- Documentation change ({gh-pr}`101`) +""" + +CHANGELOG_WITH_LOOSE_ENTRIES = """\ +# Release Notes + +Unreleased changes +------------------ + +- Loose entry ({gh-pr}`100`) +""" + +EXPECTED_WITH_LOOSE_ENTRIES = """\ +# Release Notes + +Unreleased changes +------------------ + +- Loose entry ({gh-pr}`100`) + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) +""" + + +@pytest.mark.parametrize( + ("changelog_content", "expected_content"), + [ + (CHANGELOG_WITH_CICD_SUBSECTION, EXPECTED_WITH_CICD_SUBSECTION), + (CHANGELOG_WITHOUT_CICD_SUBSECTION, EXPECTED_WITHOUT_CICD_SUBSECTION), + (CHANGELOG_WITHOUT_UNRELEASED_SECTION, EXPECTED_WITHOUT_UNRELEASED_SECTION), + (CHANGELOG_WITHOUT_ANY_SECTIONS, EXPECTED_WITHOUT_ANY_SECTIONS), + (CHANGELOG_WITH_TRAILING_SUBSECTION, EXPECTED_WITH_TRAILING_SUBSECTION), + (CHANGELOG_WITH_LOOSE_ENTRIES, EXPECTED_WITH_LOOSE_ENTRIES), + ], + ids=[ + "existing-cicd-subsection", + "missing-cicd-subsection", + "missing-unreleased-section", + "missing-any-sections", + "trailing-subsection", + "loose-entries", + ], +) +def test_add_changelog_entry(changelog_content: str, expected_content: str, tmp_path: Path) -> None: + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text(changelog_content) + changed = add_changelog_entry( + changelog=changelog_path, pr_number=123, pr_title="Bump foo from 1 to 2" + ) + assert changed is True + assert changelog_path.read_text() == expected_content + + +def test_add_changelog_entry_to_real_changelog(tmp_path: Path) -> None: + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text(PATH_TO_CHANGELOG.read_text()) + changed = add_changelog_entry( + changelog=changelog_path, pr_number=999999, pr_title="Bump foo from 1 to 2" + ) + assert changed is True + text = changelog_path.read_text() + entry = "- Bump foo from 1 to 2 ({gh-pr}`999999`)" + assert entry in text + # The new entry should land in the unreleased section (i.e., before + # the first released version's section) + first_release_at = text.index("\n0.") + assert text.index(entry) < first_release_at + + +def test_add_changelog_entry_is_idempotent(tmp_path: Path) -> None: + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text(CHANGELOG_WITH_CICD_SUBSECTION) + changed = add_changelog_entry( + changelog=changelog_path, pr_number=100, pr_title="Some already mentioned PR" + ) + assert changed is False + assert changelog_path.read_text() == CHANGELOG_WITH_CICD_SUBSECTION + + +def test_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + changelog_path = tmp_path / "changelog.md" + changelog_path.write_text(CHANGELOG_WITH_CICD_SUBSECTION) + monkeypatch.setattr( + "sys.argv", + [ + "add_changelog_entry.py", + "123", + "Bump foo from 1 to 2", + "--changelog", + str(changelog_path), + ], + ) + main() + assert changelog_path.read_text() == EXPECTED_WITH_CICD_SUBSECTION From 916d9d5ec2a39e0dabf71be4b13e10bbe47a21c5 Mon Sep 17 00:00:00 2001 From: Tomas Pereira de Vasconcelos <tomasvasconcelos1@gmail.com> Date: Sat, 25 Jul 2026 13:50:11 +0200 Subject: [PATCH 4/4] Rewrite add_changelog_entry.py using markdown-it-py source maps --- .github/workflows/bot-prs.yml | 2 + .../cicd/scripts/add_changelog_entry.py | 161 ++++++++++-------- requirements/cicd_utils.txt | 1 + .../test_scripts/test_add_changelog_entry.py | 109 ++++++++++++ 4 files changed, 200 insertions(+), 73 deletions(-) diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml index 7e036178..cf792589 100644 --- a/.github/workflows/bot-prs.yml +++ b/.github/workflows/bot-prs.yml @@ -69,6 +69,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} run: | + python3 -m pip install --quiet markdown-it-py python3 cicd_utils/cicd/scripts/add_changelog_entry.py "$PR_NUMBER" "$PR_TITLE" if git diff --quiet -- docs/reference/changelog.md; then echo "Changelog entry already present; nothing to do." @@ -120,6 +121,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + python3 -m pip install --quiet markdown-it-py prs=$( gh pr list --author 'app/dependabot' --json number,headRefName,title --jq '.[] | [.number, .headRefName, .title] | @tsv' diff --git a/cicd_utils/cicd/scripts/add_changelog_entry.py b/cicd_utils/cicd/scripts/add_changelog_entry.py index b1c42492..9e9676b9 100755 --- a/cicd_utils/cicd/scripts/add_changelog_entry.py +++ b/cicd_utils/cicd/scripts/add_changelog_entry.py @@ -5,6 +5,12 @@ subsection of the ``Unreleased changes`` section of the changelog, creating the subsection (or the whole section) if it doesn't exist yet. +The changelog's structure is discovered with ``markdown-it-py`` (using the +tokens' source line maps), while the actual edit is a surgical line splice. +This keeps the rest of the file byte-for-byte untouched (as opposed to, +e.g., re-rendering the whole document with ``mdformat``, which would +restyle it). + This script is idempotent: if the changelog already references the given PR number, the file is left unchanged. @@ -19,14 +25,19 @@ from __future__ import annotations import argparse -import re from pathlib import Path +from typing import TYPE_CHECKING + +from markdown_it import MarkdownIt + +if TYPE_CHECKING: + from markdown_it.token import Token PATH_ROOT_DIR = Path(__file__).parents[3] PATH_TO_CHANGELOG = PATH_ROOT_DIR.joinpath("docs/reference/changelog.md") UNRELEASED_HEADING = "Unreleased changes" -CICD_HEADING = "### CI/CD" +CICD_HEADING = "CI/CD" # Known bot prefixes that should be stripped from PR titles to # match the changelog's entry conventions (e.g., the convention @@ -42,40 +53,39 @@ def format_entry(pr_number: int, pr_title: str) -> str: return f"- {title} ({{gh-pr}}`{pr_number}`)" -def _is_setext_underline(lines: list[str], i: int) -> bool: - """Whether ``lines[i]`` is a setext heading underline (e.g., ``-----``). +def _token_lines(token: Token) -> tuple[int, int]: + """Return a block token's source line range as an ``(start, end)`` tuple.""" + if token.map is None: + raise AssertionError("Block-level tokens always carry a source line map") + return token.map[0], token.map[1] - A dash-only line is a setext underline if it directly follows a - non-blank line. Otherwise, it is a thematic break (e.g., ``---``). - """ - if not re.fullmatch(r"-{2,}", lines[i].strip()): - return False - return i > 0 and bool(lines[i - 1].strip()) - -def _is_thematic_break(lines: list[str], i: int) -> bool: - """Whether ``lines[i]`` is a thematic break (e.g., ``---``).""" - return bool(re.fullmatch(r"-{3,}", lines[i].strip())) and not _is_setext_underline(lines, i) +def _is_heading(tokens: list[Token], i: int, tag: str, text: str | None = None) -> bool: + """Whether ``tokens[i]`` opens a heading with the given tag (and text).""" + token = tokens[i] + if token.type != "heading_open" or token.tag != tag: + return False + return text is None or tokens[i + 1].content == text -def _find_unreleased_section(lines: list[str]) -> tuple[int, int] | None: - """Find the (start, end) line indices of the 'Unreleased changes' section. +def _find_unreleased_section(tokens: list[Token]) -> tuple[int, int] | None: + """Find the (start, end) token index range of the 'Unreleased changes' section. - ``start`` points at the section's heading text line and ``end`` at the - heading text line of the next section (or one past the last line). - Returns :data:`None` if the section doesn't exist. + ``start`` points at the first token after the section's heading and + ``end`` at the next section's ``heading_open`` token (or one past the + last token). Returns :data:`None` if the section doesn't exist. """ start = None - for i in range(len(lines) - 1): - if lines[i].strip() == UNRELEASED_HEADING and _is_setext_underline(lines, i + 1): - start = i - break + for i in range(len(tokens)): + if not _is_heading(tokens, i, tag="h2"): + continue + if start is not None: + return start, i + if _is_heading(tokens, i, tag="h2", text=UNRELEASED_HEADING): + start = i + 3 # skip the heading_open, inline, and heading_close tokens if start is None: return None - for j in range(start + 2, len(lines)): - if _is_setext_underline(lines, j): - return start, j - 1 - return start, len(lines) + return start, len(tokens) def _new_unreleased_section(entry: str) -> list[str]: @@ -83,7 +93,7 @@ def _new_unreleased_section(entry: str) -> list[str]: UNRELEASED_HEADING, "-" * len(UNRELEASED_HEADING), "", - CICD_HEADING, + f"### {CICD_HEADING}", "", entry, "", @@ -92,16 +102,12 @@ def _new_unreleased_section(entry: str) -> list[str]: ] -def _insert_unreleased_section(lines: list[str], entry: str) -> list[str]: +def _insert_unreleased_section(lines: list[str], tokens: list[Token], entry: str) -> list[str]: """Insert a whole new 'Unreleased changes' section before the first section.""" - for i in range(len(lines)): - if _is_setext_underline(lines, i): - first_heading = i - 1 - return [ - *lines[:first_heading], - *_new_unreleased_section(entry), - *lines[first_heading:], - ] + for i in range(len(tokens)): + if _is_heading(tokens, i, tag="h2"): + at = _token_lines(tokens[i])[0] + return [*lines[:at], *_new_unreleased_section(entry), *lines[at:]] # No sections yet (e.g., an empty changelog): append at the end, making # sure that the new section's heading is preceded by a blank line # (otherwise the preceding paragraph would absorb the setext heading) @@ -110,34 +116,37 @@ def _insert_unreleased_section(lines: list[str], entry: str) -> list[str]: return [*lines, *_new_unreleased_section(entry)] -def _insert_cicd_subsection(lines: list[str], start: int, end: int, entry: str) -> list[str]: - """Insert a new '### CI/CD' subsection at the end of the 'Unreleased changes' section.""" +def _find_cicd_insertion(tokens: list[Token], start: int, end: int) -> int | None: + """Find the source line at which to insert an entry into the '### CI/CD' subsection. + + Returns the line right after the subsection's last bullet list (or right + after its heading, if it contains no list yet), or :data:`None` if the + subsection doesn't exist. + """ + insert_at = None + for i in range(start, end): + token = tokens[i] + if token.type == "heading_open": + if insert_at is not None: + break # reached the next subsection + if _is_heading(tokens, i, tag="h3", text=CICD_HEADING): + insert_at = _token_lines(token)[1] + elif insert_at is not None and token.type == "hr": + break # reached the section's trailing thematic break + elif insert_at is not None and token.type == "bullet_list_open" and token.level == 0: + insert_at = _token_lines(token)[1] + return insert_at + + +def _find_subsection_insertion(tokens: list[Token], start: int, end: int, n_lines: int) -> int: + """Find the source line at which to insert a new subsection at the end of the section.""" # Insert before the section's trailing thematic break (if any) - insert_at = end - for i in range(start + 2, end): - if _is_thematic_break(lines, i): - insert_at = i - break - # Walk back over any blank lines - while insert_at > start + 2 and not lines[insert_at - 1].strip(): - insert_at -= 1 - return [*lines[:insert_at], "", CICD_HEADING, "", entry, *lines[insert_at:]] - - -def _append_to_cicd_subsection(lines: list[str], cicd_at: int, end: int, entry: str) -> list[str]: - """Append an entry at the end of an existing '### CI/CD' subsection.""" - # The subsection ends at the next subsection heading, the section's - # trailing thematic break, or the end of the section (whichever - # comes first). - insert_at = end - for i in range(cicd_at + 1, end): - if lines[i].startswith("### ") or _is_thematic_break(lines, i): - insert_at = i - break - # Walk back over any blank lines - while insert_at > cicd_at + 1 and not lines[insert_at - 1].strip(): - insert_at -= 1 - return [*lines[:insert_at], entry, *lines[insert_at:]] + for i in range(start, end): + if tokens[i].type == "hr": + return _token_lines(tokens[i])[0] + if end < len(tokens): + return _token_lines(tokens[end])[0] + return n_lines def add_changelog_entry(changelog: Path, pr_number: int, pr_title: str) -> bool: @@ -149,20 +158,26 @@ def add_changelog_entry(changelog: Path, pr_number: int, pr_title: str) -> bool: entry = format_entry(pr_number, pr_title) lines = text.splitlines() + tokens = MarkdownIt().parse(text) - section = _find_unreleased_section(lines) + section = _find_unreleased_section(tokens) if section is None: - lines = _insert_unreleased_section(lines, entry) + lines = _insert_unreleased_section(lines, tokens, entry) else: start, end = section - cicd_at = next( - (i for i in range(start + 2, end) if lines[i].strip() == CICD_HEADING), - None, - ) - if cicd_at is None: - lines = _insert_cicd_subsection(lines, start, end, entry) + insert_at = _find_cicd_insertion(tokens, start, end) + if insert_at is None: + insert_at = _find_subsection_insertion(tokens, start, end, len(lines)) + new_lines = ["", f"### {CICD_HEADING}", "", entry] else: - lines = _append_to_cicd_subsection(lines, cicd_at, end, entry) + new_lines = [entry] + # Walk back over any blank lines + while insert_at > 0 and not lines[insert_at - 1].strip(): + insert_at -= 1 + if new_lines == [entry] and lines[insert_at - 1].lstrip().startswith("#"): + # Keep a blank line between a heading and the first entry + new_lines = ["", entry] + lines[insert_at:insert_at] = new_lines while lines and not lines[-1].strip(): lines.pop() diff --git a/requirements/cicd_utils.txt b/requirements/cicd_utils.txt index 6cd107d9..ea70970d 100644 --- a/requirements/cicd_utils.txt +++ b/requirements/cicd_utils.txt @@ -4,6 +4,7 @@ minify-html kaleido<0.4 # ./cicd_utils/cicd/scripts/extract_latest_release_notes.py +# ./cicd_utils/cicd/scripts/add_changelog_entry.py (markdown-it-py only) markdown-it-py mdit-py-plugins mdformat diff --git a/tests/cicd_utils/test_scripts/test_add_changelog_entry.py b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py index 9d438bb9..75b07f2d 100644 --- a/tests/cicd_utils/test_scripts/test_add_changelog_entry.py +++ b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py @@ -222,6 +222,109 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: - Bump foo from 1 to 2 ({gh-pr}`123`) """ +CHANGELOG_WITH_ATX_HEADINGS = """\ +# Release Notes + +## Unreleased changes + +### CI/CD + +- Old entry ({gh-pr}`100`) + +--- + +## 0.1.0 + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITH_ATX_HEADINGS = """\ +# Release Notes + +## Unreleased changes + +### CI/CD + +- Old entry ({gh-pr}`100`) +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +## 0.1.0 + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITH_EMPTY_CICD_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### CI/CD + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITH_EMPTY_CICD_SUBSECTION = """\ +# Release Notes + +Unreleased changes +------------------ + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +--- + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +CHANGELOG_WITHOUT_THEMATIC_BREAK = """\ +# Release Notes + +Unreleased changes +------------------ + +### Bug fixes + +- Fix something ({gh-pr}`101`) + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + +EXPECTED_WITHOUT_THEMATIC_BREAK = """\ +# Release Notes + +Unreleased changes +------------------ + +### Bug fixes + +- Fix something ({gh-pr}`101`) + +### CI/CD + +- Bump foo from 1 to 2 ({gh-pr}`123`) + +0.1.0 +----- + +- Old release change ({gh-pr}`99`) +""" + @pytest.mark.parametrize( ("changelog_content", "expected_content"), @@ -232,6 +335,9 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: (CHANGELOG_WITHOUT_ANY_SECTIONS, EXPECTED_WITHOUT_ANY_SECTIONS), (CHANGELOG_WITH_TRAILING_SUBSECTION, EXPECTED_WITH_TRAILING_SUBSECTION), (CHANGELOG_WITH_LOOSE_ENTRIES, EXPECTED_WITH_LOOSE_ENTRIES), + (CHANGELOG_WITH_ATX_HEADINGS, EXPECTED_WITH_ATX_HEADINGS), + (CHANGELOG_WITH_EMPTY_CICD_SUBSECTION, EXPECTED_WITH_EMPTY_CICD_SUBSECTION), + (CHANGELOG_WITHOUT_THEMATIC_BREAK, EXPECTED_WITHOUT_THEMATIC_BREAK), ], ids=[ "existing-cicd-subsection", @@ -240,6 +346,9 @@ def test_format_entry(pr_number: int, pr_title: str, expected: str) -> None: "missing-any-sections", "trailing-subsection", "loose-entries", + "atx-headings", + "empty-cicd-subsection", + "missing-thematic-break", ], ) def test_add_changelog_entry(changelog_content: str, expected_content: str, tmp_path: Path) -> None: