File indexing completed on 2025-08-03 08:09:26
0001
0002 import os
0003 import asyncio
0004 from typing import List, Optional, Tuple
0005 from pathlib import Path
0006 import sys
0007 import http
0008 import json
0009 import yaml
0010 import datetime
0011 import typer
0012 import base64
0013
0014 import aiohttp
0015 from gidgethub.aiohttp import GitHubAPI
0016 from gidgethub import InvalidField
0017 from semantic_release.enums import LevelBump
0018 from semantic_release.version import Version
0019 from semantic_release.commit_parser.angular import (
0020 AngularCommitParser,
0021 AngularParserOptions,
0022 )
0023 from semantic_release.commit_parser.token import ParseError, ParseResult
0024 import gidgethub
0025 import sh
0026 from dotenv import load_dotenv
0027 import functools
0028
0029 load_dotenv()
0030
0031 git = sh.git
0032
0033 RETRY_COUNT = 10
0034 RETRY_INTERVAL = 0.5
0035
0036
0037 def get_repo():
0038 repo = os.environ.get("GITHUB_REPOSITORY", None)
0039 if repo is not None:
0040 return repo
0041
0042 origin = git.remote("get-url", "origin")
0043 _, loc = origin.split(":", 1)
0044 repo, _ = loc.split(".", 1)
0045 return repo
0046
0047
0048 class Commit:
0049 sha: str
0050 message: str
0051 author: str
0052
0053 def __init__(self, sha: str, message: str, author: str):
0054 self.sha = sha
0055 self.message = self._normalize(message)
0056 self.author = author
0057
0058 @staticmethod
0059 def _normalize(message):
0060 message = message.replace("\r", "\n")
0061 return message
0062
0063 def __str__(self):
0064 message = self.message.split("\n")[0]
0065 return f"Commit(sha='{self.sha[:8]}', message='{message}')"
0066
0067
0068 @property
0069 def hexsha(self):
0070 return self.sha
0071
0072
0073 _default_parser = AngularCommitParser(AngularParserOptions())
0074
0075
0076 def evaluate_version_bump(
0077 commits: List[Commit], commit_parser=_default_parser
0078 ) -> Optional[str]:
0079 """
0080 Adapted from: https://github.com/relekang/python-semantic-release/blob/master/semantic_release/history/logs.py#L22
0081 """
0082 bump = None
0083
0084 changes = []
0085 commit_count = 0
0086
0087 for commit in commits:
0088 commit_count += 1
0089
0090 message: ParseResult = commit_parser.parse(commit)
0091 if isinstance(message, ParseError):
0092 print("Unknown commit message style!")
0093 else:
0094 changes.append(message.bump)
0095
0096 if changes:
0097 level = max(changes)
0098 if level in LevelBump:
0099 bump = level
0100 else:
0101 print(f"Unknown bump level {level}")
0102
0103 return bump
0104
0105
0106 def generate_changelog(commits, commit_parser=_default_parser) -> dict:
0107 """
0108 Modified from: https://github.com/relekang/python-semantic-release/blob/48972fb761ed9b0fb376fa3ad7028d65ff407ee6/semantic_release/history/logs.py#L78
0109 """
0110 changes: dict = {"breaking": []}
0111
0112 for commit in commits:
0113 message: ParseResult = commit_parser.parse(commit)
0114
0115 if isinstance(message, ParseError):
0116 print("Unknown commit message style!")
0117 continue
0118
0119 if message.type not in changes:
0120 changes[message.type] = list()
0121
0122 capital_message = (
0123 message.descriptions[0][0].upper() + message.descriptions[0][1:]
0124 )
0125 changes[message.type].append((commit.sha, capital_message, commit.author))
0126
0127 return changes
0128
0129
0130 def markdown_changelog(version: str, changelog: dict, header: bool = False) -> str:
0131 output = f"## v{version}\n" if header else ""
0132
0133 for section, items in changelog.items():
0134 if len(items) == 0:
0135 continue
0136 output += "\n### {0}\n".format(section.capitalize())
0137
0138 for sha, msg, author in items:
0139 output += "* {} ({}) (@{})\n".format(msg, sha, author)
0140
0141 return output
0142
0143
0144 def update_zenodo(zenodo_file: Path, repo: str, next_version):
0145 data = json.loads(zenodo_file.read_text())
0146 data["title"] = f"{repo}: v{next_version}"
0147 data["version"] = f"v{next_version}"
0148 zenodo_file.write_text(json.dumps(data, indent=2))
0149
0150
0151 def update_citation(citation_file: Path, next_version):
0152 with citation_file.open() as fh:
0153 data = yaml.safe_load(fh)
0154 data["version"] = f"v{next_version}"
0155 data["date-released"] = datetime.date.today().strftime("%Y-%m-%d")
0156 with citation_file.open("w") as fh:
0157 yaml.dump(data, fh, indent=2)
0158
0159
0160 def make_sync(fn):
0161 @functools.wraps(fn)
0162 def wrapped(*args, **kwargs):
0163 loop = asyncio.get_event_loop()
0164 loop.run_until_complete(fn(*args, **kwargs))
0165
0166 return wrapped
0167
0168
0169 app = typer.Typer()
0170
0171
0172 async def get_parsed_commit_range(
0173 start: str, end: str, repo: str, gh: GitHubAPI, edit: bool = False
0174 ) -> Tuple[List[Commit], List[Commit]]:
0175 commits_iter = gh.getiter(f"/repos/{repo}/commits?sha={start}")
0176
0177 commits = []
0178 unparsed_commits = []
0179
0180 try:
0181 async for item in commits_iter:
0182 commit_hash = item["sha"]
0183 commit_message = item["commit"]["message"]
0184 if commit_hash == end:
0185 break
0186
0187 commit = Commit(commit_hash, commit_message, item["author"]["login"])
0188
0189 invalid_message = False
0190 message: ParseResult = _default_parser.parse(commit)
0191
0192 if isinstance(message, ParseError):
0193 print("Unknown commit message style!")
0194 if not commit_message.startswith("Merge"):
0195 invalid_message = True
0196
0197 if (
0198 (invalid_message or edit)
0199 and sys.stdout.isatty()
0200 and False
0201 and typer.confirm(f"Edit effective message '{commit_message}'?")
0202 ):
0203 commit_message = typer.edit(commit_message)
0204 _default_parser(commit_message)
0205
0206 commits.append(commit)
0207
0208 if invalid_message:
0209 unparsed_commits.append(commit)
0210
0211 print("-", commit)
0212 if len(commits) > 200:
0213 raise RuntimeError(f"{len(commits)} are a lot. Aborting!")
0214 return commits, unparsed_commits
0215 except gidgethub.BadRequest:
0216 print(
0217 "BadRequest for commit retrieval. That is most likely because you forgot to push the merge commit."
0218 )
0219 return
0220
0221
0222 @app.command()
0223 @make_sync
0224 async def make_release(
0225 token: str = typer.Argument(..., envvar="GH_TOKEN"),
0226 force_next_version: Optional[str] = typer.Option(None, "--next-version"),
0227 draft: bool = True,
0228 dry_run: bool = False,
0229 edit: bool = False,
0230 ):
0231 async with aiohttp.ClientSession(loop=asyncio.get_event_loop()) as session:
0232 gh = GitHubAPI(session, __name__, oauth_token=token)
0233
0234 version_file = Path("version_number")
0235 current_version = version_file.read_text()
0236
0237 tag_hash = str(git("rev-list", "-n", "1", f"v{current_version}").strip())
0238 print("current_version:", current_version, "[" + tag_hash[:8] + "]")
0239
0240 sha = git("rev-parse", "HEAD").strip()
0241 print("sha:", sha)
0242
0243 repo = get_repo()
0244 print("repo:", repo)
0245
0246 commits, _ = await get_parsed_commit_range(
0247 start=sha, end=tag_hash, repo=repo, gh=gh, edit=edit
0248 )
0249
0250 bump = evaluate_version_bump(commits)
0251 print("bump:", bump)
0252 if bump is None:
0253 print("-> nothing to do")
0254 return
0255
0256 current_version_obj = Version(*(map(int, current_version.split("."))))
0257 next_version_obj = current_version_obj.bump(bump)
0258 next_version = f"{next_version_obj.major}.{next_version_obj.minor}.{next_version_obj.patch}"
0259 if force_next_version is not None:
0260 next_version = force_next_version
0261 print("next version:", next_version)
0262 next_tag = f"v{next_version}"
0263
0264 changes = generate_changelog(commits)
0265 md = markdown_changelog(next_version, changes, header=False)
0266
0267 print(md)
0268
0269 if not dry_run:
0270 execute_bump(next_version)
0271
0272 git.commit(m=f"Bump to version {next_tag}", no_verify=True)
0273
0274 target_hash = str(git("rev-parse", "HEAD")).strip()
0275 print("target_hash:", target_hash)
0276
0277 git.push()
0278
0279 commit_ok = False
0280 print("Waiting for commit", target_hash[:8], "to be received")
0281 for _ in range(RETRY_COUNT):
0282 try:
0283 url = f"/repos/{repo}/commits/{target_hash}"
0284 await gh.getitem(url)
0285 commit_ok = True
0286 break
0287 except InvalidField:
0288 print("Commit", target_hash[:8], "not received yet")
0289 pass
0290 await asyncio.sleep(RETRY_INTERVAL)
0291
0292 if not commit_ok:
0293 print("Commit", target_hash[:8], "was not created on remote")
0294 sys.exit(1)
0295
0296 print("Commit", target_hash[:8], "received")
0297
0298 await gh.post(
0299 f"/repos/{repo}/releases",
0300 data={
0301 "body": md,
0302 "tag_name": next_tag,
0303 "name": next_tag,
0304 "draft": draft,
0305 "target_commitish": target_hash,
0306 },
0307 )
0308
0309
0310 def execute_bump(next_version: str):
0311 version_file = Path("version_number")
0312
0313 version_file.write_text(next_version)
0314 git.add(version_file)
0315
0316 repo = get_repo()
0317 zenodo_file = Path(".zenodo.json")
0318 update_zenodo(zenodo_file, repo, next_version)
0319 git.add(zenodo_file)
0320
0321 citation_file = Path("CITATION.cff")
0322 update_citation(citation_file, next_version)
0323 git.add(citation_file)
0324
0325
0326 @app.command()
0327 def bump(
0328 next_version: str = typer.Argument(..., help="Format: X.Y.Z"), commit: bool = False
0329 ):
0330 execute_bump(next_version)
0331 next_tag = f"v{next_version}"
0332
0333 if commit:
0334 git.commit(m=f"Bump to version {next_tag}", no_verify=True)
0335
0336
0337 async def get_release_branch_version(
0338 repo: str, target_branch: str, gh: GitHubAPI
0339 ) -> str:
0340 content = await gh.getitem(
0341 f"repos/{repo}/contents/version_number?ref={target_branch}"
0342 )
0343 assert content["type"] == "file"
0344 return base64.b64decode(content["content"]).decode("utf-8")
0345
0346
0347 async def get_tag_hash(tag: str, repo: str, gh: GitHubAPI) -> str:
0348 async for item in gh.getiter(f"repos/{repo}/tags"):
0349 if item["name"] == tag:
0350 return item["commit"]["sha"]
0351 raise ValueError(f"Tag {tag} not found")
0352
0353
0354 async def get_merge_commit_sha(pr: int, repo: str, gh: GitHubAPI) -> str:
0355 for _ in range(RETRY_COUNT):
0356 pull = await gh.getitem(f"repos/{repo}/pulls/{pr}")
0357 if pull["mergeable"] is None:
0358
0359 await asyncio.sleep(RETRY_INTERVAL)
0360 continue
0361 if not pull["mergeable"]:
0362 raise RuntimeError("Pull request is not mergeable, can't continue")
0363 return pull["merge_commit_sha"]
0364 raise RuntimeError("Timeout waiting for pull request merge status")
0365
0366
0367 async def get_tag(tag: str, repo: str, gh: GitHubAPI):
0368 async for item in gh.getiter(f"repos/{repo}/tags"):
0369 if item["name"] == tag:
0370 return item
0371 return None
0372
0373
0374 async def get_release(tag: str, repo: str, gh: GitHubAPI):
0375 existing_release = None
0376 try:
0377 existing_release = await gh.getitem(f"repos/{repo}/releases/tags/v{tag}")
0378 except gidgethub.BadRequest as e:
0379 if e.status_code == http.HTTPStatus.NOT_FOUND:
0380 pass
0381 else:
0382 raise e
0383 return existing_release
0384
0385
0386 if __name__ == "__main__":
0387 app()