Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changelog Validation & Merge Upstream Workflow #992

Merged
merged 5 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@
## 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:

<!-- Оба :cl:'а должны быть на месте, что-бы чейнджлог работал! Вы можете написать свой ник справа от первого :cl:, если хотите. Иначе будет использован ваш ник на ГитХабе. -->
<!-- Оба :cl:а должны быть на месте, что-бы чейнджлог работал! Вы можете написать свой ник справа от первого :cl:, если хотите. Иначе будет использован ваш ник на ГитХабе. -->
m-dzianishchyts marked this conversation as resolved.
Show resolved Hide resolved
<!-- Вы можете использовать несколько записей с одинаковым префиксом (Они используются только для иконки в игре) и удалить ненужные. Помните, что чейнджлог должен быть понятен обычным игроком. -->
<!-- Если чейнджлог не влияет на игроков(например, это рефактор), вы можете исключить всю секцию. -->
2 changes: 1 addition & 1 deletion .github/workflows/auto_changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions .github/workflows/check_changelog.yml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions .github/workflows/merge_upstream.yml
Original file line number Diff line number Diff line change
@@ -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 "[email protected]"
git config --global user.name "Upstream Sync"
python3 -u merge_upstream.py
99 changes: 99 additions & 0 deletions tools/changelog/changelog_utils.py
Original file line number Diff line number Diff line change
@@ -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<author>.+?)?\s*\n(?P<content>(.|\n)*?)\n/(:cl:|🆑)", re.MULTILINE)
CL_SPLIT = re.compile(r"\s*(?:(?P<tag>\w+)\s*:)?\s*(?P<message>.*)")

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
}
85 changes: 85 additions & 0 deletions tools/changelog/check_changelog.py
Original file line number Diff line number Diff line change
@@ -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.")
47 changes: 47 additions & 0 deletions tools/changelog/tags.yml
Original file line number Diff line number Diff line change
@@ -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: 'Изменил что-то серверное, о чем должен знать хост'
Loading
Loading