diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 32d1824..01195c9 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -23,8 +23,10 @@ jobs: curl -L https://github.com/idc101/git-mkver/releases/download/v1.2.1/git-mkver-linux-amd64-1.2.1.tar.gz \ | tar xvz \ && sudo mv git-mkver /usr/local/bin + - name: Install semantic version checker + run: pip install git+https://github.com/octue/conventional-commits - name: Check version - run: python .github/workflows/scripts/check-semantic-version.py + run: check-semantic-version setup.py run-tests: if: "!contains(github.event.head_commit.message, 'skip_ci_tests')" diff --git a/.github/workflows/scripts/check-semantic-version.py b/.github/workflows/scripts/check-semantic-version.py deleted file mode 100644 index 06192ab..0000000 --- a/.github/workflows/scripts/check-semantic-version.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -import subprocess -import sys - - -PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) - - -RED = '\033[0;31m' -GREEN = "\033[0;32m" -NO_COLOUR = '\033[0m' - - -def get_setup_version(): - process = subprocess.run(["python", "setup.py", "--version"], capture_output=True) - return process.stdout.strip().decode("utf8") - - -def get_expected_semantic_version(): - process = subprocess.run(["git-mkver", "next"], capture_output=True) - return process.stdout.strip().decode("utf8") - - -if __name__ == "__main__": - os.chdir(PACKAGE_ROOT) - setup_version = get_setup_version() - expected_semantic_version = get_expected_semantic_version() - - if setup_version != expected_semantic_version: - print( - f"{RED}VERSION FAILED CHECKS:{NO_COLOUR} The version stated in 'setup.py' ({setup_version}) is different " - f"from the expected semantic version ({expected_semantic_version})." - ) - sys.exit(1) - - print( - f"{GREEN}VERSION PASSED CHECKS:{NO_COLOUR} The version stated in 'setup.py' is the same as the expected " - f"semantic version: {expected_semantic_version}." - ) - sys.exit(0) diff --git a/.github/workflows/scripts/compile-release-notes.py b/.github/workflows/scripts/compile-release-notes.py deleted file mode 100644 index 68c918c..0000000 --- a/.github/workflows/scripts/compile-release-notes.py +++ /dev/null @@ -1,176 +0,0 @@ -import re -import subprocess -import sys - - -LAST_RELEASE = "LAST_RELEASE" -LAST_PULL_REQUEST = "LAST_PULL_REQUEST" - -SEMANTIC_VERSION_PATTERN = r"tag: (\d+\.\d+\.\d+)" -PULL_REQUEST_INDICATOR = "Merge pull request #" - -COMMIT_CODES_TO_HEADINGS_MAPPING = { - "FEA": "### New features", - "ENH": "### Enhancements", - "FIX": "### Fixes", - "OPS": "### Operations", - "DEP": "### Dependencies", - "REF": "### Refactoring", - "TST": "### Testing", - "MRG": "### Other", - "REV": "### Reversions", - "CHO": "### Chores", - "WIP": "### Other", - "DOC": "### Other", - "STY": "### Other", -} - -AUTO_GENERATION_START_INDICATOR = "" -AUTO_GENERATION_END_INDICATOR = "" - - -class ReleaseNoteCompiler: - """A release/pull request notes compiler. The notes are pulled together from Conventional Commit messages, stopping - at the specified stop point. The stop point can either be the last merged pull request in the branch or the last - semantically-versioned release tagged in the branch. If previous notes are provided, only the text between the - comment lines `` and `` will be replaced - - anything outside of this will appear in the new release notes. - - :param str header: - :param str|None previous_notes: - :param str list_item_symbol: - :param dict|None commit_codes_to_headings_mapping: - :param str stop_point: - :return None: - """ - - def __init__( - self, - stop_point, - previous_notes=None, - header="## Contents", - list_item_symbol="- [x] ", - commit_codes_to_headings_mapping=None, - ): - if stop_point.upper() not in {LAST_RELEASE, LAST_PULL_REQUEST}: - raise ValueError(f"`stop_point` must be one of {LAST_RELEASE, LAST_PULL_REQUEST!r}; received {stop_point!r}.") - - self.stop_point = stop_point.upper() - self.previous_notes = previous_notes - self.header = header - self.list_item_symbol = list_item_symbol - self.commit_codes_to_headings_mapping = commit_codes_to_headings_mapping or COMMIT_CODES_TO_HEADINGS_MAPPING - - def compile_release_notes(self): - """Compile the release or pull request notes into a multiline string, sorting the commit messages into headed - sections according to their commit codes and the commit-codes-to-headings mapping. - - :return str: - """ - git_log = subprocess.run(["git", "log", "--pretty=format:%s|%d"], capture_output=True).stdout.strip().decode() - parsed_commits, unparsed_commits = self._parse_commit_messages(git_log) - categorised_commit_messages = self._categorise_commit_messages(parsed_commits, unparsed_commits) - autogenerated_release_notes = self._build_release_notes(categorised_commit_messages) - - if not self.previous_notes: - return autogenerated_release_notes - - previous_notes_lines = self.previous_notes.splitlines() - - for i, line in enumerate(previous_notes_lines): - if AUTO_GENERATION_START_INDICATOR in line: - autogeneration_start_index = i - - elif AUTO_GENERATION_END_INDICATOR in line: - autogeneration_end_index = i + 1 - - try: - return "\n".join( - ( - *previous_notes_lines[:autogeneration_start_index], - autogenerated_release_notes, - *previous_notes_lines[autogeneration_end_index:] - ) - ) - - except NameError: - return autogenerated_release_notes - - def _parse_commit_messages(self, formatted_oneline_git_log): - """Parse commit messages from the git log (formatted using `--pretty=format:%s|%d`) until the stop point is - reached. The parsed commit messages are returned separately to any that fail to parse. - - :param str formatted_oneline_git_log: - :return list(tuple), list(str): - """ - parsed_commits = [] - unparsed_commits = [] - - for commit in formatted_oneline_git_log.splitlines(): - split_commit = commit.split("|") - - if len(split_commit) == 2: - message, decoration = split_commit - - try: - code, message = message.split(":") - except ValueError: - unparsed_commits.append(message.strip()) - continue - - if self.stop_point == LAST_RELEASE: - if "tag" in decoration: - if re.compile(SEMANTIC_VERSION_PATTERN).search(decoration): - break - - if self.stop_point == LAST_PULL_REQUEST: - if PULL_REQUEST_INDICATOR in message: - break - - parsed_commits.append((code.strip(), message.strip(), decoration.strip())) - - return parsed_commits, unparsed_commits - - def _categorise_commit_messages(self, parsed_commits, unparsed_commits): - """Categorise the commit messages into headed sections. Unparsed commits are put under an "uncategorised" - header. - - :param iter(tuple)) parsed_commits: - :param iter(str) unparsed_commits: - :return dict: - """ - release_notes = {heading: [] for heading in self.commit_codes_to_headings_mapping.values()} - - for code, message, _ in parsed_commits: - try: - release_notes[self.commit_codes_to_headings_mapping[code]].append(message) - except KeyError: - release_notes["### Other"].append(message) - - release_notes["### Uncategorised!"] = unparsed_commits - return release_notes - - def _build_release_notes(self, categorised_commit_messages): - """Build the the categorised commit messages into a single multi-line string ready to be used as formatted - release notes. - - :param dict categorised_commit_messages: - :return str: - """ - release_notes_for_printing = f"{AUTO_GENERATION_START_INDICATOR}\n{self.header}\n\n" - - for heading, notes in categorised_commit_messages.items(): - - if len(notes) == 0: - continue - - note_lines = "\n".join(self.list_item_symbol + note for note in notes) - release_notes_for_printing += f"{heading}\n{note_lines}\n\n" - - release_notes_for_printing += AUTO_GENERATION_END_INDICATOR - return release_notes_for_printing - - -if __name__ == "__main__": - release_notes = ReleaseNoteCompiler(*sys.argv[1:]).compile_release_notes() - print(release_notes) diff --git a/.github/workflows/update-pull-request.yml b/.github/workflows/update-pull-request.yml index dd8233d..e1762e5 100644 --- a/.github/workflows/update-pull-request.yml +++ b/.github/workflows/update-pull-request.yml @@ -21,10 +21,12 @@ jobs: with: # Set fetch-depth to 0 to fetch all commit history (necessary for compiling pull request description). fetch-depth: 0 + - name: Install release note compiler + run: pip install git+https://github.com/octue/conventional-commits - name: Compile new pull request description run: | echo 'PULL_REQUEST_NOTES<> $GITHUB_ENV - echo "$(python .github/workflows/scripts/compile-release-notes.py LAST_RELEASE '${{ github.event.pull_request.body }}')" >> $GITHUB_ENV + echo "$(compile-release-notes LAST_RELEASE '${{ github.event.pull_request.body }}')" >> $GITHUB_ENV echo 'EOF' >> $GITHUB_ENV - name: Update pull request body uses: riskledger/update-pr-description@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a165f6..384aa1e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,7 +61,7 @@ repos: - '^release/(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$' - repo: https://github.com/octue/pre-commit-hooks - rev: 0.0.2 + rev: 0.0.3 hooks: - id: check-commit-message-is-conventional stages: [commit-msg] diff --git a/setup.py b/setup.py index 171205a..07add6c 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ setup( name="twined", - version="0.0.23", + version="0.0.24", py_modules=[], install_requires=["jsonschema ~= 3.2.0", "python-dotenv"], url="https://www.github.com/octue/twined",