diff --git a/README.md b/README.md index 668a622..0b24724 100644 --- a/README.md +++ b/README.md @@ -102,11 +102,19 @@ _These lists are automatically generated, and may not be complete or may contain duplicates._ """ -# Profiles that are excluded from the contributor list. +# Usernames that are excluded from the contributor list. This may also ignore +# pull request of these users (see field `ignore_prs_by_username`). ignored_user_logins = [ "web-flow", ] +# Ignore pull requests authored by these users. +# If `{include = "ignored_user_logins"}` is included in this list (the default), +# usernames from the field `ignored_user_logins` are also included. +ignore_prs_by_username = [ + {include = "ignored_user_logins"}, +] + # If this regex matches a pull requests description, the captured content # is included instead of the pull request title. E.g. the # default regex below is matched by @@ -228,7 +236,7 @@ jobs: name: attach to PR runs-on: ubuntu-latest steps: - - uses: scientific-python/attach-next-milestone-action@bc07be829f693829263e57d5e8489f4e57d3d420 + - uses: scientific-python/attach-next-milestone-action@c9cfab10ad0c67fed91b01103db26b7f16634639 with: token: ${{ secrets.MILESTONE_LABELER_TOKEN }} force: true diff --git a/src/changelist/_cli.py b/src/changelist/_cli.py index 46c0bdb..6dbfc10 100644 --- a/src/changelist/_cli.py +++ b/src/changelist/_cli.py @@ -157,6 +157,7 @@ def main( pull_requests, pr_summary_regex=config["pr_summary_regex"], pr_summary_label_regex=config["pr_summary_label_regex"], + ignore_prs_by_username=config["ignore_prs_by_username"], ) Formatter = {"md": MdFormatter, "rst": RstFormatter}[format] diff --git a/src/changelist/_config.py b/src/changelist/_config.py index 223cd5c..5fd3e5a 100644 --- a/src/changelist/_config.py +++ b/src/changelist/_config.py @@ -13,6 +13,28 @@ DEFAULT_CONFIG_PATH = Path(__file__).parent / "default_config.toml" +def _dereference_ignore_prs_by_username(config: dict) -> dict: + """Dereference "include" in `ignore_prs_by_username` field. + + Example: + >>> config = { + ... "ignored_user_logins": ["bot"], + ... "ignore_prs_by_username": [{"include": "ignored_user_logins"}, "web-flow"] + ... } + >>> _dereference_ignore_prs_by_username(config) + {'ignored_user_logins': ['bot'], 'ignore_prs_by_username': ['web-flow', 'bot']} + """ + if "ignore_prs_by_username" not in config: + return config + + include_sentinel = {"include": "ignored_user_logins"} + if include_sentinel in config["ignore_prs_by_username"]: + config["ignore_prs_by_username"].remove(include_sentinel) + config["ignore_prs_by_username"] += config.get("ignored_user_logins", []) + + return config + + def remote_config(gh: Github, org_repo: str, *, rev: str): """Return configuration options in remote pyproject.toml if they exist.""" repo = gh.get_repo(org_repo) @@ -50,4 +72,5 @@ def add_config_defaults( if key not in config: config[key] = value logger.debug("using default config value for %s", key) + config = _dereference_ignore_prs_by_username(config) return config diff --git a/src/changelist/_objects.py b/src/changelist/_objects.py index eb88cfa..76ff3e0 100644 --- a/src/changelist/_objects.py +++ b/src/changelist/_objects.py @@ -2,7 +2,7 @@ import re from dataclasses import dataclass from datetime import datetime -from typing import Union +from typing import Optional, Union from github.NamedUser import NamedUser from github.PullRequest import PullRequest @@ -27,6 +27,7 @@ def from_pull_requests( *, pr_summary_regex: str, pr_summary_label_regex: str, + ignore_prs_by_username: Optional[list[str]] = None, ) -> "set[ChangeNote]": """Create a set of notes from pull requests. @@ -39,12 +40,25 @@ def from_pull_requests( requests and notes somewhat. While ideally, a pull request introduces a change that would be described in a single note, this is often not the case. + + `ignore_prs_by_username` is a list of user logins whose pull requests + should be excluded from the changelog entirely. """ + if ignore_prs_by_username is None: + ignore_prs_by_username = [] + pr_summary_regex = re.compile(pr_summary_regex, flags=re.MULTILINE) pr_summary_label_regex = re.compile(pr_summary_label_regex) notes = set() for pr in pull_requests: + if pr.user and pr.user.login in ignore_prs_by_username: + logger.debug( + "skipping PR %s from ignored user %s", + pr.html_url, + pr.user.login, + ) + continue pr_labels = tuple(label.name for label in pr.labels) if not pr.body or not ( diff --git a/src/changelist/default_config.toml b/src/changelist/default_config.toml index 67e0a77..643e6e4 100644 --- a/src/changelist/default_config.toml +++ b/src/changelist/default_config.toml @@ -21,11 +21,19 @@ _These lists are automatically generated, and may not be complete or may contain duplicates._ """ -# Profiles that are excluded from the contributor list. +# Usernames that are excluded from the contributor list. This may also ignore +# pull request of these users (see field `ignore_prs_by_username`). ignored_user_logins = [ "web-flow", ] +# Ignore pull requests authored by these users. +# If `{include = "ignored_user_logins"}` is included in this list (the default), +# usernames from the field `ignored_user_logins` are also included. +ignore_prs_by_username = [ + {include = "ignored_user_logins"}, +] + # If this regex matches a pull requests description, the captured content # is included instead of the pull request title. E.g. the # default regex below is matched by diff --git a/test/test_objects.py b/test/test_objects.py index 18caf5a..320b38b 100644 --- a/test/test_objects.py +++ b/test/test_objects.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Union @@ -12,6 +12,13 @@ DEFAULT_CONFIG = local_config(DEFAULT_CONFIG_PATH) +@dataclass +class _MockUser: + """Mocks github.User partially.""" + + login: str + + @dataclass class _MockLabel: """Mocks github.Label.Label partially.""" @@ -26,6 +33,7 @@ class _MockPullRequest: title: str body: Union[str, None] labels: list[_MockLabel] + user: _MockUser = field(default_factory=lambda: _MockUser(login="friendlyDev")) number: int = (42,) html_url: str = "https://github.com/scientific-python/changelist/pull/53" merged_at: datetime = datetime(2024, 1, 1) @@ -73,6 +81,40 @@ def test_from_pull_requests_multiple(self, caplog): assert caplog.records[0].levelname == "DEBUG" assert "falling back to PR labels for summary" in caplog.records[0].msg + def test_from_pull_requests_ignore_by_username(self, caplog): + caplog.set_level("DEBUG") + pull_requests = [ + _MockPullRequest( + title="PR from user", + body=None, + labels=[_MockLabel("Documentation")], + user=_MockUser("someDev"), + ), + _MockPullRequest( + title="PR from bot", + body=None, + labels=[_MockLabel("Documentation")], + user=_MockUser("bot"), + ), + ] + notes = ChangeNote.from_pull_requests( + pull_requests, + pr_summary_regex=DEFAULT_CONFIG["pr_summary_regex"], + pr_summary_label_regex=DEFAULT_CONFIG["pr_summary_label_regex"], + ignore_prs_by_username=["bot"], + ) + assert len(notes) == 1 + notes = list(notes) + assert notes[0].content == "PR from user" + assert notes[0].labels == ("Documentation",) + + assert len(caplog.records) == 2 + assert caplog.records[0].levelname == "DEBUG" + assert "falling back to title" in caplog.records[0].msg + + assert caplog.records[1].levelname == "DEBUG" + assert "skipping PR %s from ignored user %s" in caplog.records[1].msg + def test_from_pull_requests_fallback_title(self, caplog): caplog.set_level("DEBUG") pull_request = _MockPullRequest(