diff --git a/.github/workflows/changelog_prepare.yml b/.github/workflows/changelog_prepare.yml new file mode 100644 index 0000000..7f5ce91 --- /dev/null +++ b/.github/workflows/changelog_prepare.yml @@ -0,0 +1,51 @@ +name: Changelog prepare + +# Manual step of the release flow: translates changelogs missing a .ru.yaml +# counterpart (calls the GPT API) and regenerates release notes. +# Run it on the release-next branch after reviewing the english changelog. + +on: + workflow_dispatch: + +jobs: + changelog-prepare: + name: Translate changelog and update release notes + runs-on: [self-hosted, large] + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.RELEASE_TOKEN || github.token }} + + - name: Translate changelogs missing a .ru.yaml counterpart + env: + GPT_API_TOKEN: ${{ secrets.GPT_API_TOKEN }} + GPT_API_URL: ${{ secrets.GPT_API_URL }} + run: | + for f in CHANGELOG/v*.yaml; do + case "$f" in *.ru.yaml) continue;; esac + ru="${f%.yaml}.ru.yaml" + if [ ! -f "$ru" ]; then + echo "translating $f -> $ru" + hack/chlog.py -o "$ru" translate -f "$f" -l ru + fi + done + + - name: Generate release notes + run: | + hack/chlog.py -o docs/RELEASE_NOTES.md release-notes -d CHANGELOG/ -l en + hack/chlog.py -o docs/RELEASE_NOTES.ru.md release-notes -d CHANGELOG/ -l ru + + - name: Commit and push changes + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "nothing to commit, translations and release notes are up to date" + exit 0 + fi + git config user.name "ci-changelog" + git config user.email "ci-changelog@users.noreply.github.com" + git add CHANGELOG/ docs/ + git commit -m "internal(changelog): Add translations and update release notes" + git push origin "HEAD:${GITHUB_REF_NAME}" diff --git a/.github/workflows/release_pr.yml b/.github/workflows/release_pr.yml new file mode 100644 index 0000000..ab53d63 --- /dev/null +++ b/.github/workflows/release_pr.yml @@ -0,0 +1,78 @@ +name: Release PR + +# Release-please-like flow: on every push to main, (re)create the release +# branch with the generated changelog and release notes, and open/update +# the release PR. Translation is a manual step ("Changelog prepare" workflow +# dispatched on the release branch) to keep API calls controlled. + +on: + push: + branches: + - main + +env: + RELEASE_BRANCH: release-next + +jobs: + release-pr: + name: Create or update release PR + runs-on: operator-helm-e2e + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + # git-cliff needs full history and tags + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN || github.token }} + + - name: Generate changelog for the next version + id: changelog + run: | + VERSION="$(git cliff --unreleased --bumped-version)" + echo "next version: ${VERSION}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + # changelog already merged to main -> release awaits tagging + if [ -f "CHANGELOG/${VERSION}.yaml" ]; then + echo "CHANGELOG/${VERSION}.yaml already in main, release is awaiting tag" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + + git cliff --unreleased -o "CHANGELOG/${VERSION}.yaml" + + # keep the translation already made in the release branch (manual step result) + if git fetch origin "${RELEASE_BRANCH}" 2>/dev/null; then + if git show "origin/${RELEASE_BRANCH}:CHANGELOG/${VERSION}.ru.yaml" > /tmp/prev_ru.yaml 2>/dev/null; then + echo "keeping existing translation from ${RELEASE_BRANCH}" + cp /tmp/prev_ru.yaml "CHANGELOG/${VERSION}.ru.yaml" + fi + fi + + # release-notes generation is local-only (yq), no API calls + hack/chlog.py -o docs/RELEASE_NOTES.md release-notes -d CHANGELOG/ -l en + hack/chlog.py -o docs/RELEASE_NOTES.ru.md release-notes -d CHANGELOG/ -l ru + + - name: Create or update release PR + if: steps.changelog.outputs.skip == 'false' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.RELEASE_TOKEN || github.token }} + branch: ${{ env.RELEASE_BRANCH }} + base: main + title: "Release ${{ steps.changelog.outputs.version }}" + body: | + Automated release PR. Review `CHANGELOG/${{ steps.changelog.outputs.version }}.yaml` and its translation before merging. + + To generate the translation, run the "Changelog prepare" workflow on the `release-next` branch. + commit-message: "internal(changelog): Release ${{ steps.changelog.outputs.version }}" + committer: "ci-changelog " + author: "ci-changelog " + add-paths: | + CHANGELOG/ + docs/ + delete-branch: true diff --git a/CHANGELOG/v0.0.1.ru.yaml b/CHANGELOG/v0.0.1.ru.yaml new file mode 100644 index 0000000..f9ff725 --- /dev/null +++ b/CHANGELOG/v0.0.1.ru.yaml @@ -0,0 +1,5 @@ +features: + - выпущена первоначальная версия с базовыми возможностями +fixes: [] +security: [] +chore: [] diff --git a/CHANGELOG/v0.0.2.ru.yaml b/CHANGELOG/v0.0.2.ru.yaml new file mode 100644 index 0000000..7af417e --- /dev/null +++ b/CHANGELOG/v0.0.2.ru.yaml @@ -0,0 +1,5 @@ +features: + - применены рекомендации по результатам ревью deckhouse runtime +fixes: [] +security: [] +chore: [] diff --git a/CHANGELOG/v0.0.3.ru.yaml b/CHANGELOG/v0.0.3.ru.yaml new file mode 100644 index 0000000..bfed4ed --- /dev/null +++ b/CHANGELOG/v0.0.3.ru.yaml @@ -0,0 +1,5 @@ +features: + - выпущен первый публичный альфа-релиз с поддержкой CRD HelmClusterAddon, HelmClusterAddonChart и HelmClusterAddonRepository +fixes: [] +security: [] +chore: [] diff --git a/CHANGELOG/v0.0.4.ru.yaml b/CHANGELOG/v0.0.4.ru.yaml new file mode 100644 index 0000000..73db9f1 --- /dev/null +++ b/CHANGELOG/v0.0.4.ru.yaml @@ -0,0 +1,5 @@ +features: [] +fixes: [] +security: [] +chore: + - обновлено форматирование уведомлений на главной странице документации diff --git a/CHANGELOG/v0.0.5.ru.yaml b/CHANGELOG/v0.0.5.ru.yaml new file mode 100644 index 0000000..9ef6a50 --- /dev/null +++ b/CHANGELOG/v0.0.5.ru.yaml @@ -0,0 +1,5 @@ +features: [] +fixes: [] +security: [] +chore: + - внесены незначительные обновления документации diff --git a/CHANGELOG/v0.0.6.ru.yaml b/CHANGELOG/v0.0.6.ru.yaml new file mode 100644 index 0000000..45ae5cd --- /dev/null +++ b/CHANGELOG/v0.0.6.ru.yaml @@ -0,0 +1,6 @@ +features: + - возможные условия статуса больше не отмечаются как инициализированные при согласовании +fixes: [] +security: [] +chore: + - добавлены аннотации веса в веб-хук валидации diff --git a/CHANGELOG/v0.0.7.ru.yaml b/CHANGELOG/v0.0.7.ru.yaml new file mode 100644 index 0000000..ededd02 --- /dev/null +++ b/CHANGELOG/v0.0.7.ru.yaml @@ -0,0 +1,5 @@ +features: + - добавлена возможность просматривать значения чарта по умолчанию в консоли при создании дополнения +fixes: [] +security: [] +chore: [] diff --git a/CHANGELOG/v0.0.8.ru.yaml b/CHANGELOG/v0.0.8.ru.yaml new file mode 100644 index 0000000..3b684d1 --- /dev/null +++ b/CHANGELOG/v0.0.8.ru.yaml @@ -0,0 +1,6 @@ +features: [] +fixes: + - устранена гонка при отключении модуля, которая могла привести к сбою приложения +security: [] +chore: + - теневые пользовательские ресурсы теперь отслеживаются только в пространстве имен модуля diff --git a/CHANGELOG/v0.0.8.yaml b/CHANGELOG/v0.0.8.yaml index fc27078..d0d118f 100644 --- a/CHANGELOG/v0.0.8.yaml +++ b/CHANGELOG/v0.0.8.yaml @@ -1,6 +1,6 @@ features: [] fixes: - - resolve race on module disable which could lead to application distruption + - resolve race on module disable which could lead to application disruption security: [] chore: - watch shadow custom resources in module namespace only diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..8efc9d3 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,74 @@ +# git-cliff configuration: generates CHANGELOG/vX.Y.Z.yaml for the unreleased version. +# Usage: +# version="$(git cliff --unreleased --bumped-version)" +# git cliff --unreleased -o "CHANGELOG/${version}.yaml" + +[changelog] +header = "" +footer = "" +trim = false +# Emit the module changelog yaml: all four categories are always present, +# empty ones are rendered as []. +body = """ +{%- set feats = commits | filter(attribute="group", value="features") -%} +{%- set fixes = commits | filter(attribute="group", value="fixes") -%} +{%- set security = commits | filter(attribute="group", value="security") -%} +{%- set chores = commits | filter(attribute="group", value="chore") -%} +features: +{%- if feats | length == 0 %} [] +{%- else %} +{%- for c in feats %} + - "{{ c.message | split(pat="\n") | first | trim | replace(from="\\", to="\\\\") | replace(from='"', to='\\"') }}" +{%- endfor %} +{%- endif %} +fixes: +{%- if fixes | length == 0 %} [] +{%- else %} +{%- for c in fixes %} + - "{{ c.message | split(pat="\n") | first | trim | replace(from="\\", to="\\\\") | replace(from='"', to='\\"') }}" +{%- endfor %} +{%- endif %} +security: +{%- if security | length == 0 %} [] +{%- else %} +{%- for c in security %} + - "{{ c.message | split(pat="\n") | first | trim | replace(from="\\", to="\\\\") | replace(from='"', to='\\"') }}" +{%- endfor %} +{%- endif %} +chore: +{%- if chores | length == 0 %} [] +{%- else %} +{%- for c in chores %} + - "{{ c.message | split(pat="\n") | first | trim | replace(from="\\", to="\\\\") | replace(from='"', to='\\"') }}" +{%- endfor %} +{%- endif %} +""" + +[git] +conventional_commits = true +# Keep unconventional commits too: they fall through to the "chore" bucket +# instead of silently disappearing from the changelog. +filter_unconventional = false +split_commits = false +protect_breaking_commits = true +# Release tags look like v0.4.17 +tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+$" + +commit_parsers = [ + # noise: merge commits and generated-changelog commits + { message = "^Merge", skip = true }, + { message = "^internal\\(changelog\\)", skip = true }, + # security first: explicit type or CVE/vex mentions + { message = "^security", group = "security" }, + { message = "(?i)\\bcve-\\d{4}-\\d+", group = "security" }, + { message = "(?i)\\b(cve|vex)s?\\b", group = "security" }, + # standard buckets + { message = "^feat", group = "features" }, + { message = "^(fix|bug)", group = "fixes" }, + # everything else (chore, ci, docs, refactor, unconventional...) is chore + { message = ".*", group = "chore" }, +] + +[bump] +features_always_bump_minor = true +breaking_always_bump_major = true diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md new file mode 100644 index 0000000..a205f65 --- /dev/null +++ b/docs/RELEASE_NOTES.md @@ -0,0 +1,61 @@ +--- +title: "Release Notes" +description: "Release notes for Deckhouse operator-helm." +--- + +## v0.0.8 + +### Bug Fixes + +* resolve race on module disable which could lead to application disruption + +### Chore + +* watch shadow custom resources in module namespace only + +## v0.0.7 + +### New Features + +* add ability to review chart default values in console during addon creation + +## v0.0.6 + +### New Features + +* do not mark possible status conditions as intitialized on reconcile + +### Chore + +* add weight annotations to validation webhook + +## v0.0.5 + +### Chore + +* minor documentation updates + +## v0.0.4 + +### Chore + +* update main documentation page alerts formatting + +## v0.0.3 + +### New Features + +* the first public alpha release with HelmClusterAddon, HelmClusterAddonChart, and HelmClusterAddonRepository CRDs supoort + +## v0.0.2 + +### New Features + +* apply deckhouse runtime time review recommendations + +## v0.0.1 + +### New Features + +* initial release with basic capabilities + diff --git a/docs/RELEASE_NOTES.ru.md b/docs/RELEASE_NOTES.ru.md new file mode 100644 index 0000000..49f27a6 --- /dev/null +++ b/docs/RELEASE_NOTES.ru.md @@ -0,0 +1,61 @@ +--- +title: "Релизы" +description: "Релизы Deckhouse operator-helm." +--- + +## v0.0.8 + +### Исправления + +* устранена гонка при отключении модуля, которая могла привести к сбою приложения + +### Прочее + +* теневые пользовательские ресурсы теперь отслеживаются только в пространстве имен модуля + +## v0.0.7 + +### Новые возможности + +* добавлена возможность просматривать значения чарта по умолчанию в консоли при создании дополнения + +## v0.0.6 + +### Новые возможности + +* возможные условия статуса больше не отмечаются как инициализированные при согласовании + +### Прочее + +* добавлены аннотации веса в веб-хук валидации + +## v0.0.5 + +### Прочее + +* внесены незначительные обновления документации + +## v0.0.4 + +### Прочее + +* обновлено форматирование уведомлений на главной странице документации + +## v0.0.3 + +### Новые возможности + +* выпущен первый публичный альфа-релиз с поддержкой CRD HelmClusterAddon, HelmClusterAddonChart и HelmClusterAddonRepository + +## v0.0.2 + +### Новые возможности + +* применены рекомендации по результатам ревью deckhouse runtime + +## v0.0.1 + +### Новые возможности + +* выпущена первоначальная версия с базовыми возможностями + diff --git a/hack/chlog.py b/hack/chlog.py new file mode 100755 index 0000000..fd22de7 --- /dev/null +++ b/hack/chlog.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 + +# Copyright 2023 Flant JSC +# Licensed under the Deckhouse Platform Enterprise Edition (EE) license. See https://github.com/deckhouse/deckhouse/blob/main/ee/LICENSE + +import argparse +import fnmatch +import functools +import glob +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.request + +FORMAT = "{ #'#commit#'#: #'#%H#'#, #'#abbreviated_commit#'#: #'#%h#'#, #'#tree#'#: #'#%T#'#, #'#abbreviated_tree#'#: #'#%t#'#, #'#parent#'#: #'#%P#'#, #'#abbreviated_parent#'#: #'#%p#'#, #'#refs#'#: #'#%D#'#, #'#encoding#'#: #'#%e#'#, #'#subject#'#: #'#%s#'#, #'#sanitized_subject_line#'#: #'#%f#'#, #'#body#'#: #'#%b#'#, #'#commit_notes#'#: #'#%N#'#, #'#verification_flag#'#: #'#%G?#'#, #'#signer#'#: #'#%GS#'#, #'#signer_key#'#: #'#%GK#'#, #'#author#'#: { #'#name#'#: #'#%aN#'#, #'#email#'#: #'#%aE#'#, #'#date#'#: #'#%aD#'# }, #'#commiter#'#: { #'#name#'#: #'#%cN#'#, #'#email#'#: #'#%cE#'#, #'#date#'#: #'#%cD#'# }}," + +TRANSLATIONS = { + "title": {"en": "Release Notes", "ru": "Релизы"}, + "description": { + "en": "Release notes for Deckhouse operator-helm.", + "ru": "Релизы Deckhouse operator-helm.", + }, + "features": {"en": "New Features", "ru": "Новые возможности"}, + "fixes": {"en": "Bug Fixes", "ru": "Исправления"}, + "security": {"en": "Security Fixes", "ru": "Исправления безопасности"}, + "chore": {"en": "Chore", "ru": "Прочее"}, +} + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Changelog and Release Notes generator.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--config", + "-c", + default=os.path.abspath(os.path.join(os.path.dirname(__file__), "chlog.json")), + type=str, + help="Config file.", + ) + parser.add_argument( + "--verbose", "-v", action="store_true", help="Verbose log output." + ) + parser.add_argument("--output", "-o", type=str, help="Output file.") + + subparsers = parser.add_subparsers( + title="subcommands", dest="subcommand", required=True + ) + + parser_changelog = subparsers.add_parser( + "changelog", + help="Generate changelog yaml from git commits.", + description="Generate changelog yaml from git commits.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser_changelog.add_argument( + "--source", "-s", type=str, required=True, help="Git source revision." + ) + parser_changelog.add_argument( + "--target", "-t", default="HEAD", type=str, help="Git target revision." + ) + + parser_release_notes = subparsers.add_parser( + "release-notes", + help="Generate release notes markdown from changelogs.", + description="Generate release notes markdown from changelogs.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser_release_notes.add_argument( + "--directory", + "-d", + default=os.path.abspath( + os.path.join(os.path.dirname(__file__), "../CHANGELOG/") + ), + type=str, + help="Changelog directory with yaml files.", + ) + parser_release_notes.add_argument( + "--lang", "-l", type=str, required=True, help="Target language." + ) + + parser_release_notes = subparsers.add_parser( + "translate", + help="Translate file to specified language using Yandex Translate API.", + description="Translate file to specified language using Yandex Translate API.", + epilog="IAM token is required.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser_release_notes.add_argument( + "--file", "-f", type=str, required=True, help="File to translate." + ) + parser_release_notes.add_argument( + "--lang", "-l", default="en", type=str, help="Target language." + ) + + parsed_args = parser.parse_args() + + logging.basicConfig( + format="%(levelname)s: %(message)s", + level=logging.DEBUG if parsed_args.verbose else logging.INFO, + stream=sys.stderr, + ) + + if parsed_args.subcommand == "changelog": + return changelog_handler( + parsed_args.config, + parsed_args.source, + parsed_args.target, + parsed_args.output, + ) + + if parsed_args.subcommand == "release-notes": + return release_notes_handler( + parsed_args.directory, + parsed_args.lang, + parsed_args.output, + ) + + if parsed_args.subcommand == "translate": + return openwebui_translate_handler( + parsed_args.file, parsed_args.lang, parsed_args.output + ) + + return 0 + + +def ensure_yq(func): + """Decorator used to check if yq utility is installed""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + if not shutil.which("yq"): + logging.error( + "yq is required. Please install https://github.com/mikefarah/yq/#install" + ) + sys.exit(1) + + func(*args, **kwargs) + + return wrapper + + +def ensure_yc(func): + """Decorator used to check if Yandex Cloud CLI is installed""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + if not shutil.which("yc"): + logging.error( + "yc is required. Please install https://yandex.cloud/en/docs/cli/operations/install-cli" + ) + sys.exit(1) + func(*args, **kwargs) + + return wrapper + + +@ensure_yq +def changelog_handler( + config_file: str, source: str, target: str, output: str = "" +) -> int: + commits = parse_log(source, target) + mapped = {"features": [], "fixes": [], "security": [], "chore": []} + + # read configuration here because we need to modify the config dict + with open(config_file, "r", encoding="utf-8") as f: + config = json.load(f) + + # init components + for c in config["components"]: + c["_patterns"] = [re.compile(fnmatch.translate(p)) for p in c["paths"]] + + skip_next = False + for i, _ in enumerate(commits): + if skip_next: + skip_next = False + continue + + master_entry = commits[i] + + if len(master_entry["parents"]) == 1 or len(commits) < i + 2: + message = build_message(master_entry["subject"], master_entry["body"]) + change_type = categorize(master_entry["body"]) + components = module_components(config["components"], master_entry["commit"]) + entries = [master_entry] + else: + skip_next = True + + dev_entry = commits[i + 1] + message = build_message( + dev_entry["subject"], + master_entry["subject"], + dev_entry["body"], + master_entry["body"], + ) + change_type = categorize(message) + components = module_components(config["components"], dev_entry["commit"]) + entries = [dev_entry, master_entry] + + mapped[change_type].append( + fulfill_message( + { + "message": message, + "changeType": change_type, + "components": components, + "entries": entries, + } + ) + ) + + for changes in mapped.values(): + changes.sort() + + try: + changelog_yaml = ( + subprocess.check_output( + ["yq", "-P", "-p", "json", "-o", "yaml", "-"], + input=json.dumps(mapped, ensure_ascii=False).encode(), + ) + .decode() + .strip() + ) + except subprocess.CalledProcessError as e: + logging.error(e.stderr.decode().strip()) + return 1 + + if output: + with open(output, "w", encoding="utf-8") as f: + print(changelog_yaml, file=f) + else: + print(changelog_yaml) + + return 0 + + +@ensure_yq +def release_notes_handler(changelog_dir: str, lang: str, output: str = "") -> int: + if lang not in ["en", "ru"]: + logging.error('language must be either "en" or "ru".') + return 1 + + changelog_files = sorted( + glob.glob(pathname=os.path.join(changelog_dir, "*.yaml")), key=lambda f: [int(n) for n in re.findall(r'\d+', f)], reverse=True + ) + + if len(changelog_files) == 0: + logging.error("no changelog files found.") + return 1 + + changelogs = {} + for filename in changelog_files: + logging.debug("processing file: %s", filename) + + if lang == "ru" and not filename.endswith(".ru.yaml"): + continue + + if lang == "en" and filename.endswith(".ru.yaml"): + continue + + try: + changelog_yaml = ( + subprocess.check_output( + ["yq", "-o", "json", filename], stderr=subprocess.PIPE + ) + .decode() + .strip() + ) + except subprocess.CalledProcessError as e: + logging.error(e.stderr.decode().strip()) + return 1 + + changelogs[filename] = json.loads(changelog_yaml) + + headers = { + "title": TRANSLATIONS["title"][lang], + "features": TRANSLATIONS["features"][lang], + "fixes": TRANSLATIONS["fixes"][lang], + "security": TRANSLATIONS["security"][lang], + "chore": TRANSLATIONS["chore"][lang], + } + + markdown = f'---\ntitle: "{headers["title"]}"\ndescription: "{TRANSLATIONS["description"][lang]}"\n---\n' + + for filename, changelog in changelogs.items(): + entries = { + "version": os.path.basename(filename).replace( + ".ru.yaml" if lang == "ru" else ".yaml", "" + ), + "features": "", + "fixes": "", + "security": "", + "chore": "", + } + + for category, changes in changelog.items(): + logging.debug("processing category: %s", category) + if changes: + entries[category] = yaml_to_markdown(changes) + + markdown += f"\n## {entries['version']}\n" + for category in ["features", "fixes", "security", "chore"]: + if entries[category]: + markdown += f"\n### {headers[category]}\n{entries[category]}" + + if output: + with open(output, "w", encoding="utf-8") as f: + print(markdown, file=f) + else: + print(markdown) + + return 0 + + +def openwebui_translate_handler(file: str, lang: str, output: str = "") -> int: + api_token = os.environ.get("GPT_API_TOKEN", None) + if not api_token: + logging.error("GPT_API_TOKEN is required.") + return 1 + + api_url = os.environ.get("GPT_API_URL", None) + if not api_url: + logging.error("GPT_API_URL is required.") + return 1 + + if lang not in ["en", "ru"]: + logging.error('language must be either "en" or "ru".') + return 1 + + logging.debug("translating file: %s", file) + + with open(file, "r", encoding="utf-8") as f: + content = f.read() + + target = "Russian" if lang == "ru" else "English" + + text = f"Translate the following YAML to {target}. Preserve YAML formatting.\n\n```{content}```" + + data = { + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": text, + } + ], + } + + req = urllib.request.Request( + f"{api_url.rstrip('/')}/api/chat/completions", + headers={ + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + logging.info("performing request to %s", req.get_full_url()) + with urllib.request.urlopen(req, data=json.dumps(data).encode()) as resp: + result = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + logging.error("%d %s: %s", e.code, e.reason, e.read().decode().strip()) + return 1 + + if len(result["choices"]) == 0: + logging.error("something went wrong. no translations found in response") + return 1 + + text = re.sub( + pattern=r"```(yaml)?(.*)```", + repl=r"\2", + string=result["choices"][0]["message"]["content"], + flags=re.S, + ).strip() + if output: + with open(output, "w", encoding="utf-8") as f: + print(text, file=f) + else: + print(text) + + return 0 + + +@ensure_yc +def yandex_translate_handler(file: str, lang: str, output: str = "") -> int: + iam_token = os.environ.get("YC_TOKEN", None) + if not iam_token: + logging.error( + 'YC_TOKEN is required. Run command: export YC_TOKEN="$(yc iam create-token)"' + ) + return 1 + + folder_id = os.environ.get("YC_FOLDER_ID", None) + if not folder_id: + logging.error( + 'YC_FOLDER_ID is required. Run command: export YC_FOLDER_ID="$(yc config get folder-id)"' + ) + return 1 + + if lang not in ["en", "ru"]: + logging.error('language must be either "en" or "ru".') + return 1 + + logging.debug("translating file: %s", file) + + with open(file, "r", encoding="utf-8") as f: + content = f.read() + + data = { + "folderId": folder_id, + "targetLanguageCode": lang, + "texts": [content], + } + + req = urllib.request.Request( + "https://translate.api.cloud.yandex.net/translate/v2/translate", + headers={ + "Authorization": f"Bearer {iam_token}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, data=json.dumps(data).encode()) as resp: + result = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + logging.error("%d %s: %s", e.code, e.reason, e.read().decode().strip()) + return 1 + + if len(result["translations"]) == 0: + logging.error("something went wrong. no translations found in response") + return 1 + + text = result["translations"][0]["text"] + if output: + with open(output, "w", encoding="utf-8") as f: + print(text, file=f) + else: + print(text) + + return 0 + + +def categorize(body: str) -> str: + if re.search(r"[Ff]eat(?:ure)?", body): + return "features" + if re.search(r"([Ff]ix)|([Bb]ug)", body): + return "fixes" + + return "chore" + + +def module_components(config: list[dict], commit: dict) -> list[str]: + try: + stdout = ( + subprocess.check_output( + ["git", "show", "--name-only", "--oneline", "--pretty=format:", commit], + stderr=subprocess.PIPE, + ) + .decode() + .strip() + ) + except subprocess.CalledProcessError as e: + logging.error(e.stderr.decode().strip()) + sys.exit(1) + + # use dict to preserve order and remove duplicates + components = {} + + # init components order from config + for c in config: + components[c["name"]] = False + + for line in stdout.splitlines(): + if not line: + components[""] = True + continue + + for c in config: + for r in c["_patterns"]: + if r.match(line): + components[c["name"]] = True + break + + # if len(components) > 1: + # components.pop("ci", None) + # if len(components) > 1: + # components.pop("module", None) + # if len(components) > 1: + # components.pop("docs", None) + + return [k for k, v in components.items() if v] + + +def clean_message(message: str) -> list[str]: + repo = os.environ.get("GITHUB_REPOSITORY", "") + result = [] + for s in message.splitlines(): + s = s.replace("into 'master'", "") + s = s.replace("into 'main'", "") + s = s.replace("Merge branch", "") + s = s.replace("Merge pull request", "") + if repo: + s = re.sub(r"#(\d+)", rf"https://github.com/{repo}/pull/\1", s) + s = re.sub(r"\s+", " ", s) + s = re.sub(r"\n", "; ", s) + s = s.strip() + result.append(s) + + return result + + +def build_message(*strings: str) -> str: + return " ".join( + dict.fromkeys(message for s in strings for message in clean_message(s)) + ) + + +def fulfill_message(change: dict) -> str: + author = change["entries"][0]["author"]["email"] + components = "".join([f"[{c}]" for c in change["components"]]) + commit = change["entries"][0]["abbreviated_commit"] + + return f"{components} <{author}> {change['message']} ({commit})" + + +def parse_log(source: str, target: str) -> dict: + try: + output = ( + subprocess.check_output( + ["git", "log", f"{source}..{target}", f"--pretty=format:{FORMAT}"], + stderr=subprocess.PIPE, + ) + .decode() + .strip() + ) + except subprocess.CalledProcessError as e: + logging.error(e.stderr.decode().strip()) + sys.exit(1) + + output = re.sub(r"\"", '\\"', output) + output = re.sub(r"#'#", '"', output) + output = re.sub(r"([^,])\n", r"\1\\n", output) + output = re.sub(r"([^,])\n", r"\1\\n", output) + output = re.sub(r"([^,])\n", r"\1\\n", output) + output = re.sub(r"([^,])\n", r"\1\\n", output) + + commits = json.loads(f"[{output[0:-1]}]") + + for commit in commits: + commit["parents"] = [c.strip() for c in commit["parent"].split()] + commit["abbreviated_parents"] = [ + c.strip() for c in commit["abbreviated_parent"].split() + ] + + return commits + + +def yaml_to_markdown(yaml_data): + def parse_dict(d, level=4): + md = "" + if isinstance(d, dict): + for k, v in d.items(): + logging.debug("processing dict: %s", k) + md += f"\n{'#' * (level)} {k}\n" + md += parse_dict(v, level + 1) + elif isinstance(d, list): + # logging.debug("processing list: %s", d) + md += "\n" + for item in d: + if not isinstance(item, str): + logging.error( + "invalid value type, expected string: %s: %s", type(item), item + ) + sys.exit(1) + md += f"* {item}\n" + return md + + return parse_dict(yaml_data) + + +if __name__ == "__main__": + sys.exit(main())