diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fc3abcbeb5100..dceca6f8ae1eb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,17 +22,20 @@ ## Changelog :cl: -add: Что-то добавил -del: Что-то удалил -tweak: Поменял что-то по мелочи +add: Изменил геймплей или добавил новую механику fix: Что-то починил -wip: Какие-либо наработки в процессе -soundadd: Добавил новый звук -sounddel: Удалил старый звук -imageadd: Добавил новую картинку -imagedel: Удалил старую картинку -spellcheck: Исправил опечатку -experiment: Добавил экспериментальную функцию +del: Что-то удалил +qol: Сделал что-то удобнее +sound: Добавил, изменил или удалил звук +image: Добавил, изменил или удалил картинку +map: Добавил, изменил или удалил что-то на карте +typo: Исправил опечатку +code_imp: Незначительно улучшил качество кода +refactor: Значительно улучшил качество кода +balance: Сделал правки в балансе +config: Изменил что-то в конфиге +admin: Поменял кнопки админам +server: Изменил что-то серверное, о чем должен знать хост /:cl: diff --git a/.github/workflows/auto_changelog.yml b/.github/workflows/auto_changelog.yml index 45303ec0c92af..c1fcf82e76890 100644 --- a/.github/workflows/auto_changelog.yml +++ b/.github/workflows/auto_changelog.yml @@ -11,7 +11,7 @@ permissions: jobs: auto_changelog: runs-on: ubuntu-latest - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true && github.head_ref != 'merge-upstream' steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/check_changelog.yml b/.github/workflows/check_changelog.yml new file mode 100644 index 0000000000000..91398c7d47aca --- /dev/null +++ b/.github/workflows/check_changelog.yml @@ -0,0 +1,45 @@ +name: Changelog validation + +permissions: + contents: read + pull-requests: write + issues: write + +on: + pull_request_target: + types: [opened, reopened, edited, labeled, unlabeled, ready_for_review] + +jobs: + CheckCL: + runs-on: ubuntu-latest + if: github.base_ref == 'master' && github.event.pull_request.draft == false + + steps: + - id: create_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + + - run: echo "GH_TOKEN=${{ steps.create_token.outputs.token }}" >> "$GITHUB_ENV" + + - name: Downloading scripts + run: | + wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.base_ref }}/tools/changelog/changelog_utils.py + wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.base_ref }}/tools/changelog/check_changelog.py + wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.base_ref }}/tools/changelog/tags.yml + + - name: Installing Python + uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 + with: + python-version: '3.x' + + - name: Installing deps + run: | + python -m pip install --upgrade pip + pip install ruamel.yaml PyGithub + + - name: Changelog validation + env: + GITHUB_TOKEN: ${{ env.GH_TOKEN }} + run: python check_changelog.py diff --git a/.github/workflows/merge_upstream.yml b/.github/workflows/merge_upstream.yml new file mode 100644 index 0000000000000..e67af1585554c --- /dev/null +++ b/.github/workflows/merge_upstream.yml @@ -0,0 +1,50 @@ +name: Merge Upstream + +on: + workflow_dispatch: + +jobs: + merge-upstream: + runs-on: ubuntu-latest + + steps: + - id: create_token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + + - run: echo "GH_TOKEN=${{ steps.create_token.outputs.token }}" >> "$GITHUB_ENV" + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.x + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install PyGithub openai + + - name: Download the script + run: | + wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/tools/changelog/changelog_utils.py + wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/tools/merge-upstream/merge_upstream.py + wget https://raw.githubusercontent.com/${{ github.repository }}/${{ github.ref_name }}/tools/merge-upstream/translation_context.txt + + - name: Run the script + env: + GITHUB_TOKEN: ${{ env.GH_TOKEN }} + TARGET_REPO: 'ss220club/BandaStation' + TARGET_BRANCH: 'master' + UPSTREAM_REPO: 'tgstation/tgstation' + UPSTREAM_BRANCH: 'master' + MERGE_BRANCH: 'merge-upstream' + CHANGELOG_AUTHOR: 'tgstation' + TRANSLATE_CHANGES: 'true' + OPENAI_API_KEY: ${{ secrets.ORG_EMPTY_TOKEN }} + LOG_LEVEL: ${{ runner.debug && 'DEBUG' || 'INFO' }} + run: | + git config --global user.email "action@github.com" + git config --global user.name "Upstream Sync" + python3 -u merge_upstream.py diff --git a/tools/changelog/changelog_utils.py b/tools/changelog/changelog_utils.py new file mode 100644 index 0000000000000..9b073f8f17ff9 --- /dev/null +++ b/tools/changelog/changelog_utils.py @@ -0,0 +1,99 @@ +import re +import copy + +CL_INVALID = ":scroll: CL невалиден" +CL_VALID = ":scroll: CL валиден" +CL_NOT_NEEDED = ":scroll: CL не требуется" + +CL_BODY = re.compile(r"(:cl:|🆑)[ \t]*(?P.+?)?\s*\n(?P(.|\n)*?)\n/(:cl:|🆑)", re.MULTILINE) +CL_SPLIT = re.compile(r"\s*(?:(?P\w+)\s*:)?\s*(?P.*)") + +DISCORD_TAG_EMOJI = { + "rscadd": ":sparkles:", + "bugfix": ":adhesive_bandage:", + "rscdel": ":wastebasket:", + "qol": ":herb:", + "sound": ":notes:", + "image": ":frame_photo:", + "map": ":map:", + "spellcheck": ":pencil:", + "balance": ":scales:", + "code_imp": ":hammer:", + "refactor": ":tools:", + "config": ":gear:", + "admin": ":magic_wand:", + "server": ":shield:" +} + + +def build_changelog(pr: dict, tags_config: dict) -> dict: + changelog = parse_changelog(pr.body, tags_config) + if changelog is None: + raise Exception("Failed to parse the changelog. Check changelog format.") + changelog["author"] = changelog["author"] or pr.user.login + return changelog + + +def emojify_changelog(changelog: dict): + changelog_copy = copy.deepcopy(changelog) + for change in changelog_copy["changes"]: + if change["tag"] in DISCORD_TAG_EMOJI: + change["tag"] = DISCORD_TAG_EMOJI[change["tag"]] + else: + raise Exception(f"Invalid tag for emoji: {change}") + return changelog_copy + + +def validate_changelog(changelog: dict): + if not changelog: + raise Exception("No changelog.") + if not changelog["author"]: + raise Exception("The changelog has no author.") + if len(changelog["changes"]) == 0: + raise Exception("No changes found in the changelog. Use special label if changelog is not expected.") + + +def parse_changelog(pr_body: str, tags_config: dict | None = None) -> dict | None: + clean_pr_body = re.sub(r"", "", pr_body, flags=re.DOTALL) + cl_parse_result = CL_BODY.search(clean_pr_body) + if cl_parse_result is None: + return None + + cl_changes = [] + for cl_line in cl_parse_result.group("content").splitlines(): + if not cl_line: + continue + change_parse_result = CL_SPLIT.search(cl_line) + if not change_parse_result: + raise Exception(f"Invalid change: '{cl_line}'") + tag = change_parse_result["tag"] + message = change_parse_result["message"] + + if tags_config and tag and tag not in tags_config['tags'].keys(): + raise Exception(f"Invalid tag: '{cl_line}'. Valid tags: {', '.join(tags_config['tags'].keys())}") + if not message: + raise Exception(f"No message for change: '{cl_line}'") + + message = message.strip() + + if tags_config and message in list(tags_config['defaults'].values()): # Check to see if the tags are associated with something that isn't the default text + raise Exception(f"Don't use default message for change: '{cl_line}'") + if tag: + cl_changes.append({ + "tag": tags_config['tags'][tag] if tags_config else tag, + "message": message + }) + # Append line without tag to the previous change + else: + if len(cl_changes): + prev_change = cl_changes[-1] + prev_change["message"] += f" {message}" + else: + raise Exception(f"Change with no tag: {cl_line}") + + if len(cl_changes) == 0: + raise Exception("No changes found in the changelog. Use special label if changelog is not expected.") + return { + "author": str.strip(cl_parse_result.group("author") or "") or None, # I want this to be None, not empty + "changes": cl_changes + } diff --git a/tools/changelog/check_changelog.py b/tools/changelog/check_changelog.py new file mode 100644 index 0000000000000..3c0e28c20c623 --- /dev/null +++ b/tools/changelog/check_changelog.py @@ -0,0 +1,85 @@ +""" +DO NOT MANUALLY RUN THIS SCRIPT. +--------------------------------- + +Expected environmental variables: +----------------------------------- +GITHUB_REPOSITORY: Github action variable representing the active repo (Action provided) +GITHUB_TOKEN: A repository account token, this will allow the action to push the changes (Action provided) +GITHUB_EVENT_PATH: path to JSON file containing the event info (Action provided) +""" +import os +from pathlib import Path +from ruamel.yaml import YAML +from github import Github +import json + +import changelog_utils + +# Blessed is the GoOnStAtIoN birb ZeWaKa for thinking of this first +repo = os.getenv("GITHUB_REPOSITORY") +token = os.getenv("GITHUB_TOKEN") +event_path = os.getenv("GITHUB_EVENT_PATH") + +with open(event_path, 'r') as f: + event_data = json.load(f) + +git = Github(token) +repo = git.get_repo(repo) +pr = repo.get_pull(event_data['number']) + +pr_body = pr.body or "" +pr_author = pr.user.login +pr_labels = pr.labels + +pr_is_mirror = pr.title.startswith("[MIRROR]") + +has_valid_label = False +has_invalid_label = False +cl_required = True +for label in pr_labels: + print("Found label: ", label.name) + if label.name == changelog_utils.CL_NOT_NEEDED: + print("No CL needed!") + cl_required = False + if label.name == changelog_utils.CL_VALID: + has_valid_label = True + if label.name == changelog_utils.CL_INVALID: + has_invalid_label = True + +if pr_is_mirror: + cl_required = False + +if not cl_required: + # remove invalid, remove valid + if has_invalid_label: + pr.remove_from_labels(changelog_utils.CL_INVALID) + if has_valid_label: + pr.remove_from_labels(changelog_utils.CL_VALID) + exit(0) + +try: + with open(Path.cwd().joinpath("tags.yml")) as file: + yaml = YAML(typ = 'safe', pure = True) + tags_config = yaml.load(file) + cl = changelog_utils.build_changelog(pr, tags_config) + cl_emoji = changelog_utils.emojify_changelog(cl) + cl_emoji["author"] = cl_emoji["author"] or pr_author + changelog_utils.validate_changelog(cl_emoji) +except Exception as e: + print("Changelog parsing error:") + print(e) + + # add invalid, remove valid + if not has_invalid_label: + pr.add_to_labels(changelog_utils.CL_INVALID) + if has_valid_label: + pr.remove_from_labels(changelog_utils.CL_VALID) + exit(1) + +# remove invalid, add valid +if has_invalid_label: + pr.remove_from_labels(changelog_utils.CL_INVALID) +if not has_valid_label: + pr.add_to_labels(changelog_utils.CL_VALID) +print("Changelog is valid.") diff --git a/tools/changelog/tags.yml b/tools/changelog/tags.yml new file mode 100644 index 0000000000000..00e89e7f0336e --- /dev/null +++ b/tools/changelog/tags.yml @@ -0,0 +1,47 @@ +tags: + rscadd: 'rscadd' + add: 'rscadd' + adds: 'rscadd' + bugfix: 'bugfix' + fix: 'bugfix' + fixes: 'bugfix' + rscdel: 'rscdel' + del: 'rscdel' + dels: 'rscdel' + qol: 'qol' + sound: 'sound' + image: 'image' + map: 'map' + spellcheck: 'spellcheck' + typo: 'spellcheck' + balance: 'balance' + code_imp: 'code_imp' + code: 'code_imp' + refactor: 'refactor' + config: 'config' + admin: 'admin' + server: 'server' + +defaults: + rscadd: 'Изменил геймплей или добавил новую механику' + add: 'Изменил геймплей или добавил новую механику' + adds: 'Изменил геймплей или добавил новую механику' + bugfix: 'Что-то починил' + fix: 'Что-то починил' + fixes: 'Что-то починил' + rscdel: 'Что-то удалил' + del: 'Что-то удалил' + dels: 'Что-то удалил' + qol: 'Сделал что-то удобнее' + sound: 'Добавил, изменил или удалил звук' + image: 'Добавил, изменил или удалил картинку' + map: 'Добавил, изменил или удалил что-то на карте' + spellcheck: 'Исправил опечатку' + typo: 'Исправил опечатку' + code_imp: 'Незначительно улучшил качество кода' + code: 'Незначительно улучшил качество кода' + balance: 'Сделал правки в балансе' + refactor: 'Значительно улучшил качество кода' + config: 'Изменил что-то в конфиге' + admin: 'Поменял кнопки админам' + server: 'Изменил что-то серверное, о чем должен знать хост' diff --git a/tools/merge-upstream/merge_upstream.py b/tools/merge-upstream/merge_upstream.py new file mode 100644 index 0000000000000..281b42c1d4de5 --- /dev/null +++ b/tools/merge-upstream/merge_upstream.py @@ -0,0 +1,414 @@ +import enum +import logging +import os +import re +import subprocess +import time +import typing + +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, Future, as_completed +from pathlib import Path +from re import Pattern +from subprocess import CompletedProcess + +from github import Github +from github.PaginatedList import PaginatedList +from github.PullRequest import PullRequest +from github.Repository import Repository +from openai import OpenAI +from openai.types.chat import ChatCompletion + +import changelog_utils + + +class UpstreamLabel(str, enum.Enum): + CONFIG_CHANGE = "Config Update" + SQL_CHANGE = "SQL" + + +class Change(typing.TypedDict): + tag: str + message: str + translated_message: typing.NotRequired[str] + pull: PullRequest + + +class PullDetails(typing.TypedDict): + changelog: typing.Dict[int, list[Change]] + merge_order: list[int] + config_changes: list[PullRequest] + sql_changes: list[PullRequest] + + +LABEL_BLOCK_STYLE = { + UpstreamLabel.CONFIG_CHANGE: "IMPORTANT", + UpstreamLabel.SQL_CHANGE: "IMPORTANT", +} + + +def check_env(): + """Check if the required environment variables are set.""" + logging.debug("Checking environment variables...") + required_vars = [ + "GITHUB_TOKEN", + "TARGET_REPO", + "TARGET_BRANCH", + "UPSTREAM_REPO", + "UPSTREAM_BRANCH", + "MERGE_BRANCH" + ] + if TRANSLATE_CHANGES: + required_vars.append("OPENAI_API_KEY") + missing_vars = [var for var in required_vars if not os.getenv(var)] + if missing_vars: + logging.error("Missing required environment variables: %s", ", ".join(missing_vars)) + raise EnvironmentError(f"Missing required environment variables: {', '.join(missing_vars)}") + + +logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper()) + +# Environment variables +TRANSLATE_CHANGES = os.getenv("TRANSLATE_CHANGES", "False").lower() in ("true", "yes", "1") +CHANGELOG_AUTHOR = os.getenv("CHANGELOG_AUTHOR", "") + +check_env() +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +TARGET_REPO = os.getenv("TARGET_REPO") +TARGET_BRANCH = os.getenv("TARGET_BRANCH") +UPSTREAM_REPO = os.getenv("UPSTREAM_REPO") +UPSTREAM_BRANCH = os.getenv("UPSTREAM_BRANCH") +MERGE_BRANCH = os.getenv("MERGE_BRANCH") +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + + +def run_command(command: str) -> str: + """Run a shell command and return its output.""" + logging.debug("Running command: %s", command) + try: + result: CompletedProcess[str] = subprocess.run(command, shell=True, capture_output=True, text=True) + result.check_returncode() + logging.debug("Command output: %s", result.stdout.strip()) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + logging.error("Error executing command: %s", command) + logging.error("Exit code: %d, Output: %s, Error: %s", e.returncode, e.output, e.stderr) + raise + + +def setup_repo(): + """Clone the repository and set up the upstream remote.""" + logging.info("Cloning repository: %s", TARGET_REPO) + run_command(f"git clone https://x-access-token:{GITHUB_TOKEN}@github.com/{TARGET_REPO}.git repo") + os.chdir("repo") + run_command(f"git remote add upstream https://x-access-token:{GITHUB_TOKEN}@github.com/{UPSTREAM_REPO}.git") + logging.info("Git remotes set up: %s", run_command(f"git remote -v")) + + +def update_merge_branch(): + """Update the merge branch with the latest changes from upstream.""" + logging.info("Fetching branch %s from upstream...", UPSTREAM_BRANCH) + run_command(f"git fetch upstream {UPSTREAM_BRANCH}") + run_command(f"git fetch origin") + all_branches: list[str] = run_command("git branch -a").split() + logging.debug("Fetched branches: %s", all_branches) + + if f"remotes/origin/{MERGE_BRANCH}" not in all_branches: + logging.info("Branch '%s' does not exist. Creating it from upstream/%s...", MERGE_BRANCH, UPSTREAM_BRANCH) + run_command(f"git checkout -b {MERGE_BRANCH} upstream/{UPSTREAM_BRANCH}") + run_command(f"git push -u origin {MERGE_BRANCH}") + return + + logging.info("Resetting '%s' onto upstream/%s...", MERGE_BRANCH, UPSTREAM_BRANCH) + run_command(f"git checkout {MERGE_BRANCH}") + run_command(f"git reset --hard upstream/{UPSTREAM_BRANCH}") + logging.info("Pushing changes to origin...") + run_command(f"git push origin {MERGE_BRANCH} --force") + + +def detect_commits() -> list[str]: + """Detect commits from upstream not yet in downstream.""" + logging.info("Detecting new commits from upstream...") + commit_log: list[str] = run_command(f"git log {TARGET_BRANCH}..{MERGE_BRANCH} --pretty=format:'%h %s'").split("\n") + commit_log.reverse() + logging.debug("Detected commits: %s", commit_log) + return commit_log + + +def fetch_pull(github: Github, pull_number: int) -> PullRequest | None: + """Fetch the pull request from GitHub.""" + logging.debug("Fetching pull request #%d", pull_number) + upstream_repo: Repository = github.get_repo(UPSTREAM_REPO) + + max_retries = 3 + for attempt in range(max_retries): + try: + pull = upstream_repo.get_pull(int(pull_number)) + logging.debug("Successfully fetched PR #%d: %s", pull_number, pull.title) + return pull + except Exception as e: + logging.error("Error fetching PR #%d: %s", pull_number, e) + if attempt + 1 < max_retries: + logging.warning("Retrying fetch for PR #%d (attempt %d/%d)", pull_number, attempt + 1, max_retries) + time.sleep(2) + else: + logging.error("Failed to fetch PR #%d after %d attempts", pull_number, max_retries) + return None + + +def build_details(github: Github, commit_log: list[str], + translate: typing.Optional[typing.Callable[[typing.Dict[int, list[Change]]], None]]) -> PullDetails: + """Generate data from parsed commits.""" + logging.info("Building pull request details from commit log...") + pull_number_pattern: Pattern[str] = re.compile("#(?P\\d+)") + details = PullDetails( + changelog={}, + merge_order=[], + config_changes=[], + sql_changes=[] + ) + pull_cache: dict[int, str] = {} + + with ThreadPoolExecutor() as executor: + futures: dict[Future, int] = {} + for commit in commit_log: + if '[ci skip]' in commit: + logging.debug("Skipping automatic commit: %s", commit) + continue + + match = re.search(pull_number_pattern, commit) + if not match: + logging.debug("Skipping commit without pull request reference: %s", commit) + continue + + pull_number = int(match.group("id")) + + if pull_number in pull_cache: + logging.warning( + "Duplicate pull request detected for #%d\n" + "Existing: %s\n" + "New: %s", + pull_number, pull_cache[pull_number], commit + ) + continue + + pull_cache[pull_number] = commit + details["merge_order"].append(pull_number) + futures[executor.submit(fetch_pull, github, pull_number)] = pull_number + + for future in as_completed(futures): + pull_number = futures[future] + try: + pull: PullRequest | None = future.result() + if not pull: + logging.warning("Failed to fetch pull request #%d. Skipping.", pull_number) + continue + process_pull(details, pull) + except Exception as e: + logging.error("Error processing pull request #%d: %s", pull_number, e) + + if translate: + translate(details["changelog"]) + + logging.info("Details building complete. Processed %d pull requests.", len(details["merge_order"])) + return details + + +def process_pull(details: PullDetails, pull: PullRequest): + """Handle fetched pull request data during details building.""" + logging.debug("Processing pull request #%d: %s", pull.number, pull.title) + pull_number: int = pull.number + labels: list[str] = [label.name for label in pull.get_labels()] + pull_changes: list[Change] = [] + + try: + for label in labels: + if label == UpstreamLabel.CONFIG_CHANGE.value: + details["config_changes"].append(pull) + logging.debug("Detected CONFIG_CHANGE label for PR #%d", pull_number) + elif label == UpstreamLabel.SQL_CHANGE.value: + details["sql_changes"].append(pull) + logging.debug("Detected SQL_CHANGE label for PR #%d", pull_number) + + parsed = changelog_utils.parse_changelog(pull.body) + if parsed and parsed["changes"]: + logging.debug("Parsed changelog for PR #%d: %s", pull_number, parsed["changes"]) + for change in parsed["changes"]: + pull_changes.append(Change( + tag=change["tag"], + message=change["message"], + pull=pull + )) + + if pull_changes: + details["changelog"][pull_number] = pull_changes + logging.debug("Added %d changes for PR #%d", len(pull_changes), pull_number) + except Exception as e: + logging.error( + "An error occurred while processing PR #%d: %s\n" + "Body: %s", + pull.number, e, pull.body + ) + raise + + +def translate_changelog(changelog: typing.Dict[int, list[Change]]): + """Translate changelog using OpenAI API.""" + logging.info("Translating changelog...") + if not changelog: + logging.warning("No changelog entries to translate.") + return + + changes: list[Change] = [change for changes in changelog.values() for change in changes] + if not changes: + logging.warning("No changes found in the changelog to translate.") + return + + logging.debug("Preparing text for translation: %d changes", len(changes)) + text = "\n".join([change["message"] for change in changes]) + logging.debug(text) + script_dir = Path(__file__).resolve().parent + with open(script_dir.joinpath("translation_context.txt"), encoding="utf-8") as f: + context = "\n".join(f.readlines()).strip() + + client = OpenAI( + base_url="https://models.inference.ai.azure.com", + api_key=OPENAI_API_KEY, + ) + response: ChatCompletion = client.chat.completions.create( + messages=[ + {"role": "system", "content": context}, + {"role": "user", "content": text} + ], + temperature=1.0, + top_p=1.0, + model="gpt-4o", + ) + translated_text: str | None = response.choices[0].message.content + + if not translated_text: + logging.warning("Changelog translation failed!") + logging.debug("Translation API response: %s", response) + return + + translated_text = sanitize_translation(translated_text) + + for change, translated_message in zip(changes, translated_text.split("\n"), strict=True): + change["translated_message"] = translated_message + logging.debug("Translated: %s -> %s", change["message"], translated_message) + + +def sanitize_translation(translated_text: str): + """Sanitize changelog translation.""" + return re.sub(r"\\n+", "\n+", translated_text.strip()) + + +def silence_pull_url(pull_url: str) -> str: + """Reformat HTTP URL with 'www' prefix to prevent pull request linking.""" + return re.sub("https?://", "www.", pull_url) + + +def prepare_pull_body(details: PullDetails) -> str: + """Build new pull request body from the generated changelog.""" + logging.info("Preparing pull request body...") + pull_body: str = ( + f"This pull request merges upstream/{UPSTREAM_BRANCH}. " + f"Resolve possible conflicts manually and make sure all the changes are applied correctly.\n" + ) + + if not details: + logging.warning("No pull details provided. Using default body.") + return pull_body + + label_to_pulls: dict[UpstreamLabel, list[PullRequest]] = { + UpstreamLabel.CONFIG_CHANGE: details["config_changes"], + UpstreamLabel.SQL_CHANGE: details["sql_changes"] + } + + for label, fetched_pulls in label_to_pulls.items(): + if not fetched_pulls: + logging.debug("No pulls found for label '%s'", label.value) + continue + + pull_body += ( + f"\n> [!{LABEL_BLOCK_STYLE[label]}]\n" + f"> {label.value}:\n" + ) + for fetched_pull in fetched_pulls: + silenced_url = silence_pull_url(fetched_pull.html_url) + logging.debug("Adding pull #%d to body: %s", fetched_pull.number, silenced_url) + pull_body += f"> {silenced_url}\n" + + if not details["changelog"]: + logging.info("No changelog entries found.") + return pull_body + + logging.info("Adding changelog entries to pull request body.") + pull_body += f"\n## Changelog\n" + pull_body += f":cl: {CHANGELOG_AUTHOR}\n" if CHANGELOG_AUTHOR else ":cl:\n" + + for pull_number in details["merge_order"]: + if pull_number not in details["changelog"]: + continue + for change in details["changelog"][pull_number]: + tag: str = change["tag"] + message: str = change["message"] + translated_message: str | None = change.get("translated_message") + pull_url: str = silence_pull_url(change["pull"].html_url) + if translated_message: + pull_body += f"{tag}: {translated_message} \n" + logging.debug("Added translated change for PR #%d: %s", pull_number, translated_message) + else: + pull_body += f"{tag}: {message} \n" + logging.debug("Added original change for PR #%d: %s", pull_number, message) + pull_body += "/:cl:\n" + + logging.info("Pull request body prepared successfully.") + return pull_body + + +def create_pr(repo: Repository, details: PullDetails): + """Create a pull request with the processed changelog.""" + logging.info("Creating pull request...") + pull_body: str = prepare_pull_body(details) + + try: + # Create the pull request + pull: PullRequest = repo.create_pull( + title=f"Merge Upstream {datetime.today().strftime('%d.%m.%Y')}", + body=pull_body, + head=MERGE_BRANCH, + base=TARGET_BRANCH + ) + logging.info("Pull request created: %s", pull.html_url) + except Exception as e: + logging.error("Failed to create pull request: %s", e) + raise + + +def check_pull_exists(target_repo: Repository, base: str, head: str): + """Check if the merge pull request already exists.""" + logging.info("Checking if pull request already exists between '%s' and '%s'...", base, head) + owner: str = target_repo.owner.login + head_strict = f"{owner}:{head}" + existing_pulls: PaginatedList[PullRequest] = target_repo.get_pulls(state="open", base=base, head=head_strict) + if existing_pulls.totalCount > 0: + logging.error("Pull request already exists: %s", ", ".join(pull.html_url for pull in existing_pulls)) + exit(1) + logging.debug("No existing pull requests found.") + +if __name__ == "__main__": + github = Github(GITHUB_TOKEN) + target_repo: Repository = github.get_repo(TARGET_REPO) + + check_pull_exists(target_repo, TARGET_BRANCH, MERGE_BRANCH) + setup_repo() + + update_merge_branch() + commit_log: list[str] = detect_commits() + + if commit_log: + details: PullDetails = build_details(github, commit_log, translate_changelog if TRANSLATE_CHANGES else None) + create_pr(target_repo, details) + else: + logging.info("No changes detected from %s/%s. Skipping pull request creation.", UPSTREAM_REPO, UPSTREAM_BRANCH) diff --git a/tools/merge-upstream/translation_context.txt b/tools/merge-upstream/translation_context.txt new file mode 100644 index 0000000000000..8bef50b6d5ee4 --- /dev/null +++ b/tools/merge-upstream/translation_context.txt @@ -0,0 +1,101 @@ +Ты работаешь над переводом чейнджлога проекта Space Station 13 "tg/station13". Каждая строка описывает отдельное изменение или улучшение, внесённое в проект. + +Инструкции для перевода: + +Дословность с ясностью: Перевод должен быть максимально точным и дословным, но допускается перефразирование для лучшего понимания текста на русском языке. +Терминология: Сохраняй технические термины и названия (например, названия предметов, функций, ролей или систем) в оригинальном виде, если они общеупотребимы в сообществе игры. Если термин можно перевести без потери смысла, адаптируй его. Если термин можно транслитерировать на русском, сделай это. +Стиль: Придерживайся официального и нейтрального стиля, но сохраняй читабельность и естественность текста. +Формат: Сохраняй структуру исходного текста: каждая строка перевода должна соответствовать ровно одной строке исходного текста, равно как и обратное. Увеличение или уменьшение количества строк недопустимо. Нумерация или форматирование в виде списка недопустимы. Добавление знаков препинания в предложениях допустимо. + +Основные понятия: + +Tajaran – Таяр / таяра +Unathi – Унати / унатх +Grey – Серый +IPC – КПБ +Plasmamen – Плазмамен +Vulpkanin – Вульпканин / вульпа +Mothpeople – Ниан + +Captain – Капитан +Head of Personnel (HoP) – Глава персонала (ХОП/ГП) +Head of Security (HoS) – Глава службы безопасности (ХОС/ГСБ) +Chief Engineer (CE) – Главный инженер (СЕ) +Research Director (RD) – Директор исследований (РД) +Chief Medical Officer (CMO) – Главный врач (СМО) +Quartermaster (QM) – Квартирмейстер (КМ) +Blueshield - Офицер "Синий Щит" +Nanotrasen Representative - Представитель Нанотрейзен +Magistrate - Магистрат +Lawyer - Юрист +Warden – Смотритель +Detective – Детектив +Security Officer – Офицер службы безопасности +Atmospheric Technician – Атмосферный техник +Station Engineer – Станционный инженер +Scientist – Ученый +Roboticist – Робототехник +Geneticist – Генетик +Xenobiologist – Ксенобиолог +Medical Doctor – Врач +Paramedic – Парамедик +Chemist – Химик +Cargo Technician – Грузчик +Miner – Шахтер +Bitrunner – Битраннер +Bartender – Бармен +Chef – Шеф-повар +Botanist – Ботаник +Janitor – Уборщик +Clown – Клоун +Mime – Мим +Chaplain – Священник +Psychologist - Психолог +Assistant – Ассистент +Internal Affairs Agent (IAA) – Агент внутренних дел (АВД) +Librarian – Библиотекарь + +Traitor – Предатель +Changeling – Генокрад +Wizard – Волшебник +Revenant – Ревенант +Revolutionary – Революционер +Cultist – Культист +Blob – Блоб +Nuclear Operative – Ядерный оперативник +Xenomorph – Ксеноморф +Morph – Морф +Abductor – Абдуктор + +AI Core – Ядро ИИ +Holopad – Голопад +Cryo – Криокапсула +MedHUD – MedHUD +SecHUD – SecHUD +Nanotrasen – Нанотрейзен +Syndicate – Синдикат +Supermatter (SM) – Кристалл суперматерии (СМ) +Singularity Engine – Сингулярный двигатель +Rapid Construction Device (RCD) – Устройство быстрой постройки (РЦД/УБП) +Personal Data Assistant (PDA) – Карманный персональный компьютер (ПДА/КПК) +Bluespace – Блюспейс +Bluespace Anomaly – Блюспейс-аномалия +Plasma Tank – Баллон с плазмой +Chem Dispenser – Химический раздатчик +Circuit Imprinter – Принтер плат +Autolathe – Автолат +Protolathe – Протолат +Cloning Pod – Капсула клонирования +Borg Charger – Зарядное устройство для боргов +APC (Area Power Controller) – Локальный контроллер питания (АПЦ/ЛКП) +SMES (Substation Energy Storage) – Энергетический накопитель (СМЕС) +Tcomms (Telecommunications) – Телекоммуникации +Bolts (Airlock Bolts) – Болты шлюза +Gravitational Singularity – Гравитационная сингулярность +Spacesuit – Космический скафандр +Cyborg Module – Модуль киборга +Mech (Mecha) – Мех +Exosuit – Экзокостюм +Death Alarm Implant – Имплант-сигнализатор смерти +Mindshield Implant – Имплант защиты разума (майндщилд) +Loadout / loadout gear – Лодаут / предметы лодаута