diff --git a/AGENTS.md b/AGENTS.md index a169799237..82f77b35d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,8 @@ gets wrong: `pybamm.X`) and get a `docs/source/api/*.rst` entry. - Every feature or fix adds a `CHANGELOG.md` bullet under `# [Unreleased]` (Keep a Changelog format), ending with the PR link, e.g. `([#1234](https://github.com/pybamm-team/PyBaMM/pull/1234))`. + Get a prediction from `uv run --no-project scripts/next_pr_number.py`. If another issue, pull + request, or discussion takes that number before the PR opens, update the entry manually. ### Tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4fdb6a8d1..c066e073b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,6 +102,8 @@ If your PR is internal-only (refactor, docs typo, CI tweak, tests), no changelog Each entry is a single bullet ending in a PR link, e.g. `([#1234](https://github.com/pybamm-team/PyBaMM/pull/1234))`. Use the full URL so the rendered markdown on docs.pybamm.org links correctly. +To write the entry before opening the PR, run `uv run --no-project scripts/next_pr_number.py`. It prints the likely next number using the [GitHub CLI](https://cli.github.com). This is only a prediction: if another issue, pull request, or discussion takes the number first, update the entry manually. + For the full release policy — what counts as "public API", deprecation horizon, version scheme — see [`RELEASE.md`](https://github.com/pybamm-team/PyBaMM/blob/main/RELEASE.md). ## Coding style guidelines diff --git a/packages/pybamm/tests/unit/test_next_pr_number.py b/packages/pybamm/tests/unit/test_next_pr_number.py new file mode 100644 index 0000000000..872edc104c --- /dev/null +++ b/packages/pybamm/tests/unit/test_next_pr_number.py @@ -0,0 +1,41 @@ +import importlib.util +import json +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[4] / "scripts" / "next_pr_number.py" +spec = importlib.util.spec_from_file_location("next_pr_number", SCRIPT) +next_pr_number = importlib.util.module_from_spec(spec) +spec.loader.exec_module(next_pr_number) + + +class TestNextPrNumber: + def test_returns_one_more_than_the_newest_number(self, monkeypatch): + response = { + "data": { + "repository": { + "issues": {"nodes": [{"number": 40}]}, + "pullRequests": {"nodes": [{"number": 42}]}, + "discussions": {"nodes": [{"number": 41}]}, + } + } + } + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout=json.dumps(response), stderr="" + ) + monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: completed) + + assert next_pr_number.next_pr_number() == 43 + + def test_reports_github_cli_failure(self, monkeypatch): + def fail(*args, **kwargs): + raise subprocess.CalledProcessError( + 1, "gh", stderr="authentication required" + ) + + monkeypatch.setattr(subprocess, "run", fail) + + with pytest.raises(SystemExit, match="authentication required"): + next_pr_number.next_pr_number() diff --git a/scripts/next_pr_number.py b/scripts/next_pr_number.py new file mode 100644 index 0000000000..46e2ad7a77 --- /dev/null +++ b/scripts/next_pr_number.py @@ -0,0 +1,63 @@ +"""Print the likely number of the next PyBaMM pull request. + +The result is a prediction. An issue, pull request, or discussion created before +the pull request will take the predicted number first. +""" + +from __future__ import annotations + +import json +import subprocess + +REPO = "pybamm-team/PyBaMM" +QUERY = """ +query ($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issues(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { + nodes { number } + } + pullRequests(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { + nodes { number } + } + discussions(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { + nodes { number } + } + } +} +""" + + +def next_pr_number() -> int: + """Return the likely number of the next PyBaMM pull request.""" + owner, name = REPO.split("/") + try: + result = subprocess.run( + [ + "gh", + "api", + "graphql", + "-f", + f"query={QUERY}", + "-f", + f"owner={owner}", + "-f", + f"name={name}", + ], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + except FileNotFoundError as error: + raise SystemExit("gh is not installed; see https://cli.github.com") from error + except subprocess.CalledProcessError as error: + message = error.stderr.strip() or "unknown error" + raise SystemExit(f"`gh api graphql` failed: {message}") from error + + connections = json.loads(result.stdout)["data"]["repository"].values() + newest = [item["nodes"][0]["number"] for item in connections if item["nodes"]] + return max(newest, default=0) + 1 + + +if __name__ == "__main__": + print(next_pr_number())