Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

Check warning on line 130 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7p&open=AZ_S9Fj0zT6DEmm3FR7p&pullRequest=16
pip install nox

Check warning on line 131 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7q&open=AZ_S9Fj0zT6DEmm3FR7q&pullRequest=16

Check warning on line 131 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7r&open=AZ_S9Fj0zT6DEmm3FR7r&pullRequest=16
- name: Lint
run: nox -t lint

Expand Down Expand Up @@ -164,8 +164,12 @@
python-version: ${{ matrix.py }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip

Check warning on line 167 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7s&open=AZ_S9Fj0zT6DEmm3FR7s&pullRequest=16
pip install -U nox "tomli;python_version<'3.11'"
# argcomplete 3.7.1 declares Requires-Python >=3.8 but annotates class
# bodies with PEP 604 unions and subscripted collections.abc types
# without `from __future__ import annotations`, so it fails to import on
# 3.8 and 3.9 and takes nox down with it before any session runs.
pip install -U nox "tomli;python_version<'3.11'" "argcomplete!=3.7.1;python_version<'3.10'"

Check warning on line 172 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7t&open=AZ_S9Fj0zT6DEmm3FR7t&pullRequest=16

Check warning on line 172 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7u&open=AZ_S9Fj0zT6DEmm3FR7u&pullRequest=16
- name: Run Test Suite
run: nox -t test -- unit functional
env:
Expand Down Expand Up @@ -235,9 +239,12 @@

- name: compile requirements.txt
run: |
# uv replaces pip-compile here: pip-tools reaches into pip internals
# (pip._internal.utils.compat.stdlib_pkgs), which pip 26.2 removed, so
# `pip install --upgrade pip` and `pip-compile` cannot both succeed.
python -m pip install --upgrade pip

Check warning on line 245 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7v&open=AZ_S9Fj0zT6DEmm3FR7v&pullRequest=16
pip install pip-tools pipenv
pip-compile -o requirements.txt
pip install uv

Check warning on line 246 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7w&open=AZ_S9Fj0zT6DEmm3FR7w&pullRequest=16

Check warning on line 246 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7x&open=AZ_S9Fj0zT6DEmm3FR7x&pullRequest=16
uv pip compile pyproject.toml -o requirements.txt
cat requirements.txt

- name: Run FOSSA scan and upload build data
Expand Down Expand Up @@ -293,8 +300,8 @@
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

Check warning on line 303 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7y&open=AZ_S9Fj0zT6DEmm3FR7y&pullRequest=16
pip install nox

Check warning on line 304 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR7z&open=AZ_S9Fj0zT6DEmm3FR7z&pullRequest=16

Check warning on line 304 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR70&open=AZ_S9Fj0zT6DEmm3FR70&pullRequest=16

- name: Build mkdocs_github_changelog
run: nox -t build
Expand Down Expand Up @@ -326,8 +333,8 @@
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

Check warning on line 336 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR71&open=AZ_S9Fj0zT6DEmm3FR71&pullRequest=16
pip install nox

Check warning on line 337 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR73&open=AZ_S9Fj0zT6DEmm3FR73&pullRequest=16

Check warning on line 337 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR72&open=AZ_S9Fj0zT6DEmm3FR72&pullRequest=16


- name: Configure Git
Expand Down Expand Up @@ -387,11 +394,11 @@
path: dist
- shell: bash
run: |
pip install $(ls -t dist/*.whl | head -n1)

Check warning on line 397 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR74&open=AZ_S9Fj0zT6DEmm3FR74&pullRequest=16
- shell: bash
name: Install tomli
run: |
pip install tomli

Check warning on line 401 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR75&open=AZ_S9Fj0zT6DEmm3FR75&pullRequest=16

Check warning on line 401 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR76&open=AZ_S9Fj0zT6DEmm3FR76&pullRequest=16
- shell: python
name: Install test dependencies
run: |
Expand Down Expand Up @@ -483,7 +490,7 @@
- shell: bash
name: Install tomli
run: |
pip install tomli mkdocs-github-changelog

Check warning on line 493 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR77&open=AZ_S9Fj0zT6DEmm3FR77&pullRequest=16

Check warning on line 493 in .github/workflows/pipeline.yaml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_S9Fj0zT6DEmm3FR78&open=AZ_S9Fj0zT6DEmm3FR78&pullRequest=16
- shell: python
name: Install test dependencies
run: |
Expand Down
3 changes: 3 additions & 0 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ plugins:
# Regex string for matching the release name.
autoprocess: True
# Autoprocess the body for user and issue/pull request links
include_prereleases: False
# Include prereleases (draft releases are always excluded)
enabled: True
# Enable or disable the plugin.
```
Expand All @@ -50,6 +52,7 @@ markdown
github_api_url: <url>
release_template: <jinja2 str>
match: '[0-9+].[0-9+].[0-9]+'
include_prereleases: false
autoprocess: true
```

Expand Down
11 changes: 8 additions & 3 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,15 @@ def lint(session):

@nox.session(reuse_venv=True, tags=['lint'])
def security(session):
install_dependencies(session, required=False, optional=['dev', 'dev-security'])
# Dependencies are installed (required=True) so that the audit below sees the
# runtime requirements and not just the tooling.
install_dependencies(session, required=True, optional=['dev', 'dev-security'])
Path('reports').mkdir(exist_ok=True)
session.run('pipenv', 'lock')
session.run('pipenv', 'check')
# Replaces `pipenv lock` + `pipenv check`: current pipenv delegates checking to
# safety, which prompts to install itself and then wants an API key, so on CI it
# dies with `EOFError: EOF when reading a line`. pip-audit needs neither, and
# audits the session environment directly rather than via a generated lockfile.
session.run('pip-audit', '--progress-spinner', 'off')
session.run('bandit', '-r', 'src')
session.run('bandit', '-r', 'src', '--format', 'xml', '--output', 'reports/security-results.xml')

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[build-system]
requires = ["setuptools >= 40.9.0", 'setuptools_scm[toml]>=6.2']
build-backend = "setuptools.build_meta"
Expand Down Expand Up @@ -59,11 +59,14 @@
"flake8-noqa>=1.3.1"
]
dev-security = [
"pipenv",
"pip-audit",
"bandit"
]
dev-types = [
"mypy",
# Required by mypy's --cobertura-xml-report, which otherwise aborts with an
# INTERNAL ERROR rather than a missing-dependency message.
"lxml",
"types-colorama",
"types-setuptools"
]
Expand Down
6 changes: 4 additions & 2 deletions src/mkdocs_github_changelog/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
class GithubReleaseChangelogProcessor(BlockProcessor):
"""Changelog Markdown block processor."""

regex = re.compile(r"^(?P<heading>#{1,6} *|)::github-release-changelog ?(?P<org>[a-zA-Z\d-]+?)\/(?P<repo>.+?) *$", flags=re.MULTILINE)

Check warning on line 46 in src/mkdocs_github_changelog/extension.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=djpugh_mkdocs_github_changelog&issues=AZ_SQbmcw_ngBPLbwoh-&open=AZ_SQbmcw_ngBPLbwoh-&pullRequest=16

def __init__(
self,
Expand Down Expand Up @@ -109,16 +109,18 @@
release_template = config.get('release_template', self._config.get('release_template', None))
match = config.get('match', self._config.get('match', None))
autoprocess = config.get('autoprocess', self._config.get('autoprocess', True))
include_prereleases = config.get('include_prereleases', self._config.get('include_prereleases', False))
logger.info('Getting releases for {org}/{repo}')
logger.debug('Config:: \nrelease_template: {release_template}\ngithub_api_url: {github_api_url}\nmatch: {match}\nautoprocess: {autoprocess}')
logger.debug('Config:: \nrelease_template: {release_template}\ngithub_api_url: {github_api_url}\nmatch: {match}\nautoprocess: {autoprocess}\ninclude_prereleases: {include_prereleases}')
block = '\n\n'.join(get_releases_as_markdown(
organisation_or_user=org,
repository=repo,
token=token,
release_template=release_template,
github_api_url=github_api_url,
match=match,
autoprocess=autoprocess
autoprocess=autoprocess,
include_prereleases=include_prereleases
))
# We need to decrease/increase the base indent level
if base_indent > 0:
Expand Down
99 changes: 88 additions & 11 deletions src/mkdocs_github_changelog/get_releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

from datetime import datetime
import inspect
import json
import os
import re
Expand All @@ -16,14 +17,44 @@
from dateutil.parser import parse


from ghapi.all import GhApi, paged
import ghapi.all
from ghapi.all import GhApi
from jinja2 import Environment

from mkdocs_github_changelog import logger

# On ghapi 2.x, paged() is an async generator even against a synchronous client,
# so iterating it raises "'async_generator' object is not iterable"; sync_paged()
# is its synchronous form. On 1.x, paged() is already synchronous and sync_paged
# does not exist. Bound to one name so the call site is version-agnostic.
paged = getattr(ghapi.all, 'sync_paged', ghapi.all.paged)

RELEASE_TEMPLATE = "# [{{release.name}}]({{release.html_url}})\n*Released at {{release.published_at.isoformat()}}*\n\n{{release.body}}"


def _supports_sync() -> bool:
"""Whether the installed ghapi accepts the ``sync`` constructor flag."""
return 'sync' in inspect.signature(GhApi.__init__).parameters


def _make_api(token: str | None, github_api_url: str | None) -> GhApi:
"""Build a GhApi that returns results rather than coroutines.

ghapi 2.0 made operation calls asynchronous by default, so ``paged(...)``
yields an async generator and iterating it raises ``'async_generator' object
is not iterable``. The same release added a ``sync`` flag selecting a
synchronous transport.

The flag does not exist on ghapi 1.x, which accepts arbitrary keyword
arguments without necessarily ignoring them, so it is passed only when the
installed signature declares it.
"""
kwargs = {'token': token, 'gh_host': github_api_url}
if _supports_sync():
kwargs['sync'] = True
return GhApi(**kwargs)


class _EnvironmentFactory():
"""Jinja2 Environment Factory to allow for extension/customisation.

Expand Down Expand Up @@ -95,15 +126,55 @@ def github_issue_link(match_obj):
return release


def _process_releases(releases, match: str | None = None, autoprocess: bool = True):
def _coerce_published_at(release) -> datetime | None:
"""Return a release's ``published_at`` as a datetime, or None if it has none.

The value is checked by type rather than by Python version. An unpublished
release has no timestamp at all: the API returns ``null``, which ghapi
surfaces as an empty ``AttrDict`` rather than ``None``, so neither a
``datetime`` nor ``str`` check matches and there is nothing to parse.

The version branch below is only about *how* to parse a string:
``fromisoformat`` cannot handle the trailing ``Z`` of a GitHub timestamp
before 3.11, so dateutil is used there.
"""
value = getattr(release, 'published_at', None)
if isinstance(value, datetime):
return value
if isinstance(value, str) and value:
if sys.version_info.major >= 3 and sys.version_info.minor < 11:
return parse(value)
return datetime.fromisoformat(value)
return None


def _process_releases(
releases,
match: str | None = None,
autoprocess: bool = True,
include_prereleases: bool = False,
):
selected_releases = []
for release in releases:
# Convert the published_at to datetime object
if not isinstance(release.published_at, datetime):
if sys.version_info.major >= 3 and sys.version_info.minor < 11:
release.published_at = parse(release.published_at)
else:
release.published_at = datetime.fromisoformat(release.published_at)
# Drafts are unpublished, so they have no published_at and an empty
# name; they render as a broken, dateless entry and do not belong in a
# changelog. Skipping them also avoids failing the whole build on the
# missing timestamp.
if getattr(release, 'draft', False):
logger.debug(f'Skipping draft release {release.html_url}')
continue
# A prerelease is published, so it renders fine, but it is usually noise
# in a changelog -- excluded unless asked for.
if not include_prereleases and getattr(release, 'prerelease', False):
logger.debug(f'Skipping prerelease {release.html_url}')
continue
published_at = _coerce_published_at(release)
if published_at is None:
# Defensive: a published release should always carry a timestamp, so
# warn rather than fail the build if one somehow does not.
logger.warning(f'Skipping release with no published_at: {release.html_url}')
continue
release.published_at = published_at
if autoprocess is None or autoprocess:
autoprocess_github_links(release)
if (match and re.match(match, release.name) is not None) or not match:
Expand All @@ -118,19 +189,25 @@ def get_releases_as_markdown(
release_template: str | None = RELEASE_TEMPLATE,
github_api_url: str | None = None,
match: str | None = None,
autoprocess: bool | None = True
autoprocess: bool | None = True,
include_prereleases: bool | None = False
):
"""Get the releases from github as a list of rendered markdown strings."""
if github_api_url is not None:
github_api_url = github_api_url.rstrip('/')
logger.info('Getting releases from github')
api = GhApi(token=token, gh_host=github_api_url)
api = _make_api(token, github_api_url)
releases = []
for page in paged(api.repos.list_releases, organisation_or_user, repository, per_page=100):
releases += page
logger.info(f'Processing releases from github, {len(releases)} found')
jinja_environment = JINJA_ENVIRONMENT_FACTORY.environment
selected_releases = _process_releases(releases, match=match, autoprocess=autoprocess)
selected_releases = _process_releases(
releases,
match=match,
autoprocess=autoprocess,
include_prereleases=include_prereleases,
)
if release_template is None:
release_template = RELEASE_TEMPLATE
logger.info(f'Rendering releases from github, {len(releases)} selected')
Expand Down
2 changes: 2 additions & 0 deletions src/mkdocs_github_changelog/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class PluginConfig(Config):
"""Regex string for matching the rleease name."""
autoprocess = opt.Type(bool, default=True)
"""Autoprocess the release bodies for issue and username links."""
include_prereleases = opt.Type(bool, default=False)
"""Include prereleases in the changelog."""
enabled = opt.Type(bool, default=True)
"""Enable or disable the plugin."""

Expand Down
11 changes: 7 additions & 4 deletions tests/functional/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from pathlib import Path
import unittest
from unittest.mock import MagicMock, patch
import webbrowser

from click.testing import CliRunner
from fastcore.net import HTTP403ForbiddenError, HTTP404NotFoundError
Expand All @@ -27,17 +26,24 @@ def mock_gh_api(func):
@patch.object(get_releases, 'paged', autospec=True)
@wraps(func)
def mocked_call(self, paged, GhApi):
# draft and prerelease must be set explicitly: an unset MagicMock
# attribute is truthy, so leaving them off makes every release look like
# a draft prerelease and it gets filtered out of the changelog.
release1_content = MagicMock()
release1_content.body = RELEASE_1
release1_content.name = '0.2.0'
release1_content.html_url = 'https://www.google.com'
release1_content.published_at = datetime(2023, 12, 1, 13, 46).astimezone().isoformat()
release1_content.draft = False
release1_content.prerelease = False

release2_content = MagicMock()
release2_content.body = RELEASE_2
release2_content.name = '0.1.0'
release2_content.html_url = 'https://www.google.com'
release2_content.published_at = datetime(2023, 11, 1, 13, 46).astimezone().isoformat()
release2_content.draft = False
release2_content.prerelease = False

def paged_mock(func, organisation_or_user, repository, *args, **kwargs):
print('Mocked', organisation_or_user, repository)
Expand Down Expand Up @@ -114,9 +120,7 @@ def test_mkdocs(self, *args):
resp = runner.invoke(build_command, catch_exceptions=False)
self.assertEqual(resp.exit_code, 0, resp.exc_info)
self.assertTrue(Path('html').exists())
# webbrowser.open(str(Path('html', 'index.html').absolute()))
index_html = Path('html', 'index.html')
webbrowser.open(str(index_html.absolute()))
contents = index_html.read_text(encoding="utf8")
self.assertIn('<h3 id="020"><a href="https://www.google.com">0.2.0</a></h3>\n<p><em>Released at 2023-12-01T13:46:00+00:00</em>', contents)
self.assertIn('<h4 id="020_1"><a href="https://www.google.com">0.2.0</a></h4>\n<p><em>Released at 2023-12-01T13:46:00+00:00</em>', contents)
Expand Down Expand Up @@ -198,7 +202,6 @@ def test_mkdocs(self, *args):
self.assertTrue(Path('html').exists())

index_html = Path('html', 'index.html')
webbrowser.open(str(index_html.absolute()))
contents = index_html.read_text(encoding="utf8")
self.assertIn('<h3 id="release-0122"><a href="https://github.com/djpugh/fastapi_aad_auth/releases/tag/0.1.22">Release 0.1.22</a></h3>', contents)
self.assertIn('<p><em>Released at 2022-04-17T14:22:48+00:00</em>', contents)
Expand Down
Loading
Loading