From 7ef6624bcea8fb56d2da8b4934572ce3323cbd2f Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Tue, 16 Dec 2025 16:15:55 +0100 Subject: [PATCH 1/2] Change Python code formatting and linting tools (#270) Signed-off-by: Tobias Wolf On-behalf-of: SAP --- tests/github/test_create_github_release.py | 86 ---------------------- 1 file changed, 86 deletions(-) delete mode 100644 tests/github/test_create_github_release.py diff --git a/tests/github/test_create_github_release.py b/tests/github/test_create_github_release.py deleted file mode 100644 index 23af3492..00000000 --- a/tests/github/test_create_github_release.py +++ /dev/null @@ -1,86 +0,0 @@ -import pytest -import requests -import requests_mock - -from gardenlinux.github.release import create_github_release, write_to_release_id_file - -from ..constants import ( - TEST_GARDENLINUX_COMMIT, - TEST_GARDENLINUX_RELEASE, -) - - -def test_create_github_release_needs_github_token(): - with requests_mock.Mocker(): - with pytest.raises(ValueError) as exn: - create_github_release( - "gardenlinux", - "gardenlinux", - TEST_GARDENLINUX_RELEASE, - TEST_GARDENLINUX_COMMIT, - False, - "", - ) - assert str(exn.value) == "GITHUB_TOKEN environment variable not set", ( - "Expected an exception to be raised on missing GITHUB_TOKEN environment variable" - ) - - -def test_create_github_release_raise_on_failure(caplog, github_token): - with requests_mock.Mocker() as m: - with pytest.raises(requests.exceptions.HTTPError): - m.post( - "https://api.github.com/repos/gardenlinux/gardenlinux/releases", - text="{}", - status_code=503, - ) - create_github_release( - "gardenlinux", - "gardenlinux", - TEST_GARDENLINUX_RELEASE, - TEST_GARDENLINUX_COMMIT, - False, - "", - ) - assert any( - "Failed to create release" in record.message for record in caplog.records - ), "Expected a failure log record" - - -def test_create_github_release(caplog, github_token): - with requests_mock.Mocker() as m: - m.post( - "https://api.github.com/repos/gardenlinux/gardenlinux/releases", - text='{"id": 101}', - status_code=201, - ) - assert ( - create_github_release( - "gardenlinux", - "gardenlinux", - TEST_GARDENLINUX_RELEASE, - TEST_GARDENLINUX_COMMIT, - False, - "", - ) - == 101 - ) - assert any( - "Release created successfully" in record.message - for record in caplog.records - ), "Expected a success log record" - - -def test_write_to_release_id_file(release_id_file): - write_to_release_id_file(TEST_GARDENLINUX_RELEASE) - assert release_id_file.read_text() == TEST_GARDENLINUX_RELEASE - - -def test_write_to_release_id_file_broken_file_permissions(release_id_file, caplog): - release_id_file.touch(0) # this will make the file unwritable - - with pytest.raises(SystemExit): - write_to_release_id_file(TEST_GARDENLINUX_RELEASE) - assert any("Could not create" in record.message for record in caplog.records), ( - "Expected a failure log record" - ) From 56af6705a6812828f00b1c6011bc4183bc18cc5a Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Thu, 4 Dec 2025 12:03:50 +0100 Subject: [PATCH 2/2] Add support to create pre-releases with a generated changelog Signed-off-by: Tobias Wolf --- .github/workflows/release.yml | 51 +++++ cliff.toml | 84 ++++++++ poetry.lock | 98 ++++++++- pyproject.toml | 1 + src/gardenlinux/github/__init__.py | 9 + src/gardenlinux/github/client.py | 73 +++++++ src/gardenlinux/github/release/__init__.py | 46 +--- src/gardenlinux/github/release/__main__.py | 35 +++- src/gardenlinux/github/release/release.py | 231 +++++++++++++++++++++ src/gardenlinux/oci/__main__.py | 4 +- tests/github/constants.py | 77 +++++++ tests/github/test_github_script.py | 84 +++++--- tests/github/test_release.py | 84 ++++++++ 13 files changed, 798 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 cliff.toml create mode 100644 src/gardenlinux/github/client.py create mode 100644 src/gardenlinux/github/release/release.py create mode 100644 tests/github/constants.py create mode 100644 tests/github/test_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..cde48637 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: pre-release + +on: + push: + tags: [ "[0-9]+.[0-9]+.[0-9]+*" ] + +jobs: + create-pre-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout commit + uses: actions/checkout@v6 + with: + sparse-checkout: | + cliff.toml + sparse-checkout-cone-mode: false + - uses: gardenlinux/python-gardenlinux-lib/.github/actions/setup@main + with: + version: 1.0.0-pre1 + - name: Use cargo cache + id: cache-cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo + key: gh-release-${{ runner.os }}-cargo-${{ hashFiles('~/.cargo/.crates.toml') }} + restore-keys: gh-release-${{ runner.os }}-cargo- + - name: Install git-cliff + if: steps.cache-cargo.outputs.cache-hit != 'true' + run: | + cargo install git-cliff + - name: Get the Git tag name + id: get-tag-name + run: echo "tag-name=${GITHUB_REF/refs\/tags\//}" | tee -a "$GITHUB_OUTPUT" + - id: release + name: Create changelog and release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gl-gh-release create \ + --repo "python-gardenlinux-lib" \ + --tag "${{ steps.get-tag-name.outputs.tag-name }}" \ + --commit "${{ github.sha }}" \ + --name 'python-gardenlinux-lib v${{ steps.get-tag-name.outputs.tag-name }}' \ + --latest \ + --body " + $(git-cliff -o - --current) + " diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..9ef8bdfc --- /dev/null +++ b/cliff.toml @@ -0,0 +1,84 @@ +# git-cliff ~ configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +# A Tera template to be rendered as the changelog's header. +# See https://keats.github.io/tera/docs/#introduction +header = """ +{% if version -%} + # Changelog for {{ version }} }} +{% else -%} + # Changelog +{% endif -%} + +All notable changes since last release will be documented below. +""" +# A Tera template to be rendered for each release in the changelog. +# See https://keats.github.io/tera/docs/#introduction +body = """ +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} + +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {%- for commit in commits %} + - {{ commit.message | split(pat="\n") | first | upper_first | trim }}\ + {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} + {% if commit.remote.pr_number %} in \ + [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ + {%- endif -%} + {% endfor %} +{% endfor %} +""" +# A Tera template to be rendered as the changelog's footer. +# See https://keats.github.io/tera/docs/#introduction +footer = """ +{%- macro remote_url() -%} + https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} +{%- endmacro -%} + +{% for release in releases -%} + {% if release.version -%} + {% if release.previous.version -%} + [{{ release.version | trim_start_matches(pat="v") }}]: \ + {{ self::remote_url() }}/compare/{{ release.previous.version }}...{{ release.version }} + {% endif -%} + {% else -%} + [unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}...HEAD + {% endif -%} +{% endfor %} + +""" +# Remove leading and trailing whitespaces from the changelog's body. +trim = true + +[git] +# Parse commits according to the conventional commits specification. +# See https://www.conventionalcommits.org +conventional_commits = true +# Exclude commits that do not match the conventional commits specification. +filter_unconventional = false +# An array of regex based parsers for extracting data from the commit message. +# Assigns commits to groups. +# Optionally sets the commit's scope and can decide to exclude commits from further processing. +commit_parsers = [ + { message = "^[a|A]dd", group = "Added" }, + { message = "^[s|S]upport", group = "Added" }, + { message = "^[r|R]emove", group = "Removed" }, + { message = "^.*: add", group = "Added" }, + { message = "^.*: support", group = "Added" }, + { message = "^.*: remove", group = "Removed" }, + { message = "^.*: delete", group = "Removed" }, + { message = "^test", group = "Fixed" }, + { message = "^fix", group = "Fixed" }, + { message = "^.*: fix", group = "Fixed" }, + { message = "^.*", group = "Changed" }, +] +# Prevent commits that are breaking from being excluded by commit parsers. +filter_commits = false +# Order releases topologically instead of chronologically. +topo_order = true +# Order of commits in each group/release within the changelog. +# Allowed values: newest, oldest +sort_commits = "oldest" diff --git a/poetry.lock b/poetry.lock index 4429bf3b..7c51b7f3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1154,6 +1154,25 @@ files = [ [package.dependencies] cffi = ">=2.0" +[[package]] +name = "pygithub" +version = "2.8.1" +description = "Use the full Github API v3" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygithub-2.8.1-py3-none-any.whl", hash = "sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0"}, + {file = "pygithub-2.8.1.tar.gz", hash = "sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9"}, +] + +[package.dependencies] +pyjwt = {version = ">=2.4.0", extras = ["crypto"]} +pynacl = ">=1.4.0" +requests = ">=2.14.0" +typing-extensions = ">=4.5.0" +urllib3 = ">=1.26.0" + [[package]] name = "pygments" version = "2.19.2" @@ -1169,6 +1188,71 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pynacl" +version = "1.6.1" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pynacl-1.6.1-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:7d7c09749450c385301a3c20dca967a525152ae4608c0a096fe8464bfc3df93d"}, + {file = "pynacl-1.6.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc734c1696ffd49b40f7c1779c89ba908157c57345cf626be2e0719488a076d3"}, + {file = "pynacl-1.6.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3cd787ec1f5c155dc8ecf39b1333cfef41415dc96d392f1ce288b4fe970df489"}, + {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b35d93ab2df03ecb3aa506be0d3c73609a51449ae0855c2e89c7ed44abde40b"}, + {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dece79aecbb8f4640a1adbb81e4aa3bfb0e98e99834884a80eb3f33c7c30e708"}, + {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c2228054f04bf32d558fb89bb99f163a8197d5a9bf4efa13069a7fa8d4b93fc3"}, + {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:2b12f1b97346f177affcdfdc78875ff42637cb40dcf79484a97dae3448083a78"}, + {file = "pynacl-1.6.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e735c3a1bdfde3834503baf1a6d74d4a143920281cb724ba29fb84c9f49b9c48"}, + {file = "pynacl-1.6.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3384a454adf5d716a9fadcb5eb2e3e72cd49302d1374a60edc531c9957a9b014"}, + {file = "pynacl-1.6.1-cp314-cp314t-win32.whl", hash = "sha256:d8615ee34d01c8e0ab3f302dcdd7b32e2bcf698ba5f4809e7cc407c8cdea7717"}, + {file = "pynacl-1.6.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5f5b35c1a266f8a9ad22525049280a600b19edd1f785bccd01ae838437dcf935"}, + {file = "pynacl-1.6.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d984c91fe3494793b2a1fb1e91429539c6c28e9ec8209d26d25041ec599ccf63"}, + {file = "pynacl-1.6.1-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:a6f9fd6d6639b1e81115c7f8ff16b8dedba1e8098d2756275d63d208b0e32021"}, + {file = "pynacl-1.6.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e49a3f3d0da9f79c1bec2aa013261ab9fa651c7da045d376bd306cf7c1792993"}, + {file = "pynacl-1.6.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7713f8977b5d25f54a811ec9efa2738ac592e846dd6e8a4d3f7578346a841078"}, + {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a3becafc1ee2e5ea7f9abc642f56b82dcf5be69b961e782a96ea52b55d8a9fc"}, + {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ce50d19f1566c391fedc8dc2f2f5be265ae214112ebe55315e41d1f36a7f0a9"}, + {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:543f869140f67d42b9b8d47f922552d7a967e6c116aad028c9bfc5f3f3b3a7b7"}, + {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a2bb472458c7ca959aeeff8401b8efef329b0fc44a89d3775cffe8fad3398ad8"}, + {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3206fa98737fdc66d59b8782cecc3d37d30aeec4593d1c8c145825a345bba0f0"}, + {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:53543b4f3d8acb344f75fd4d49f75e6572fce139f4bfb4815a9282296ff9f4c0"}, + {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:319de653ef84c4f04e045eb250e6101d23132372b0a61a7acf91bac0fda8e58c"}, + {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:262a8de6bba4aee8a66f5edf62c214b06647461c9b6b641f8cd0cb1e3b3196fe"}, + {file = "pynacl-1.6.1-cp38-abi3-win32.whl", hash = "sha256:9fd1a4eb03caf8a2fe27b515a998d26923adb9ddb68db78e35ca2875a3830dde"}, + {file = "pynacl-1.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:a569a4069a7855f963940040f35e87d8bc084cb2d6347428d5ad20550a0a1a21"}, + {file = "pynacl-1.6.1-cp38-abi3-win_arm64.whl", hash = "sha256:5953e8b8cfadb10889a6e7bd0f53041a745d1b3d30111386a1bb37af171e6daf"}, + {file = "pynacl-1.6.1.tar.gz", hash = "sha256:8d361dac0309f2b6ad33b349a56cd163c98430d409fa503b10b70b3ad66eaa1d"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} + +[package.extras] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] + [[package]] name = "pytest" version = "9.0.2" @@ -1808,6 +1892,18 @@ files = [ {file = "stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945"}, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + [[package]] name = "urllib3" version = "2.6.2" @@ -1883,4 +1979,4 @@ test = ["pytest", "pytest-cov"] [metadata] lock-version = "2.1" python-versions = ">=3.13, <3.14" -content-hash = "72c619d1320245804ef38e5d9d45f0e026329d19e2ccbff02d65ec69dc8939f4" +content-hash = "df64b1402b852112cfd8141b0ce614a61e4360e3b1329c2997847a9551618977" diff --git a/pyproject.toml b/pyproject.toml index 7fd177bd..75327e81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ networkx = "^3.6" oras = "^0.2.38" pygit2 = "^1.19.0" pygments = "^2.19.2" +PyGithub = "^2.8.1" PyYAML = "^6.0.2" gitpython = "^3.1.45" diff --git a/src/gardenlinux/github/__init__.py b/src/gardenlinux/github/__init__.py index e69de29b..caae6fa6 100644 --- a/src/gardenlinux/github/__init__.py +++ b/src/gardenlinux/github/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +""" +GitHub module +""" + +from .client import Client + +__all__ = ["Client"] diff --git a/src/gardenlinux/github/client.py b/src/gardenlinux/github/client.py new file mode 100644 index 00000000..2a3c8f6b --- /dev/null +++ b/src/gardenlinux/github/client.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +""" +GitHub client +""" + +from logging import Logger +from os import environ +from typing import Any, Optional + +from github import Auth, Github + +from ..logger import LoggerSetup + + +class Client(object): + """ + GitHub client instance to provide methods for interaction with GitHub API. + + :author: Garden Linux Maintainers + :copyright: Copyright 2024 SAP SE + :package: gardenlinux + :subpackage: github + :since: 1.0.0 + :license: https://www.apache.org/licenses/LICENSE-2.0 + Apache License, Version 2.0 + """ + + def __init__(self, token: Optional[str] = None, logger: Optional[Logger] = None): + """ + Constructor __init__(Client) + + :param token: GitHub access token + :param logger: Logger instance + + :since: 1.0.0 + """ + + self._client = None + self._token = token + + if self._token is None or self._token.strip() == "": + self._token = environ.get("GITHUB_TOKEN") + + if self._token is None: + raise ValueError("GITHUB_TOKEN environment variable not set") + + if logger is None or not logger.hasHandlers(): + logger = LoggerSetup.get_logger("gardenlinux.github") + + self._logger = logger + + @property + def instance(self) -> Github: + if self._client is None: + self._client = Github(auth=Auth.Token(self._token)) + + return self._client + + def __getattr__(self, name: str) -> Any: + """ + python.org: Called when an attribute lookup has not found the attribute in + the usual places (i.e. it is not an instance attribute nor is it found in the + class tree for self). + + :param name: Attribute name + + :return: (mixed) Attribute + :since: 0.8.0 + """ + + self._logger.debug(f"gardenlinux.github.Client.{name} accessed") + return getattr(self.instance, name) diff --git a/src/gardenlinux/github/release/__init__.py b/src/gardenlinux/github/release/__init__.py index 18dd9cb8..4140bde2 100644 --- a/src/gardenlinux/github/release/__init__.py +++ b/src/gardenlinux/github/release/__init__.py @@ -1,52 +1,15 @@ -import json import os import sys import requests -from gardenlinux.constants import RELEASE_ID_FILE, REQUESTS_TIMEOUTS -from gardenlinux.logger import LoggerSetup +from ...constants import RELEASE_ID_FILE, REQUESTS_TIMEOUTS +from ...logger import LoggerSetup +from .release import Release LOGGER = LoggerSetup.get_logger("gardenlinux.github.release", "INFO") -def create_github_release(owner, repo, tag, commitish, latest, body): - token = os.environ.get("GITHUB_TOKEN") - if not token: - raise ValueError("GITHUB_TOKEN environment variable not set") - - headers = { - "Authorization": f"token {token}", - "Accept": "application/vnd.github.v3+json", - } - - data = { - "tag_name": tag, - "target_commitish": commitish, - "name": tag, - "body": body, - "draft": False, - "prerelease": False, - "make_latest": "true" if latest else "false", - } - - response = requests.post( - f"https://api.github.com/repos/{owner}/{repo}/releases", - headers=headers, - data=json.dumps(data), - timeout=REQUESTS_TIMEOUTS, - ) - - if response.status_code == 201: - LOGGER.info("Release created successfully") - response_json = response.json() - return response_json.get("id") - else: - LOGGER.error("Failed to create release") - LOGGER.debug(response.json()) - response.raise_for_status() - - def write_to_release_id_file(release_id): try: with open(RELEASE_ID_FILE, "w") as file: @@ -94,3 +57,6 @@ def upload_to_github_release_page( f"Upload failed with status code {response.status_code}: {response.text}" ) response.raise_for_status() + + +__all__ = ["Release", "write_to_release_id_file", "upload_to_github_release_page"] diff --git a/src/gardenlinux/github/release/__main__.py b/src/gardenlinux/github/release/__main__.py index 61c37338..e992c7d8 100644 --- a/src/gardenlinux/github/release/__main__.py +++ b/src/gardenlinux/github/release/__main__.py @@ -5,10 +5,10 @@ from ..release_notes import create_github_release_notes from . import ( - create_github_release, upload_to_github_release_page, write_to_release_id_file, ) +from .release import Release LOGGER = LoggerSetup.get_logger("gardenlinux.github", "INFO") @@ -21,9 +21,19 @@ def main(): create_parser.add_argument("--owner", default="gardenlinux") create_parser.add_argument("--repo", default="gardenlinux") create_parser.add_argument("--tag", required=True) - create_parser.add_argument("--commit", required=True) + create_parser.add_argument("--name") + create_parser.add_argument("--body", required=True) + create_parser.add_argument("--commit") + create_parser.add_argument("--pre-release", action="store_true", default=True) create_parser.add_argument("--latest", action="store_true", default=False) - create_parser.add_argument("--dry-run", action="store_true", default=False) + + create_parser_gl = subparsers.add_parser("create-with-gl-release-notes") + create_parser_gl.add_argument("--owner", default="gardenlinux") + create_parser_gl.add_argument("--repo", default="gardenlinux") + create_parser_gl.add_argument("--tag", required=True) + create_parser_gl.add_argument("--commit", required=True) + create_parser_gl.add_argument("--latest", action="store_true", default=False) + create_parser_gl.add_argument("--dry-run", action="store_true", default=False) upload_parser = subparsers.add_parser("upload") upload_parser.add_argument("--owner", default="gardenlinux") @@ -35,6 +45,15 @@ def main(): args = parser.parse_args() if args.command == "create": + release = Release(args.repo, args.owner) + release.tag = args.tag + release.name = args.name + release.body = args.body + release.commit_hash = args.commit + release.is_pre_release = args.pre_release + release.is_latest = args.latest + release.create() + elif args.command == "create-with-gl-release-notes": body = create_github_release_notes( args.tag, args.commit, GARDENLINUX_GITHUB_RELEASE_BUCKET_NAME ) @@ -43,9 +62,13 @@ def main(): print("This release would be created:") print(body) else: - release_id = create_github_release( - args.owner, args.repo, args.tag, args.commit, args.latest, body - ) + release = Release(args.repo, args.owner) + release.tag = args.tag + release.body = body + release.commit_hash = args.commit + release.is_latest = args.latest + + release_id = release.create() write_to_release_id_file(f"{release_id}") LOGGER.info(f"Release created with ID: {release_id}") elif args.command == "upload": diff --git a/src/gardenlinux/github/release/release.py b/src/gardenlinux/github/release/release.py new file mode 100644 index 00000000..d486839f --- /dev/null +++ b/src/gardenlinux/github/release/release.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- + +""" +GitHub release container +""" + +from logging import Logger +from typing import Optional + +from ...logger import LoggerSetup +from ..client import Client + + +class Release(object): + """ + GitHub release instance to provide methods for interaction. + + :author: Garden Linux Maintainers + :copyright: Copyright 2024 SAP SE + :package: gardenlinux + :subpackage: github + :since: 1.0.0 + :license: https://www.apache.org/licenses/LICENSE-2.0 + Apache License, Version 2.0 + """ + + def __init__( + self, + repo: str, + owner: str = "gardenlinux", + token: Optional[str] = None, + logger: Optional[Logger] = None, + ): + """ + Constructor __init__(Release) + + :param repo: GitHub repository containing releases + :param owner: GitHub owner for release data + :param token: GitHub access token + :param logger: Logger instance + + :since: 1.0.0 + """ + + self._owner = owner + self._repo = repo + self._name = None + self._tag = None + self._commitish = None + self._latest = True + self._pre_release = False + self._release_body = None + + if logger is None or not logger.hasHandlers(): + logger = LoggerSetup.get_logger("gardenlinux.github") + + self._logger = logger + + self._client = Client(token, self._logger) + + @property + def body(self) -> str: + """ + Returns the Git release body set. + + :return: (str) Git release body + :since: 1.0.0 + """ + + if self._release_body is None: + raise ValueError("GitHub release body not set") + + return self._release_body + + @body.setter + def body(self, value: str) -> None: + """ + Sets the Git release body. + + :param value: Git release body + + :since: 1.0.0 + """ + + self._release_body = value + + @property + def commitish(self) -> Optional[str]: + """ + Returns the Git release related commit hash. + + :return: (str) Git release commit hash + :since: 1.0.0 + """ + + return self._commitish + + @commitish.setter + def commitish(self, value: str) -> None: + """ + Sets the Git release related commit hash. + + :param value: Git release commit hash + + :since: 1.0.0 + """ + + self._commitish = value + + @property + def is_latest(self) -> bool: + """ + Returns true if the Git release is marked as "latest". + + :return: (str) Git release latest status + :since: 1.0.0 + """ + + return self._latest == True + + @is_latest.setter + def is_latest(self, value: bool) -> None: + """ + If set to true the Git release created will be marked as "latest". + + :param value: Git release latest status + + :since: 1.0.0 + """ + + self._latest = bool(value) + + @property + def is_pre_release(self) -> bool: + """ + Returns true if the Git release is marked as pre-release. + + :return: (str) Git release pre-release status + :since: 1.0.0 + """ + + return self._pre_release == True + + @is_pre_release.setter + def is_pre_release(self, value: bool) -> None: + """ + If set to true the Git release created will be marked as pre-release. + + :param value: Git release pre-release status + + :since: 1.0.0 + """ + + self._pre_release = bool(value) + + @property + def name(self) -> str: + """ + Returns the Git release name set. + + :return: (str) Git release name + :since: 1.0.0 + """ + + if self._name is None: + return self.tag + + return self._name + + @name.setter + def name(self, value: str) -> None: + """ + Sets the Git release name. + + :param value: Git release name + + :since: 1.0.0 + """ + + self._name = value + + @property + def tag(self) -> str: + """ + Returns the Git release tag set. + + :return: (str) Git release tag + :since: 1.0.0 + """ + + if self._tag is None: + raise ValueError("GitHub release tag not set") + + return self._tag + + @tag.setter + def tag(self, value: str) -> None: + """ + Sets the Git release tag. + + :param value: Git release tag + + :since: 1.0.0 + """ + + self._tag = value + + def create(self) -> str: + """ + Creates an GitHub release. + + :return: (str) GitHub release ID created + :since: 1.0.0 + """ + + kwargs = { + "name": self.name, + "message": self.body, + "draft": False, + "prerelease": self.is_pre_release, + "make_latest": "true" if self.is_latest else "false", + } + + if self.commitish is not None: + kwargs["target_commitish"] = self._commitish + + release = self._client.get_repo( + f"{self._owner}/{self._repo}" + ).create_git_release(self.tag, **kwargs) + + return release.id diff --git a/src/gardenlinux/oci/__main__.py b/src/gardenlinux/oci/__main__.py index 1d6371ee..98b4aa93 100755 --- a/src/gardenlinux/oci/__main__.py +++ b/src/gardenlinux/oci/__main__.py @@ -15,7 +15,9 @@ @click.group() def cli() -> None: """ - gl-oci click argument entrypoint +gl-oci provides functionality to handle OCI containers. It can pull and push +images from remote repositories as well as handle GardenLinux artifacts, OCI +image indices and manifests. :since: 0.7.0 """ diff --git a/tests/github/constants.py b/tests/github/constants.py new file mode 100644 index 00000000..91ea3419 --- /dev/null +++ b/tests/github/constants.py @@ -0,0 +1,77 @@ +REPO_JSON = { + "id": 1, + "node_id": "test", + "name": "gardenlinux", + "full_name": "gardenlinux/gardenlinux", + "owner": {}, + "private": False, + "html_url": "https://github.com/gardenlinux/gardenlinux", + "description": "Happily copied from REST API endpoints for repositories @ github.com", + "fork": False, + "url": "https://api.github.com/repos/gardenlinux/gardenlinux", + "archive_url": "https://api.github.com/repos/gardenlinux/gardenlinux/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/gardenlinux/gardenlinux/assignees{/user}", + "blobs_url": "https://api.github.com/repos/gardenlinux/gardenlinux/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/gardenlinux/gardenlinux/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/gardenlinux/gardenlinux/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/gardenlinux/gardenlinux/comments{/number}", + "commits_url": "https://api.github.com/repos/gardenlinux/gardenlinux/commits{/sha}", + "compare_url": "https://api.github.com/repos/gardenlinux/gardenlinux/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/gardenlinux/gardenlinux/contents/{+path}", + "contributors_url": "https://api.github.com/repos/gardenlinux/gardenlinux/contributors", + "deployments_url": "https://api.github.com/repos/gardenlinux/gardenlinux/deployments", + "downloads_url": "https://api.github.com/repos/gardenlinux/gardenlinux/downloads", + "events_url": "https://api.github.com/repos/gardenlinux/gardenlinux/events", + "forks_url": "https://api.github.com/repos/gardenlinux/gardenlinux/forks", + "git_commits_url": "https://api.github.com/repos/gardenlinux/gardenlinux/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/gardenlinux/gardenlinux/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/gardenlinux/gardenlinux/git/tags{/sha}", + "git_url": "git:github.com/gardenlinux/gardenlinux.git", + "issue_comment_url": "https://api.github.com/repos/gardenlinux/gardenlinux/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/gardenlinux/gardenlinux/issues/events{/number}", + "issues_url": "https://api.github.com/repos/gardenlinux/gardenlinux/issues{/number}", + "keys_url": "https://api.github.com/repos/gardenlinux/gardenlinux/keys{/key_id}", + "labels_url": "https://api.github.com/repos/gardenlinux/gardenlinux/labels{/name}", + "languages_url": "https://api.github.com/repos/gardenlinux/gardenlinux/languages", + "merges_url": "https://api.github.com/repos/gardenlinux/gardenlinux/merges", + "milestones_url": "https://api.github.com/repos/gardenlinux/gardenlinux/milestones{/number}", + "notifications_url": "https://api.github.com/repos/gardenlinux/gardenlinux/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/gardenlinux/gardenlinux/pulls{/number}", + "releases_url": "https://api.github.com/repos/gardenlinux/gardenlinux/releases{/id}", + "ssh_url": "git@github.com:gardenlinux/gardenlinux.git", + "stargazers_url": "https://api.github.com/repos/gardenlinux/gardenlinux/stargazers", + "statuses_url": "https://api.github.com/repos/gardenlinux/gardenlinux/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/gardenlinux/gardenlinux/subscribers", + "subscription_url": "https://api.github.com/repos/gardenlinux/gardenlinux/subscription", + "tags_url": "https://api.github.com/repos/gardenlinux/gardenlinux/tags", + "teams_url": "https://api.github.com/repos/gardenlinux/gardenlinux/teams", + "trees_url": "https://api.github.com/repos/gardenlinux/gardenlinux/git/trees{/sha}", + "clone_url": "https://github.com/gardenlinux/gardenlinux.git", + "mirror_url": "git:git.example.com/gardenlinux/gardenlinux", + "hooks_url": "https://api.github.com/repos/gardenlinux/gardenlinux/hooks", + "svn_url": "https://svn.github.com/gardenlinux/gardenlinux", + "homepage": "https://gardenlinux.io", + "language": None, + "forks_count": 0, + "stargazers_count": 0, + "watchers_count": 0, + "size": 1, + "default_branch": "main", + "open_issues_count": 0, + "is_template": False, + "topics": [], + "has_issues": True, + "has_projects": True, + "has_wiki": True, + "has_pages": True, + "has_downloads": True, + "has_discussions": True, + "archived": False, + "disabled": False, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "permissions": {"admin": False, "push": False, "pull": True}, + "security_and_analysis": {}, +} diff --git a/tests/github/test_github_script.py b/tests/github/test_github_script.py index 907e0da5..cc185f62 100644 --- a/tests/github/test_github_script.py +++ b/tests/github/test_github_script.py @@ -1,11 +1,13 @@ import sys import pytest +import requests_mock import gardenlinux.github.release.__main__ as gh from gardenlinux.constants import GARDENLINUX_GITHUB_RELEASE_BUCKET_NAME from ..constants import TEST_GARDENLINUX_COMMIT, TEST_GARDENLINUX_RELEASE +from .constants import REPO_JSON def test_script_parse_args_wrong_command(monkeypatch, capfd): @@ -22,7 +24,16 @@ def test_script_parse_args_wrong_command(monkeypatch, capfd): def test_script_parse_args_create_command_required_args(monkeypatch, capfd): monkeypatch.setattr( - sys, "argv", ["gh", "create", "--owner", "gardenlinux", "--repo", "gardenlinux"] + sys, + "argv", + [ + "gh", + "create-with-gl-release-notes", + "--owner", + "gardenlinux", + "--repo", + "gardenlinux", + ], ) with pytest.raises(SystemExit): @@ -55,7 +66,7 @@ def test_script_create_dry_run(monkeypatch, capfd): "argv", [ "gh", - "create", + "create-with-gl-release-notes", "--owner", "gardenlinux", "--repo", @@ -82,37 +93,48 @@ def test_script_create_dry_run(monkeypatch, capfd): def test_script_create(monkeypatch, caplog): - monkeypatch.setattr( - sys, - "argv", - [ - "gh", - "create", - "--owner", - "gardenlinux", - "--repo", - "gardenlinux", - "--tag", - TEST_GARDENLINUX_RELEASE, - "--commit", - TEST_GARDENLINUX_COMMIT, - ], - ) - monkeypatch.setattr( - "gardenlinux.github.release.__main__.create_github_release_notes", - lambda tag, commit, bucket: f"{tag} {commit} {bucket}", - ) - monkeypatch.setattr( - "gardenlinux.github.release.__main__.create_github_release", - lambda a1, a2, a3, a4, a5, a6: TEST_GARDENLINUX_RELEASE, - ) + with requests_mock.Mocker() as m: + m.get( + "//api.github.com:443/repos/gardenlinux/gardenlinux", + json=REPO_JSON, + status_code=200, + ) + + m.post( + "//api.github.com:443/repos/gardenlinux/gardenlinux/releases", + json={"id": 101}, + status_code=201, + ) + + monkeypatch.setenv("GITHUB_TOKEN", "test") + + monkeypatch.setattr( + sys, + "argv", + [ + "gh", + "create-with-gl-release-notes", + "--owner", + "gardenlinux", + "--repo", + "gardenlinux", + "--tag", + TEST_GARDENLINUX_RELEASE, + "--commit", + TEST_GARDENLINUX_COMMIT, + ], + ) + monkeypatch.setattr( + "gardenlinux.github.release.__main__.create_github_release_notes", + lambda tag, commit, bucket: f"{tag} {commit} {bucket}", + ) - gh.main() + gh.main() - assert any( - f"Release created with ID: {TEST_GARDENLINUX_RELEASE}" in record.message - for record in caplog.records - ), "Expected a release creation confirmation log entry" + assert any( + "Release created with ID: 101" in record.message + for record in caplog.records + ), "Expected a release creation confirmation log entry" def test_script_upload_dry_run(monkeypatch, capfd): diff --git a/tests/github/test_release.py b/tests/github/test_release.py new file mode 100644 index 00000000..12ed924b --- /dev/null +++ b/tests/github/test_release.py @@ -0,0 +1,84 @@ +import pytest +import requests +import requests_mock +from github import GithubException + +from gardenlinux.github.release import Release, write_to_release_id_file + +from ..constants import ( + TEST_GARDENLINUX_COMMIT, + TEST_GARDENLINUX_RELEASE, +) +from .constants import REPO_JSON + + +def test_Release_create_needs_github_token(): + with ( + requests_mock.Mocker(), + pytest.raises(ValueError, match="GITHUB_TOKEN environment variable not set"), + ): + _ = Release("gardenlinux", "gardenlinux") + + +def test_Release_raise_on_failure(caplog, github_token): + with requests_mock.Mocker() as m: + release = Release("gardenlinux", "gardenlinux", token="test") + + release.tag = TEST_GARDENLINUX_RELEASE + release.commitish = TEST_GARDENLINUX_COMMIT + release.is_latest = (False,) + release.body = "" + + with pytest.raises(GithubException): + m.get( + "//api.github.com:443/repos/gardenlinux/gardenlinux", + json=REPO_JSON, + status_code=200, + ) + + m.post( + "//api.github.com:443/repos/gardenlinux/gardenlinux/releases", + json={}, + status_code=503, + ) + + release.create() + + +def test_Release(caplog, github_token): + with requests_mock.Mocker() as m: + release = Release("gardenlinux", "gardenlinux", token="test") + + release.tag = TEST_GARDENLINUX_RELEASE + release.commitish = TEST_GARDENLINUX_COMMIT + release.is_latest = (False,) + release.body = "" + + m.get( + "//api.github.com:443/repos/gardenlinux/gardenlinux", + json=REPO_JSON, + status_code=200, + ) + + m.post( + "//api.github.com:443/repos/gardenlinux/gardenlinux/releases", + json={"id": 101}, + status_code=201, + ) + + assert release.create() == 101 + + +def test_write_to_release_id_file(release_id_file): + write_to_release_id_file(TEST_GARDENLINUX_RELEASE) + assert release_id_file.read_text() == TEST_GARDENLINUX_RELEASE + + +def test_write_to_release_id_file_broken_file_permissions(release_id_file, caplog): + release_id_file.touch(0) # this will make the file unwritable + + with pytest.raises(SystemExit): + write_to_release_id_file(TEST_GARDENLINUX_RELEASE) + assert any("Could not create" in record.message for record in caplog.records), ( + "Expected a failure log record" + )