diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index fe105d09..12f3204a 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,5 +1,11 @@
# 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.
+# - 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
#
@@ -8,10 +14,16 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
- interval: "daily"
+ interval: "weekly"
labels: [ "github_actions" ]
+ groups:
+ github-actions:
+ patterns: [ "*" ]
- package-ecosystem: "pip"
directory: "/"
schedule:
- interval: "daily"
+ interval: "weekly"
labels: [ "dependencies" ]
+ groups:
+ pip:
+ patterns: [ "*" ]
diff --git a/.github/workflows/bot-prs.yml b/.github/workflows/bot-prs.yml
new file mode 100644
index 00000000..cf792589
--- /dev/null
+++ b/.github/workflows/bot-prs.yml
@@ -0,0 +1,167 @@
+# Automation for PRs opened by trusted bots (dependabot and pre-commit.ci).
+#
+# For every bot PR, this workflow:
+# 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 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 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, synchronize ]
+ push:
+ branches: [ main ]
+
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ changelog:
+ name: Add changelog entry
+ if: >-
+ 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: 3
+ permissions:
+ contents: write
+ steps:
+ - 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_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."
+ 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"
+ python3 -m pip install --quiet markdown-it-py
+
+ 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..9e9676b9
--- /dev/null
+++ b/cicd_utils/cicd/scripts/add_changelog_entry.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python
+"""Add a changelog entry for a given pull request.
+
+Inserts a ``-
({gh-pr}``)`` 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.
+
+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.
+
+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
+"""
+
+from __future__ import annotations
+
+import argparse
+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"
+
+# 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 _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]
+
+
+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(tokens: list[Token]) -> tuple[int, int] | None:
+ """Find the (start, end) token index range of the 'Unreleased changes' section.
+
+ ``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(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
+ return start, len(tokens)
+
+
+def _new_unreleased_section(entry: str) -> list[str]:
+ return [
+ UNRELEASED_HEADING,
+ "-" * len(UNRELEASED_HEADING),
+ "",
+ f"### {CICD_HEADING}",
+ "",
+ entry,
+ "",
+ "---",
+ "",
+ ]
+
+
+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(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)
+ if lines and lines[-1].strip():
+ lines = [*lines, ""]
+ return [*lines, *_new_unreleased_section(entry)]
+
+
+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)
+ 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:
+ """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()
+ tokens = MarkdownIt().parse(text)
+
+ section = _find_unreleased_section(tokens)
+ if section is None:
+ lines = _insert_unreleased_section(lines, tokens, entry)
+ else:
+ start, end = section
+ 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:
+ 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()
+ 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/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..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.
+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 6d462fdc..3c885a10 100644
--- a/docs/reference/changelog.md
+++ b/docs/reference/changelog.md
@@ -30,6 +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, 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/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
new file mode 100644
index 00000000..75b07f2d
--- /dev/null
+++ b/tests/cicd_utils/test_scripts/test_add_changelog_entry.py
@@ -0,0 +1,404 @@
+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`)
+"""
+
+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"),
+ [
+ (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),
+ (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",
+ "missing-cicd-subsection",
+ "missing-unreleased-section",
+ "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:
+ 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