From 8d2d0941f05c2492df76ff95f2ae2ebba8b89451 Mon Sep 17 00:00:00 2001 From: Pierre Sassoulas Date: Wed, 1 Jun 2022 12:59:38 +0200 Subject: [PATCH] Add a script to check the changelog --- .github/workflows/changelog.yml | 25 ++++++ doc/whatsnew/2/2.15/index.rst | 2 + script/check_changelog.py | 150 ++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 .github/workflows/changelog.yml create mode 100644 script/check_changelog.py diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000000..27735767d0 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,25 @@ +name: changelog + +on: + pull_request: + types: [opened, synchronize, labeled, unlabeled, reopened] + +permissions: + contents: read + +jobs: + build: + name: Changelog Entry Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Grep CHANGES.md for PR number + if: + contains(github.event.pull_request.labels.*.name, 'skip news :mute:') != true + run: | + grep -Pz "(\*\s[\S[\n +]+?]*\n\n\s\s(Refs|Closes|Follow-up in|Fixes part of)) (PyCQA/astroid)?#${{ github.event.pull_request.number }}" doc/whatsnew/2/**/*.rst || \ + (echo "Please add '(#${{ github.event.pull_request.number }})' change line to 'doc/whatsnew/2/2.15/index.rst' \ + (or if appropriate, ask a maintainer to add the 'skip news' label)" && \ + exit 1) diff --git a/doc/whatsnew/2/2.15/index.rst b/doc/whatsnew/2/2.15/index.rst index 6a285fe028..54aee010ef 100644 --- a/doc/whatsnew/2/2.15/index.rst +++ b/doc/whatsnew/2/2.15/index.rst @@ -47,3 +47,5 @@ Other Changes Internal changes ================ + +* Added a check in CI for changelogs entry (#6688) diff --git a/script/check_changelog.py b/script/check_changelog.py new file mode 100644 index 0000000000..da0c5100d9 --- /dev/null +++ b/script/check_changelog.py @@ -0,0 +1,150 @@ +# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html +# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE +# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt + +"""Small script to check the changelog. Used by 'changelog.yml' and pre-commit. + +If no issue number is provided we only check that proper formatting is respected.""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections.abc import Iterator +from pathlib import Path +from re import Pattern + +VALID_ISSUES_KEYWORDS = [ + "Refs", + "Closes", + "Follow-up in", + "Fixes part of", + "Closes BitBucket", + "Closes Logilab", +] +ISSUE_NUMBER_PATTERN = r"#\d{1,5}" +VALID_ISSUE_NUMBER_PATTERN = r"\*[\S\s]*?" + ISSUE_NUMBER_PATTERN +ISSUES_KEYWORDS = "|".join(VALID_ISSUES_KEYWORDS) +PREFIX_CHANGELOG_PATTERN = ( + rf"(\*\s[\S[\n ]+?]*\n\n\s\s({ISSUES_KEYWORDS})) (PyCQA/astroid)?" +) +VALID_CHANGELOG_PATTERN = PREFIX_CHANGELOG_PATTERN + ISSUE_NUMBER_PATTERN + +ISSUE_NUMBER_COMPILED_PATTERN = re.compile(ISSUE_NUMBER_PATTERN) +VALID_CHANGELOG_COMPILED_PATTERN: Pattern[str] = re.compile(VALID_CHANGELOG_PATTERN) +VALID_ISSUE_NUMBER_COMPILED_PATTERN: Pattern[str] = re.compile( + VALID_ISSUE_NUMBER_PATTERN +) + +DOC_PATH = (Path(__file__).parent / "../doc/").resolve() +PATH_TO_WHATSNEW = DOC_PATH / "whatsnew" +UNCHECKED_VERSION = [ + # Not checking version prior to 1.0.0 because the issues referenced are a mix + # between Logilab internal issue and Bitbucket. It's hard to tell, it's + # inaccessible for Logilab and often dead links for Bitbucket anyway. + # Not very useful generally, unless you're an open source historian. + "0.x", +] + +NO_CHECK_REQUIRED_FILES = { + "index.rst", + "full_changelog_explanation.rst", + "summary_explanation.rst", +} + + +def sorted_whatsnew(verbose: bool) -> Iterator[Path]: + """Return the whats-new in the 'right' numerical order ('9' before '10')""" + numeric_whatsnew = {} + for file in PATH_TO_WHATSNEW.glob("**/*"): + relpath_file = file.relative_to(DOC_PATH) + if file.is_dir(): + if verbose: + print(f"I don't care about '{relpath_file}', it's a directory : 🤖🤷") + continue + if file.name in NO_CHECK_REQUIRED_FILES: + if verbose: + print( + f"I don't care about '{relpath_file}' it's in 'NO_CHECK_REQUIRED_FILES' : 🤖🤷" + ) + continue + version = ( + file.parents[0].name if file.stem in {"summary", "full"} else file.stem + ) + if any(version == x for x in UNCHECKED_VERSION): + if verbose: + print( + f"I don't care about '{relpath_file}' {version} is in UNCHECKED_VERSION : 🤖🤷" + ) + continue + if verbose: + print(f"I'm going to check '{relpath_file}' 🤖") + num = tuple(int(x) for x in (version.split("."))) + numeric_whatsnew[num] = file + for num in sorted(numeric_whatsnew): + yield numeric_whatsnew[num] + + +def main(argv: list[str] | None = None) -> int: + argv = argv or sys.argv[1:] + description = __doc__ + description += " The regex we enforce is: " + VALID_CHANGELOG_PATTERN + parser = argparse.ArgumentParser(description=description) + parser.add_argument( + "--issue-number", + type=int, + default=0, + help="The issue we expect to find in the changelog.", + ) + parser.add_argument("--verbose", "-v", action="count", default=0) + args = parser.parse_args(argv) + verbose = args.verbose + is_valid = True + for file in sorted_whatsnew(verbose): + if not check_file(file, verbose): + is_valid = False + return 0 if is_valid else 1 + + +def check_file(file: Path, verbose: bool) -> bool: + """Check that a file contain valid change-log's entries.""" + with open(file, encoding="utf8") as f: + content = f.read() + valid_full_descriptions = VALID_CHANGELOG_COMPILED_PATTERN.findall(content) + result = len(valid_full_descriptions) + contain_issue_number_descriptions = VALID_ISSUE_NUMBER_COMPILED_PATTERN.findall( + content + ) + expected = len(contain_issue_number_descriptions) + if result != expected: + return create_detailed_fail_message( + file, contain_issue_number_descriptions, valid_full_descriptions + ) + if verbose: + relpath_file = file.relative_to(DOC_PATH) + print(f"Checked '{relpath_file}' : LGTM 🤖👍") + return True + + +def create_detailed_fail_message( + file_name: Path, + contain_issue_number_descriptions: list, + valid_full_descriptions: list, +) -> bool: + is_valid = True + for issue_number_description in contain_issue_number_descriptions: + if not any(v[0] in issue_number_description for v in valid_full_descriptions): + is_valid = False + issue_number = ISSUE_NUMBER_COMPILED_PATTERN.findall( + issue_number_description + )[0] + print( + f"{file_name}: {issue_number}'s description is not on one line, or " + "does not respect the standard format 🤖👎" + ) + return is_valid + + +if __name__ == "__main__": + sys.exit(main())