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}`<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. + +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 <pr-number> <pr-title> +""" + +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