Analysis Software
Documentation for sPHENIX simulation software
 All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Groups Pages
release.py
Go to the documentation of this file. Or view the newest version in sPHENIX GitHub for file release.py
1 #!/usr/bin/env python3
2 import os
3 import asyncio
4 from typing import List, Optional, Tuple
5 import re
6 from pathlib import Path
7 import sys
8 import http
9 import json
10 import yaml
11 import datetime
12 import typer
13 import base64
14 
15 import aiohttp
16 from gidgethub.aiohttp import GitHubAPI
17 from gidgethub import InvalidField
18 import gidgethub
19 from semantic_release.history import angular_parser, get_new_version
20 from semantic_release.errors import UnknownCommitMessageStyleError
21 from semantic_release.history.logs import LEVELS
22 from semantic_release.history.parser_helpers import ParsedCommit
23 import sh
24 from dotenv import load_dotenv
25 import functools
26 
27 load_dotenv()
28 
29 git = sh.git
30 
31 RETRY_COUNT = 10
32 RETRY_INTERVAL = 0.5 # seconds
33 
34 
35 def get_repo():
36  repo = os.environ.get("GITHUB_REPOSITORY", None)
37  if repo is not None:
38  return repo
39 
40  origin = git.remote("get-url", "origin")
41  _, loc = origin.split(":", 1)
42  repo, _ = loc.split(".", 1)
43  return repo
44 
45 
47  raw = git.describe().split("-")[0]
48  m = re.match(r"v(\d+\.\d+\.\d+)", raw)
49  return m.group(1)
50 
51 
52 class Commit:
53  sha: str
54  message: str
55  author: str
56 
57  def __init__(self, sha: str, message: str, author: str):
58  self.sha = sha
59  self.message = self._normalize(message)
60  self.author = author
61 
62  @staticmethod
63  def _normalize(message):
64  message = message.replace("\r", "\n")
65  return message
66 
67  def __str__(self):
68  message = self.message.split("\n")[0]
69  return f"Commit(sha='{self.sha[:8]}', message='{message}')"
70 
71 
72 _default_parser = angular_parser
73 
74 
76  commits: List[Commit], commit_parser=_default_parser
77 ) -> Optional[str]:
78  """
79  Adapted from: https://github.com/relekang/python-semantic-release/blob/master/semantic_release/history/logs.py#L22
80  """
81  bump = None
82 
83  changes = []
84  commit_count = 0
85 
86  for commit in commits:
87  commit_count += 1
88  try:
89  message = commit_parser(commit.message)
90  changes.append(message.bump)
91  except UnknownCommitMessageStyleError:
92  print("Unknown commit message style!")
93 
94  if changes:
95  level = max(changes)
96  if level in LEVELS:
97  bump = LEVELS[level]
98  else:
99  print(f"Unknown bump level {level}")
100 
101  return bump
102 
103 
104 def generate_changelog(commits, commit_parser=_default_parser) -> dict:
105  """
106  Modified from: https://github.com/relekang/python-semantic-release/blob/48972fb761ed9b0fb376fa3ad7028d65ff407ee6/semantic_release/history/logs.py#L78
107  """
108  changes: dict = {"breaking": []}
109 
110  for commit in commits:
111  try:
112  message: ParsedCommit = commit_parser(commit.message)
113  if message.type not in changes:
114  changes[message.type] = list()
115 
116  capital_message = (
117  message.descriptions[0][0].upper() + message.descriptions[0][1:]
118  )
119  changes[message.type].append((commit.sha, capital_message, commit.author))
120 
121  if message.breaking_descriptions:
122  for paragraph in message.breaking_descriptions:
123  changes["breaking"].append((commit.sha, paragraph, commit.author))
124  elif message.bump == 3:
125  changes["breaking"].append(
126  (commit.sha, message.descriptions[0], commit.author)
127  )
128 
129  except UnknownCommitMessageStyleError:
130  print("Unknown commit message style!")
131 
132  return changes
133 
134 
135 def markdown_changelog(version: str, changelog: dict, header: bool = False) -> str:
136  output = f"## v{version}\n" if header else ""
137 
138  for section, items in changelog.items():
139  if len(items) == 0:
140  continue
141  output += "\n### {0}\n".format(section.capitalize())
142 
143  for sha, msg, author in items:
144  output += "* {} ({}) (@{})\n".format(msg, sha, author)
145 
146  return output
147 
148 
149 def update_zenodo(zenodo_file: Path, repo: str, next_version):
150  data = json.loads(zenodo_file.read_text())
151  data["title"] = f"{repo}: v{next_version}"
152  data["version"] = f"v{next_version}"
153  zenodo_file.write_text(json.dumps(data, indent=2))
154 
155 
156 def update_citation(citation_file: Path, next_version):
157  with citation_file.open() as fh:
158  data = yaml.safe_load(fh)
159  data["version"] = f"v{next_version}"
160  data["date-released"] = datetime.date.today().strftime("%Y-%m-%d")
161  with citation_file.open("w") as fh:
162  yaml.dump(data, fh, indent=2)
163 
164 
165 def make_sync(fn):
166  @functools.wraps(fn)
167  def wrapped(*args, **kwargs):
168  loop = asyncio.get_event_loop()
169  loop.run_until_complete(fn(*args, **kwargs))
170 
171  return wrapped
172 
173 
174 app = typer.Typer()
175 
176 
177 async def get_parsed_commit_range(
178  start: str, end: str, repo: str, gh: GitHubAPI, edit: bool = False
179 ) -> Tuple[List[Commit], List[Commit]]:
180  commits_iter = gh.getiter(f"/repos/{repo}/commits?sha={start}")
181 
182  commits = []
183  unparsed_commits = []
184 
185  try:
186  async for item in commits_iter:
187  commit_hash = item["sha"]
188  commit_message = item["commit"]["message"]
189  if commit_hash == end:
190  break
191 
192  invalid_message = False
193  try:
194  _default_parser(commit_message)
195  # if this succeeds, do nothing
196  except UnknownCommitMessageStyleError:
197  print("Unknown commit message style!")
198  if not commit_message.startswith("Merge"):
199  invalid_message = True
200  if (
201  (invalid_message or edit)
202  and sys.stdout.isatty()
203  and False
204  and typer.confirm(f"Edit effective message '{commit_message}'?")
205  ):
206  commit_message = typer.edit(commit_message)
207  _default_parser(commit_message)
208 
209  commit = Commit(commit_hash, commit_message, item["author"]["login"])
210  commits.append(commit)
211 
212  if invalid_message:
213  unparsed_commits.append(commit)
214 
215  print("-", commit)
216  if len(commits) > 200:
217  raise RuntimeError(f"{len(commits)} are a lot. Aborting!")
218  return commits, unparsed_commits
219  except gidgethub.BadRequest:
220  print(
221  "BadRequest for commit retrieval. That is most likely because you forgot to push the merge commit."
222  )
223  return
224 
225 
226 @app.command()
227 @make_sync
228 async def make_release(
229  token: str = typer.Argument(..., envvar="GH_TOKEN"),
230  draft: bool = True,
231  dry_run: bool = False,
232  edit: bool = False,
233 ):
234  async with aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session:
235  gh = GitHubAPI(session, __name__, oauth_token=token)
236 
237  version_file = Path("version_number")
238  current_version = version_file.read_text()
239 
240  tag_hash = str(git("rev-list", "-n", "1", f"v{current_version}").strip())
241  print("current_version:", current_version, "[" + tag_hash[:8] + "]")
242 
243  sha = git("rev-parse", "HEAD").strip()
244  print("sha:", sha)
245 
246  repo = get_repo()
247  print("repo:", repo)
248 
249  commits, _ = await get_parsed_commit_range(
250  start=sha, end=tag_hash, repo=repo, gh=gh, edit=edit
251  )
252 
253  bump = evaluate_version_bump(commits)
254  print("bump:", bump)
255  if bump is None:
256  print("-> nothing to do")
257  return
258  next_version = get_new_version(current_version, bump)
259  print("next version:", next_version)
260  next_tag = f"v{next_version}"
261 
262  changes = generate_changelog(commits)
263  md = markdown_changelog(next_version, changes, header=False)
264 
265  print(md)
266 
267  if not dry_run:
268  version_file.write_text(next_version)
269  git.add(version_file)
270 
271  zenodo_file = Path(".zenodo.json")
272  update_zenodo(zenodo_file, repo, next_version)
273  git.add(zenodo_file)
274 
275  citation_file = Path("CITATION.cff")
276  update_citation(citation_file, next_version)
277  git.add(citation_file)
278 
279  git.commit(m=f"Bump to version {next_tag}")
280 
281  target_hash = str(git("rev-parse", "HEAD")).strip()
282  print("target_hash:", target_hash)
283 
284  git.push()
285 
286  commit_ok = False
287  print("Waiting for commit", target_hash[:8], "to be received")
288  for _ in range(RETRY_COUNT):
289  try:
290  url = f"/repos/{repo}/commits/{target_hash}"
291  await gh.getitem(url)
292  commit_ok = True
293  break
294  except InvalidField:
295  print("Commit", target_hash[:8], "not received yet")
296  pass # this is what we want
297  await asyncio.sleep(RETRY_INTERVAL)
298 
299  if not commit_ok:
300  print("Commit", target_hash[:8], "was not created on remote")
301  sys.exit(1)
302 
303  print("Commit", target_hash[:8], "received")
304 
305  await gh.post(
306  f"/repos/{repo}/releases",
307  data={
308  "body": md,
309  "tag_name": next_tag,
310  "name": next_tag,
311  "draft": draft,
312  "target_commitish": target_hash,
313  },
314  )
315 
316 
318  repo: str, target_branch: str, gh: GitHubAPI
319 ) -> str:
320  content = await gh.getitem(
321  f"repos/{repo}/contents/version_number?ref={target_branch}"
322  )
323  assert content["type"] == "file"
324  return base64.b64decode(content["content"]).decode("utf-8")
325 
326 
327 async def get_tag_hash(tag: str, repo: str, gh: GitHubAPI) -> str:
328  async for item in gh.getiter(f"repos/{repo}/tags"):
329  if item["name"] == tag:
330  return item["commit"]["sha"]
331  raise ValueError(f"Tag {tag} not found")
332 
333 
334 async def get_merge_commit_sha(pr: int, repo: str, gh: GitHubAPI) -> str:
335  for _ in range(RETRY_COUNT):
336  pull = await gh.getitem(f"repos/{repo}/pulls/{pr}")
337  if pull["mergeable"] is None:
338  # no merge commit yet, wait a bit
339  await asyncio.sleep(RETRY_INTERVAL)
340  continue
341  if not pull["mergeable"]:
342  raise RuntimeError("Pull request is not mergeable, can't continue")
343  return pull["merge_commit_sha"]
344  raise RuntimeError("Timeout waiting for pull request merge status")
345 
346 
347 async def get_tag(tag: str, repo: str, gh: GitHubAPI):
348  async for item in gh.getiter(f"repos/{repo}/tags"):
349  if item["name"] == tag:
350  return item
351  return None
352 
353 
354 async def get_release(tag: str, repo: str, gh: GitHubAPI):
355  existing_release = None
356  try:
357  existing_release = await gh.getitem(f"repos/{repo}/releases/tags/v{tag}")
358  except gidgethub.BadRequest as e:
359  if e.status_code == http.HTTPStatus.NOT_FOUND:
360  pass # this is what we want
361  else:
362  raise e
363  return existing_release
364 
365 
366 @app.command()
367 @make_sync
368 async def pr_action(
369  fail: bool = False,
370  pr: int = None,
371  token: Optional[str] = typer.Option(None, envvar="GH_TOKEN"),
372  repo: Optional[str] = typer.Option(None, envvar="GH_REPO"),
373 ):
374 
375  print("::group::Information")
376 
377  context = os.environ.get("GITHUB_CONTEXT")
378 
379  if context is not None:
380  context = json.loads(context)
381  repo = context["repository"]
382  token = context["token"]
383  else:
384  if token is None or repo is None:
385  raise ValueError("No context, need token and repo")
386  if pr is None:
387  raise ValueError("No context, need explicit PR to run on")
388 
389  async with aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session:
390  gh = GitHubAPI(session, __name__, oauth_token=token)
391 
392  if pr is not None:
393  pr = await gh.getitem(f"repos/{repo}/pulls/{pr}")
394  else:
395  pr = context["event"]["pull_request"]
396 
397  target_branch = pr["base"]["ref"]
398  print("Target branch:", target_branch)
399  sha = pr["head"]["sha"]
400  print("Source hash:", sha)
401 
402  merge_commit_sha = await get_merge_commit_sha(
403  pr["number"],
404  repo,
405  gh,
406  )
407  print("Merge commit sha:", merge_commit_sha)
408 
409  # Get current version from target branch
410  current_version = await get_release_branch_version(repo, target_branch, gh)
411  tag_hash = await get_tag_hash(f"v{current_version}", repo, gh)
412  print("current_version:", current_version, "[" + tag_hash[:8] + "]")
413 
414  commits, unparsed_commits = await get_parsed_commit_range(
415  start=merge_commit_sha, end=tag_hash, repo=repo, gh=gh
416  )
417 
418  bump = evaluate_version_bump(commits)
419  print("bump:", bump)
420  next_version = get_new_version(current_version, bump)
421  print("next version:", next_version)
422  next_tag = f"v{next_version}"
423 
424  print("::endgroup::")
425 
426  changes = generate_changelog(commits)
427  md = markdown_changelog(next_version, changes, header=False)
428 
429  body = ""
430  title = f"Release: {current_version} -> {next_version}"
431 
432  existing_release = await get_release(next_tag, repo, gh)
433  existing_tag = await get_tag(next_tag, repo, gh)
434 
435  body += f"# `v{current_version}` -> `v{next_version}`\n"
436 
437  exit_code = 0
438 
439  if existing_release is not None or existing_tag is not None:
440 
441  if current_version == next_version:
442  body += (
443  "## :no_entry_sign: Merging this will not result in a new version (no `fix`, "
444  "`feat` or breaking changes). I recommend **delaying** this PR until more changes accumulate.\n"
445  )
446  print("::warning::Merging this will not result in a new version")
447 
448  else:
449  exit_code = 1
450  title = f":no_entry_sign: {title}"
451  if existing_release is not None:
452  body += f"## :warning: **WARNING**: A release for '{next_tag}' already exists"
453  body += f"[here]({existing_release['html_url']})** :warning:"
454  print(f"::error::A release for tag '{next_tag}' already exists")
455  else:
456  body += (
457  f"## :warning: **WARNING**: A tag '{next_tag}' already exists"
458  )
459  print(f"::error::A tag '{next_tag}' already exists")
460 
461  body += "\n"
462  body += ":no_entry_sign: I recommend to **NOT** merge this and double check the target branch!\n\n"
463 
464  else:
465  body += f"## Merging this PR will create a new release `v{next_version}`\n"
466 
467  if len(unparsed_commits) > 0:
468  body += "\n" * 3
469  body += "## :warning: This PR contains commits which are not parseable:"
470  for commit in unparsed_commits:
471  msg, _ = commit.message.split("\n", 1)
472  body += f"\n - {msg} {commit.sha})"
473  body += "\n **Make sure these commits do not contain changes which affect the bump version!**"
474 
475  body += "\n\n"
476 
477  body += "### Changelog"
478 
479  body += md
480 
481  print("::group::PR message")
482  print(body)
483  print("::endgroup::")
484 
485  await gh.post(pr["url"], data={"body": body, "title": title})
486 
487  if fail:
488  sys.exit(exit_code)
489 
490 
491 if __name__ == "__main__":
492  app()