update_changelog.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import functools
  2. import sys
  3. import json
  4. import re
  5. import subprocess
  6. import requests
  7. import os
  8. sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../validate_pr_description")))
  9. from validate_pr_description import validate_pr_description
  10. UNRELEASED = "Unreleased"
  11. UNCATEGORIZED = "Uncategorized"
  12. VERSION_PREFIX = "## "
  13. CATEGORY_PREFIX = "### "
  14. ITEM_PREFIX = "* "
  15. YDBOT_TOKEN = os.getenv("YDBOT_TOKEN")
  16. @functools.cache
  17. def get_github_api_url():
  18. return os.getenv('GITHUB_REPOSITORY')
  19. def to_dict(changelog_path, encoding='utf-8'):
  20. changelog = {}
  21. current_version = UNRELEASED
  22. current_category = UNCATEGORIZED
  23. pr_number = None
  24. changelog[current_version] = {}
  25. changelog[current_version][current_category] = {}
  26. if not os.path.exists(changelog_path):
  27. return changelog
  28. with open(changelog_path, 'r', encoding=encoding) as file:
  29. for line in file:
  30. if line.startswith(VERSION_PREFIX):
  31. current_version = line.strip().strip(VERSION_PREFIX)
  32. pr_number = None
  33. changelog[current_version] = {}
  34. elif line.startswith(CATEGORY_PREFIX):
  35. current_category = line.strip().strip(CATEGORY_PREFIX)
  36. pr_number = None
  37. changelog[current_version][current_category] = {}
  38. elif line.startswith(ITEM_PREFIX):
  39. pr_number = extract_pr_number(line)
  40. changelog[current_version][current_category][pr_number] = line.strip(f"{ITEM_PREFIX}{pr_number}:")
  41. elif pr_number:
  42. changelog[current_version][current_category][pr_number] += f"{line}"
  43. return changelog
  44. def to_file(changelog_path, changelog):
  45. with open(changelog_path, 'w', encoding='utf-8') as file:
  46. if UNRELEASED in changelog:
  47. file.write(f"{VERSION_PREFIX}{UNRELEASED}\n\n")
  48. for category, items in changelog[UNRELEASED].items():
  49. if(len(changelog[UNRELEASED][category]) == 0):
  50. continue
  51. file.write(f"{CATEGORY_PREFIX}{category}\n")
  52. for id, body in items.items():
  53. file.write(f"{ITEM_PREFIX}{id}:{body.strip()}\n")
  54. file.write("\n")
  55. for version, categories in changelog.items():
  56. if version == UNRELEASED:
  57. continue
  58. file.write(f"{VERSION_PREFIX}{version}\n\n")
  59. for category, items in categories.items():
  60. if(len(changelog[version][category]) == 0):
  61. continue
  62. file.write(f"{CATEGORY_PREFIX}{category}\n")
  63. for id, body in items.items():
  64. file.write(f"{ITEM_PREFIX}{id}:{body.strip()}\n")
  65. file.write("\n")
  66. def extract_changelog_category(description):
  67. category_section = re.search(r"### Changelog category.*?\n(.*?)(\n###|$)", description, re.DOTALL)
  68. if category_section:
  69. categories = [line.strip('* ').strip() for line in category_section.group(1).splitlines() if line.strip()]
  70. if len(categories) == 1:
  71. return categories[0]
  72. return None
  73. def extract_pr_number(changelog_entry):
  74. match = re.search(r"#(\d+)", changelog_entry)
  75. if match:
  76. return int(match.group(1))
  77. return None
  78. def extract_changelog_body(description):
  79. body_section = re.search(r"### Changelog entry.*?\n(.*?)(\n###|$)", description, re.DOTALL)
  80. if body_section:
  81. return body_section.group(1).strip()
  82. return None
  83. def match_pr_to_changelog_category(category):
  84. categories = {
  85. "New feature": "Functionality",
  86. "Experimental feature": "Functionality",
  87. "Improvement": "Functionality",
  88. "Performance improvement": "Performance",
  89. "User Interface": "YDB UI",
  90. "Bugfix": "Bug fixes",
  91. "Backward incompatible change": "Backward incompatible change",
  92. "Documentation (changelog entry is not required)": UNCATEGORIZED,
  93. "Not for changelog (changelog entry is not required)": UNCATEGORIZED
  94. }
  95. if category in categories:
  96. return categories[category]
  97. for key, value in categories.items():
  98. if key.startswith(category):
  99. return value
  100. return UNCATEGORIZED
  101. def update_changelog(changelog_path, pr_data):
  102. changelog = to_dict(changelog_path)
  103. if UNRELEASED not in changelog:
  104. changelog[UNRELEASED] = {}
  105. for pr in pr_data:
  106. if validate_pr_description(pr["body"], is_not_for_cl_valid=False):
  107. category = extract_changelog_category(pr["body"])
  108. category = match_pr_to_changelog_category(category)
  109. body = extract_changelog_body(pr["body"])
  110. if category and body:
  111. body += f" [#{pr['number']}]({pr['url']})"
  112. body += f" ([{pr['name']}]({pr['user_url']}))"
  113. if category not in changelog[UNRELEASED]:
  114. changelog[UNRELEASED][category] = {}
  115. if pr['number'] not in changelog[UNRELEASED][category]:
  116. changelog[UNRELEASED][category][pr['number']] = body
  117. to_file(changelog_path, changelog)
  118. def run_command(command):
  119. try:
  120. result = subprocess.run(command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  121. except subprocess.CalledProcessError as e:
  122. print(f"::error::Command failed with exit code {e.returncode}: {e.stderr.decode()}")
  123. print(f"::error::Command: {e.cmd}")
  124. print(f"::error::Output: {e.stdout.decode()}")
  125. sys.exit(1)
  126. return result.stdout.decode().strip()
  127. def branch_exists(branch_name):
  128. result = subprocess.run(["git", "ls-remote", "--heads", "origin", branch_name], capture_output=True, text=True)
  129. return branch_name in result.stdout
  130. def fetch_pr_details(pr_id):
  131. url = f"https://api.github.com/repos/{get_github_api_url()}/pulls/{pr_id}"
  132. headers = {
  133. "Accept": "application/vnd.github.v3+json",
  134. "Authorization": f"token {YDBOT_TOKEN}"
  135. }
  136. response = requests.get(url, headers=headers)
  137. response.raise_for_status()
  138. return response.json()
  139. def fetch_user_details(username):
  140. url = f"https://api.github.com/users/{username}"
  141. headers = {
  142. "Accept": "application/vnd.github.v3+json",
  143. "Authorization": f"token {YDBOT_TOKEN}"
  144. }
  145. response = requests.get(url, headers=headers)
  146. response.raise_for_status()
  147. return response.json()
  148. if __name__ == "__main__":
  149. if len(sys.argv) != 5:
  150. print("Usage: update_changelog.py <pr_data_file> <changelog_path> <base_branch> <suffix>")
  151. sys.exit(1)
  152. pr_data_file = sys.argv[1]
  153. changelog_path = sys.argv[2]
  154. base_branch = sys.argv[3]
  155. suffix = sys.argv[4]
  156. try:
  157. with open(pr_data_file, 'r') as file:
  158. pr_ids = json.load(file)
  159. except Exception as e:
  160. print(f"::error::Failed to read or parse PR data file: {e}")
  161. sys.exit(1)
  162. pr_data = []
  163. for pr in pr_ids:
  164. try:
  165. pr_details = fetch_pr_details(pr["id"])
  166. user_details = fetch_user_details(pr_details["user"]["login"])
  167. if validate_pr_description(pr_details["body"], is_not_for_cl_valid=False):
  168. pr_data.append({
  169. "number": pr_details["number"],
  170. "body": pr_details["body"].strip(),
  171. "url": pr_details["html_url"],
  172. "name": user_details.get("name", pr_details["user"]["login"]), # Use login if name is not available
  173. "user_url": pr_details["user"]["html_url"]
  174. })
  175. except Exception as e:
  176. print(f"::error::Failed to fetch PR details for PR #{pr['id']}: {e}")
  177. sys.exit(1)
  178. update_changelog(changelog_path, pr_data)
  179. base_branch_name = f"dev-changelog-{base_branch}-{suffix}"
  180. branch_name = base_branch_name
  181. index = 1
  182. while branch_exists(branch_name):
  183. branch_name = f"{base_branch_name}-{index}"
  184. index += 1
  185. run_command(f"git checkout -b {branch_name}")
  186. run_command(f"git add {changelog_path}")
  187. run_command(f"git commit -m \"Update CHANGELOG.md for {suffix}\"")
  188. run_command(f"git push origin {branch_name}")
  189. pr_title = f"Update CHANGELOG.md for {suffix}"
  190. pr_body = f"This PR updates the CHANGELOG.md file for {suffix}."
  191. pr_create_command = f"gh pr create --title \"{pr_title}\" --body \"{pr_body}\" --base {base_branch} --head {branch_name}"
  192. pr_url = run_command(pr_create_command)
  193. # run_command(f"gh pr edit {pr_url} --add-assignee galnat") # TODO: Make assignee customizable