diff --git a/poetry.lock b/poetry.lock index b4eaa0ae..9f7a7fb7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -250,6 +250,25 @@ toml = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] toml = ["toml"] +[[package]] +name = "cryptography" +version = "35.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools_rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + [[package]] name = "discord.py" version = "2.0.0a0" @@ -436,6 +455,26 @@ python-dateutil = ">=2.8.1" [package.extras] dev = ["twine", "markdown", "flake8", "wheel"] +[[package]] +name = "gidgethub" +version = "5.0.1" +description = "An async GitHub API library" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +PyJWT = {version = ">=2.0.0", extras = ["crypto"]} +uritemplate = ">=3.0.1" + +[package.extras] +aiohttp = ["aiohttp"] +dev = ["aiohttp", "black", "coverage[toml] (>=5.0.3)", "httpx", "mypy", "pytest-cov", "pytest-xdist", "tornado"] +doc = ["sphinx"] +httpx = ["httpx (>=0.16.1)"] +test = ["importlib-resources", "pytest (>=5.4.1)", "pytest-asyncio", "pytest-tornasync"] +tornado = ["tornado"] + [[package]] name = "gitdb" version = "4.0.7" @@ -843,6 +882,23 @@ category = "dev" optional = false python-versions = ">=3.5" +[[package]] +name = "pyjwt" +version = "2.3.0" +description = "JSON Web Token implementation in Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = {version = ">=3.3.1", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"] + [[package]] name = "pymdown-extensions" version = "8.2" @@ -1137,6 +1193,14 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "urllib3" version = "1.26.6" @@ -1207,7 +1271,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "e99a8dc25d0c2d30dd939af8e60aa92c8e4851614508fb46387d0663a0a8c261" +content-hash = "3d4df05f510e964f8dc511907c1687c45ef727264f4156cb27dfae54710e55af" [metadata.files] aiodns = [ @@ -1494,6 +1558,28 @@ coverage = [ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] +cryptography = [ + {file = "cryptography-35.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:d57e0cdc1b44b6cdf8af1d01807db06886f10177469312fbde8f44ccbb284bc9"}, + {file = "cryptography-35.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:ced40344e811d6abba00295ced98c01aecf0c2de39481792d87af4fa58b7b4d6"}, + {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:54b2605e5475944e2213258e0ab8696f4f357a31371e538ef21e8d61c843c28d"}, + {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:7b7ceeff114c31f285528ba8b390d3e9cfa2da17b56f11d366769a807f17cbaa"}, + {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d69645f535f4b2c722cfb07a8eab916265545b3475fdb34e0be2f4ee8b0b15e"}, + {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2d0e0acc20ede0f06ef7aa58546eee96d2592c00f450c9acb89c5879b61992"}, + {file = "cryptography-35.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:07bb7fbfb5de0980590ddfc7f13081520def06dc9ed214000ad4372fb4e3c7f6"}, + {file = "cryptography-35.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7eba2cebca600a7806b893cb1d541a6e910afa87e97acf2021a22b32da1df52d"}, + {file = "cryptography-35.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d90f4711bf63e2fb21e8c8e51ed8189438e6b35a6d996201ebd98a26abbbe6"}, + {file = "cryptography-35.0.0-cp36-abi3-win32.whl", hash = "sha256:c10c797ac89c746e488d2ee92bd4abd593615694ee17b2500578b63cad6b93a8"}, + {file = "cryptography-35.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:7075b304cd567694dc692ffc9747f3e9cb393cc4aa4fb7b9f3abd6f5c4e43588"}, + {file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a688ebcd08250eab5bb5bca318cc05a8c66de5e4171a65ca51db6bd753ff8953"}, + {file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d99915d6ab265c22873f1b4d6ea5ef462ef797b4140be4c9d8b179915e0985c6"}, + {file = "cryptography-35.0.0-pp36-pypy36_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:928185a6d1ccdb816e883f56ebe92e975a262d31cc536429041921f8cb5a62fd"}, + {file = "cryptography-35.0.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ebeddd119f526bcf323a89f853afb12e225902a24d29b55fe18dd6fcb2838a76"}, + {file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22a38e96118a4ce3b97509443feace1d1011d0571fae81fc3ad35f25ba3ea999"}, + {file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb80e8a1f91e4b7ef8b33041591e6d89b2b8e122d787e87eeb2b08da71bb16ad"}, + {file = "cryptography-35.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:abb5a361d2585bb95012a19ed9b2c8f412c5d723a9836418fab7aaa0243e67d2"}, + {file = "cryptography-35.0.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1ed82abf16df40a60942a8c211251ae72858b25b7421ce2497c2eb7a1cee817c"}, + {file = "cryptography-35.0.0.tar.gz", hash = "sha256:9933f28f70d0517686bd7de36166dda42094eac49415459d9bdf5e7df3e0086d"}, +] "discord.py" = [] distlib = [ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, @@ -1549,6 +1635,10 @@ ghp-import = [ {file = "ghp-import-2.0.1.tar.gz", hash = "sha256:753de2eace6e0f7d4edfb3cce5e3c3b98cd52aadb80163303d1d036bda7b4483"}, {file = "ghp_import-2.0.1-py3-none-any.whl", hash = "sha256:8241a8e9f8dd3c1fafe9696e6e081b57a208ef907e9939c44e7415e407ab40ea"}, ] +gidgethub = [ + {file = "gidgethub-5.0.1-py3-none-any.whl", hash = "sha256:67245e93eb0918b37df038148af675df43b62e832c529d7f859f6b90d9f3e70d"}, + {file = "gidgethub-5.0.1.tar.gz", hash = "sha256:3efbd6998600254ec7a2869318bd3ffde38edc3a0d37be0c14bc46b45947b682"}, +] gitdb = [ {file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"}, {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"}, @@ -1861,6 +1951,10 @@ pygments = [ {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, ] +pyjwt = [ + {file = "PyJWT-2.3.0-py3-none-any.whl", hash = "sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f"}, + {file = "PyJWT-2.3.0.tar.gz", hash = "sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41"}, +] pymdown-extensions = [ {file = "pymdown-extensions-8.2.tar.gz", hash = "sha256:b6daa94aad9e1310f9c64c8b1f01e4ce82937ab7eb53bfc92876a97aca02a6f4"}, {file = "pymdown_extensions-8.2-py3-none-any.whl", hash = "sha256:141452d8ed61165518f2c923454bf054866b85cf466feedb0eb68f04acdc2560"}, @@ -2030,6 +2124,10 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, ] +uritemplate = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] urllib3 = [ {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, diff --git a/pyproject.toml b/pyproject.toml index aa3423f0..146a75c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ mkdocs-material = ">=7.1.9,<8.0.0" mkdocs-markdownextradata-plugin = ">=0.1.7,<0.2.0" click = "^8.0.3" Jinja2 = "^3.0.2" +gidgethub = "^5.0.1" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/scripts/news/__pycache__/__main__.cpython-39.pyc b/scripts/news/__pycache__/__main__.cpython-39.pyc index 101eb7ae..301a9536 100644 Binary files a/scripts/news/__pycache__/__main__.cpython-39.pyc and b/scripts/news/__pycache__/__main__.cpython-39.pyc differ diff --git a/scripts/news/__pycache__/utils.cpython-39.pyc b/scripts/news/__pycache__/utils.cpython-39.pyc index 1246dd94..ed06922d 100644 Binary files a/scripts/news/__pycache__/utils.cpython-39.pyc and b/scripts/news/__pycache__/utils.cpython-39.pyc differ diff --git a/scripts/news/check_news_workflow/__init__.py b/scripts/news/check_news_workflow/__init__.py new file mode 100644 index 00000000..210f5456 --- /dev/null +++ b/scripts/news/check_news_workflow/__init__.py @@ -0,0 +1,21 @@ +""" +Module for checking if the user has made a news file for the specific pull request. + +Slight modifications have been made to support our project. + +Original Source: https://github.com/python/bedevere/blob/master/LICENSE + +Copyright 2017 The Python Software Foundation + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" diff --git a/scripts/news/check_news_workflow/main.py b/scripts/news/check_news_workflow/main.py new file mode 100644 index 00000000..213e7e5e --- /dev/null +++ b/scripts/news/check_news_workflow/main.py @@ -0,0 +1,84 @@ +import functools +import pathlib +import re +from typing import Any, Dict + +import gidgethub.routing +from gidgethub import sansio +from gidgethub.abc import GitHubAPI + +from ..utils import load_toml_config +from . import utils + + +router = gidgethub.routing.Router() +create_status = functools.partial(utils.create_status, "Check News") + +CONFIG = load_toml_config() +SECTIONS = [_type for _type, _ in CONFIG.get("types").items()] +CHANGELOG_IT_URL = "TODO: URL TO CHANGE-LOGGING PR SECTION IN README" +FILENAME_RE = re.compile( + r"^\d{4}-(0?[1-9]|1[012])-(0?[1-9]|[12][0-9]|3[01])$\." # match `yyyy-mm-dd` or `yyyy-m-d` + r"pr-\d+(?:,\d+)*\." # Issue number(s) + fr"({'|'.join(SECTIONS)})\." # Section type + r"[A-Za-z0-9_=-]+\." # Nonce (URL-safe base64) + r"md", # File extension""" + re.VERBOSE, +) + +SKIP_LABEL_STATUS = create_status(utils.StatusState.SUCCESS, description='"skip changelog" label found') + + +async def check_news(gh: GitHubAPI, pull_request: Dict[str, Any]) -> None: + """ + Check for a news entry. + + The routing is handled through the filepath module. + """ + files = await utils.files_for_pr(gh, pull_request) + in_next_dir = file_found = False + + for file in files: + if not utils.is_news_dir(file["file_name"]): + continue + in_next_dir = True + file_path = pathlib.PurePath(file["file_name"]) + if len(file_path.parts) != 3: # news, next, + continue + file_found = True + if FILENAME_RE.match(file_path.name) and len(file["patch"]) >= 1: + status = create_status( + utils.StatusState.SUCCESS, description=f"News entry found in {utils.NEWS_NEXT_DIR}" + ) + break + else: + issue = await utils.issue_for_pr(gh, pull_request) + if utils.skip(issue): + status = SKIP_LABEL_STATUS + else: + if not in_next_dir: + description = f'No news entry in {utils.NEWS_NEXT_DIR} or "skip news" label found' + elif not file_found: + description = "News entry not in an appropriate directory" + else: + description = "News entry file name incorrectly formatted" + status = create_status( + utils.StatusState.FAILURE, description=description, target_url=CHANGELOG_IT_URL + ) + + await gh.post(pull_request["statuses_url"], data=status) + + +@router.register("pull_request", action="labeled") +async def label_added(event: sansio.Event, gh: GitHubAPI, *args, **kwargs) -> None: + if utils.label_name(event.data) == utils.SKIP_NEWS_LABEL: + await utils.post_status(gh, event, SKIP_LABEL_STATUS) + + +@router.register("pull_request", action="unlabeled") +async def label_removed(event: sansio.Event, gh: GitHubAPI, *args, **kwargs) -> None: + if utils.no_labels(event.data): + return + elif utils.label_name(event.data) == utils.SKIP_NEWS_LABEL: + pull_request = event.data["pull_request"] + await check_news(gh, pull_request) diff --git a/scripts/news/check_news_workflow/utils.py b/scripts/news/check_news_workflow/utils.py new file mode 100644 index 00000000..e0560b80 --- /dev/null +++ b/scripts/news/check_news_workflow/utils.py @@ -0,0 +1,82 @@ +import enum +import sys +from typing import Any, Dict, List + +from gidgethub.abc import GitHubAPI + + +NEWS_NEXT_DIR = "news/next/" +SKIP_NEWS_LABEL = "skip changelog" + + +class StatusState(enum.Enum): + SUCCESS = "success" + ERROR = "error" + FAILURE = "failure" + + +def create_status( + context: str, state: StatusState, *, description: str = None, target_url: str = None +) -> dict: + """ + Create the data for a status. + + The argument order is such that you can use functools.partial() to set the + context to avoid repeatedly specifying it throughout a module. + """ + status = { + "context": context, + "state": state.value, + } + if description is not None: + status["description"] = description + if target_url is not None: + status["target_url"] = target_url + + return status + + +async def post_status(gh: GitHubAPI, event, status: Any) -> None: + """Post a status in reaction to an event.""" + await gh.post(event.data["pull_request"]["statuses_url"], data=status) + + +def skip(issue: Dict[str, Any]) -> bool: + """See if an issue has a "SKIP_NEWS_LABEL" label.""" + return SKIP_NEWS_LABEL in {label_data["name"] for label_data in issue["labels"]} + + +def label_name(event_data: Dict[str, Any]) -> str: + """Get the label name from a label-related webhook event.""" + return event_data["label"]["name"] + + +async def files_for_pr(gh: GitHubAPI, pull_request: Dict[str, Any]) -> List[Dict[str, Any]]: + """Get files for a pull request.""" + # For some unknown reason there isn't any files URL in a pull request payload. + files_url = f'{pull_request["url"]}/files' + data = [] + async for filedata in gh.getiter(files_url): + data.append({"file_name": filedata["filename"], "patch": filedata.get("patch", "")}) + return data + + +async def issue_for_pr(gh: GitHubAPI, pull_request: Dict[str, Any]) -> Any: + """Get the issue data for a pull request.""" + return await gh.getitem(pull_request["issue_url"]) + + +def is_news_dir(filename: str) -> bool: + """Return True if file is in the News directory.""" + return filename.startswith(NEWS_NEXT_DIR) + + +def no_labels(event_data: Dict[str, Any]) -> bool: + if "label" not in event_data: + print( + "no 'label' key in payload; " "'unlabeled' event triggered by label deletion?", + file=sys.stderr, + ) + return True + else: + return False