Skip to content

Commit 46d9b3c

Browse files
galnatblinkov
authored andcommitted
KIKIMR-22458 Generate changelog increment (#14205)
Added github action for validate PR body Added github actions for generate changelog increment
1 parent ecaa8a6 commit 46d9b3c

File tree

8 files changed

+671
-0
lines changed

8 files changed

+671
-0
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: "Update Changelog"
2+
3+
description: "Custom action to update changelog based on input parameters."
4+
5+
inputs:
6+
pr_data:
7+
description: "List of ids"
8+
required: true
9+
changelog_path:
10+
description: "The value associated with the type."
11+
required: true
12+
base_branch:
13+
description: "The base branch for the changelog update"
14+
required: true
15+
suffix:
16+
description: "Suffix for the changelog update"
17+
required: true
18+
19+
runs:
20+
using: "composite"
21+
steps:
22+
- name: Set up Python
23+
uses: actions/setup-python@v4
24+
with:
25+
python-version: "3.x"
26+
27+
- name: Install dependencies
28+
shell: bash
29+
run: |
30+
python -m pip install --upgrade pip
31+
pip install requests
32+
33+
- name: Store PR data to a temporary file
34+
shell: bash
35+
run: |
36+
echo '${{ inputs.pr_data }}' > pr_data.txt
37+
38+
- name: Run update_changelog.py
39+
shell: bash
40+
run: |
41+
git config --local user.email "action@github.com"
42+
git config --local user.name "GitHub Action"
43+
git config --local github.token ${{ env.UPDATE_REPO_TOKEN }}
44+
python ${{ github.action_path }}/update_changelog.py pr_data.txt "${{ inputs.changelog_path }}" "${{ inputs.base_branch }}" "${{ inputs.suffix }}"
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import functools
2+
import sys
3+
import json
4+
import re
5+
import subprocess
6+
import requests
7+
8+
import os
9+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../validate_pr_description")))
10+
from validate_pr_description import validate_pr_description
11+
12+
UNRELEASED = "Unreleased"
13+
UNCATEGORIZED = "Uncategorized"
14+
VERSION_PREFIX = "## "
15+
CATEGORY_PREFIX = "### "
16+
ITEM_PREFIX = "* "
17+
18+
@functools.cache
19+
def get_github_api_url():
20+
return os.getenv('GITHUB_REPOSITORY')
21+
22+
def to_dict(changelog_path, encoding='utf-8'):
23+
changelog = {}
24+
current_version = UNRELEASED
25+
current_category = UNCATEGORIZED
26+
pr_number = None
27+
changelog[current_version] = {}
28+
changelog[current_version][current_category] = {}
29+
30+
if not os.path.exists(changelog_path):
31+
return changelog
32+
33+
with open(changelog_path, 'r', encoding=encoding) as file:
34+
for line in file:
35+
if line.startswith(VERSION_PREFIX):
36+
current_version = line.strip().strip(VERSION_PREFIX)
37+
pr_number = None
38+
changelog[current_version] = {}
39+
elif line.startswith(CATEGORY_PREFIX):
40+
current_category = line.strip().strip(CATEGORY_PREFIX)
41+
pr_number = None
42+
changelog[current_version][current_category] = {}
43+
elif line.startswith(ITEM_PREFIX):
44+
pr_number = extract_pr_number(line)
45+
changelog[current_version][current_category][pr_number] = line.strip(f"{ITEM_PREFIX}{pr_number}:")
46+
elif pr_number:
47+
changelog[current_version][current_category][pr_number] += f"{line}"
48+
49+
return changelog
50+
51+
def to_file(changelog_path, changelog):
52+
with open(changelog_path, 'w', encoding='utf-8') as file:
53+
if UNRELEASED in changelog:
54+
file.write(f"{VERSION_PREFIX}{UNRELEASED}\n\n")
55+
for category, items in changelog[UNRELEASED].items():
56+
if(len(changelog[UNRELEASED][category]) == 0):
57+
continue
58+
file.write(f"{CATEGORY_PREFIX}{category}\n")
59+
for id, body in items.items():
60+
file.write(f"{ITEM_PREFIX}{id}:{body.strip()}\n")
61+
file.write("\n")
62+
63+
for version, categories in changelog.items():
64+
if version == UNRELEASED:
65+
continue
66+
file.write(f"{VERSION_PREFIX}{version}\n\n")
67+
for category, items in categories.items():
68+
if(len(changelog[version][category]) == 0):
69+
continue
70+
file.write(f"{CATEGORY_PREFIX}{category}\n")
71+
for id, body in items.items():
72+
file.write(f"{ITEM_PREFIX}{id}:{body.strip()}\n")
73+
file.write("\n")
74+
75+
def extract_changelog_category(description):
76+
category_section = re.search(r"### Changelog category.*?\n(.*?)(\n###|$)", description, re.DOTALL)
77+
if category_section:
78+
categories = [line.strip('* ').strip() for line in category_section.group(1).splitlines() if line.strip()]
79+
if len(categories) == 1:
80+
return categories[0]
81+
return None
82+
83+
def extract_pr_number(changelog_entry):
84+
match = re.search(r"#(\d+)", changelog_entry)
85+
if match:
86+
return int(match.group(1))
87+
return None
88+
89+
def extract_changelog_body(description):
90+
body_section = re.search(r"### Changelog entry.*?\n(.*?)(\n###|$)", description, re.DOTALL)
91+
if body_section:
92+
return body_section.group(1).strip()
93+
return None
94+
95+
def match_pr_to_changelog_category(category):
96+
categories = {
97+
"New feature": "Functionality",
98+
"Experimental feature": "Functionality",
99+
"Improvement": "Functionality",
100+
"Performance improvement": "Performance",
101+
"User Interface": "YDB UI",
102+
"Bugfix": "Bug fixes",
103+
"Backward incompatible change": "Backward incompatible change",
104+
"Documentation (changelog entry is not required)": UNCATEGORIZED,
105+
"Not for changelog (changelog entry is not required)": UNCATEGORIZED
106+
}
107+
if category in categories:
108+
return categories[category]
109+
for key, value in categories.items():
110+
if key.startswith(category):
111+
return value
112+
return UNCATEGORIZED
113+
114+
115+
def update_changelog(changelog_path, pr_data):
116+
changelog = to_dict(changelog_path)
117+
if UNRELEASED not in changelog:
118+
changelog[UNRELEASED] = {}
119+
120+
for pr in pr_data:
121+
if validate_pr_description(pr["body"], is_not_for_cl_valid=False):
122+
category = extract_changelog_category(pr["body"])
123+
category = match_pr_to_changelog_category(category)
124+
body = extract_changelog_body(pr["body"])
125+
if category and body:
126+
body += f" [#{pr['number']}]({pr['url']})"
127+
body += f" ([{pr['name']}]({pr['user_url']}))"
128+
if category not in changelog[UNRELEASED]:
129+
changelog[UNRELEASED][category] = {}
130+
if pr['number'] not in changelog[UNRELEASED][category]:
131+
changelog[UNRELEASED][category][pr['number']] = body
132+
133+
to_file(changelog_path, changelog)
134+
135+
def run_command(command):
136+
try:
137+
result = subprocess.run(command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
138+
except subprocess.CalledProcessError as e:
139+
print(f"::error::Command failed with exit code {e.returncode}: {e.stderr.decode()}")
140+
print(f"::error::Command: {e.cmd}")
141+
print(f"::error::Output: {e.stdout.decode()}")
142+
sys.exit(1)
143+
return result.stdout.decode().strip()
144+
145+
def branch_exists(branch_name):
146+
result = subprocess.run(["git", "ls-remote", "--heads", "origin", branch_name], capture_output=True, text=True)
147+
return branch_name in result.stdout
148+
149+
def fetch_pr_details(pr_id):
150+
url = f"https://api.github.com/repos/{get_github_api_url()}/pulls/{pr_id}"
151+
headers = {
152+
"Accept": "application/vnd.github.v3+json",
153+
"Authorization": f"token {GITHUB_TOKEN}"
154+
}
155+
response = requests.get(url, headers=headers)
156+
response.raise_for_status()
157+
return response.json()
158+
159+
def fetch_user_details(username):
160+
url = f"https://api.github.com/users/{username}"
161+
headers = {
162+
"Accept": "application/vnd.github.v3+json",
163+
"Authorization": f"token {GITHUB_TOKEN}"
164+
}
165+
response = requests.get(url, headers=headers)
166+
response.raise_for_status()
167+
return response.json()
168+
169+
if __name__ == "__main__":
170+
if len(sys.argv) != 5:
171+
print("Usage: update_changelog.py <pr_data_file> <changelog_path> <base_branch> <suffix>")
172+
sys.exit(1)
173+
174+
pr_data_file = sys.argv[1]
175+
changelog_path = sys.argv[2]
176+
base_branch = sys.argv[3]
177+
suffix = sys.argv[4]
178+
179+
GITHUB_TOKEN = os.getenv("UPDATE_REPO_TOKEN")
180+
181+
try:
182+
with open(pr_data_file, 'r') as file:
183+
pr_ids = json.load(file)
184+
except Exception as e:
185+
print(f"::error::Failed to read or parse PR data file: {e}")
186+
sys.exit(1)
187+
188+
pr_data = []
189+
for pr in pr_ids:
190+
try:
191+
pr_details = fetch_pr_details(pr["id"])
192+
user_details = fetch_user_details(pr_details["user"]["login"])
193+
if validate_pr_description(pr_details["body"], is_not_for_cl_valid=False):
194+
pr_data.append({
195+
"number": pr_details["number"],
196+
"body": pr_details["body"].strip(),
197+
"url": pr_details["html_url"],
198+
"name": user_details.get("name", pr_details["user"]["login"]), # Use login if name is not available
199+
"user_url": pr_details["user"]["html_url"]
200+
})
201+
except Exception as e:
202+
print(f"::error::Failed to fetch PR details for PR #{pr['id']}: {e}")
203+
sys.exit(1)
204+
205+
update_changelog(changelog_path, pr_data)
206+
207+
base_branch_name = f"changelog-for-{base_branch}-{suffix}"
208+
branch_name = base_branch_name
209+
index = 1
210+
while branch_exists(branch_name):
211+
branch_name = f"{base_branch_name}-{index}"
212+
index += 1
213+
run_command(f"git checkout -b {branch_name}")
214+
run_command(f"git add {changelog_path}")
215+
run_command(f"git commit -m \"Update CHANGELOG.md for {suffix}\"")
216+
run_command(f"git push origin {branch_name}")
217+
218+
pr_title = f"Update CHANGELOG.md for {suffix}"
219+
pr_body = f"This PR updates the CHANGELOG.md file for {suffix}."
220+
pr_create_command = f"gh pr create --title \"{pr_title}\" --body \"{pr_body}\" --base {base_branch} --head {branch_name}"
221+
pr_url = run_command(pr_create_command)
222+
# run_command(f"gh pr edit {pr_url} --add-assignee galnat") # TODO: Make assignee customizable
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: "validate-pr-description"
2+
3+
runs:
4+
using: "composite"
5+
steps:
6+
- name: Save PR body to temporary file
7+
shell: bash
8+
run: |
9+
echo "${{ inputs.pr_body }}" > pr_body.txt
10+
11+
- name: Run validation script
12+
id: validate
13+
shell: bash
14+
env:
15+
GITHUB_TOKEN: ${{ github.token }}
16+
run: |
17+
python3 -m pip install PyGithub
18+
python3 ${{ github.action_path }}/validate_pr_description.py pr_body.txt
19+
20+
inputs:
21+
pr_body:
22+
description: "The body of the pull request."
23+
required: true
24+
25+
outputs:
26+
status:
27+
description: "The status of the PR description validation."
28+
value: ${{ steps.validate.outcome }}
29+
30+
files:
31+
- validate_pr_description.py
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import datetime
2+
import os
3+
import json
4+
from github import Github, Auth as GithubAuth
5+
from github.PullRequest import PullRequest
6+
7+
def post(is_valid, error_description):
8+
gh = Github(auth=GithubAuth.Token(os.environ["GITHUB_TOKEN"]))
9+
10+
with open(os.environ["GITHUB_EVENT_PATH"]) as fp:
11+
event = json.load(fp)
12+
13+
pr = gh.create_from_raw_data(PullRequest, event["pull_request"])
14+
15+
header = f"<!-- status pr={pr.number}, validate PR description status -->"
16+
17+
body = [header]
18+
comment = None
19+
for c in pr.get_issue_comments():
20+
if c.body.startswith(header):
21+
print(f"found comment id={c.id}")
22+
comment = c
23+
24+
status_to_header = {
25+
True: "The validation of the Pull Request description is successful.",
26+
False: "The validation of the Pull Request description has failed. Please update the description."
27+
}
28+
29+
color = "green" if is_valid else "red"
30+
indicator = f":{color}_circle:"
31+
timestamp_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
32+
body.append(f"{indicator} `{timestamp_str}` {status_to_header[is_valid]}")
33+
34+
if not is_valid:
35+
body.append(f"\n{error_description}")
36+
37+
body = "\n".join(body)
38+
39+
if comment:
40+
print(f"edit comment")
41+
comment.edit(body)
42+
else:
43+
print(f"post new comment")
44+
pr.create_issue_comment(body)

0 commit comments

Comments
 (0)