4 from typing
import List, Optional, Tuple
6 from pathlib
import Path
16 from gidgethub.aiohttp
import GitHubAPI
17 from gidgethub
import InvalidField
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
24 from dotenv
import load_dotenv
36 repo = os.environ.get(
"GITHUB_REPOSITORY",
None)
40 origin = git.remote(
"get-url",
"origin")
41 _, loc = origin.split(
":", 1)
42 repo, _ = loc.split(
".", 1)
47 raw = git.describe().split(
"-")[0]
48 m = re.match(
r"v(\d+\.\d+\.\d+)", raw)
57 def __init__(self, sha: str, message: str, author: str):
64 message = message.replace(
"\r",
"\n")
68 message = self.message.split(
"\n")[0]
69 return f
"Commit(sha='{self.sha[:8]}', message='{message}')"
72 _default_parser = angular_parser
76 commits: List[Commit], commit_parser=_default_parser
79 Adapted from: https://github.com/relekang/python-semantic-release/blob/master/semantic_release/history/logs.py#L22
86 for commit
in commits:
89 message = commit_parser(commit.message)
90 changes.append(message.bump)
91 except UnknownCommitMessageStyleError:
92 print(
"Unknown commit message style!")
99 print(f
"Unknown bump level {level}")
106 Modified from: https://github.com/relekang/python-semantic-release/blob/48972fb761ed9b0fb376fa3ad7028d65ff407ee6/semantic_release/history/logs.py#L78
108 changes: dict = {
"breaking": []}
110 for commit
in commits:
112 message: ParsedCommit = commit_parser(commit.message)
113 if message.type
not in changes:
114 changes[message.type] = list()
117 message.descriptions[0][0].
upper() + message.descriptions[0][1:]
119 changes[message.type].append((commit.sha, capital_message, commit.author))
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)
129 except UnknownCommitMessageStyleError:
130 print(
"Unknown commit message style!")
136 output = f
"## v{version}\n" if header
else ""
138 for section, items
in changelog.items():
141 output +=
"\n### {0}\n".
format(section.capitalize())
143 for sha, msg, author
in items:
144 output +=
"* {} ({}) (@{})\n".
format(msg, sha, author)
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))
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)
167 def wrapped(*args, **kwargs):
168 loop = asyncio.get_event_loop()
169 loop.run_until_complete(fn(*args, **kwargs))
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}")
183 unparsed_commits = []
186 async
for item
in commits_iter:
187 commit_hash = item[
"sha"]
188 commit_message = item[
"commit"][
"message"]
189 if commit_hash == end:
192 invalid_message =
False
196 except UnknownCommitMessageStyleError:
197 print(
"Unknown commit message style!")
198 if not commit_message.startswith(
"Merge"):
199 invalid_message =
True
201 (invalid_message
or edit)
202 and sys.stdout.isatty()
204 and typer.confirm(f
"Edit effective message '{commit_message}'?")
206 commit_message = typer.edit(commit_message)
209 commit =
Commit(commit_hash, commit_message, item[
"author"][
"login"])
210 commits.append(commit)
213 unparsed_commits.append(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:
221 "BadRequest for commit retrieval. That is most likely because you forgot to push the merge commit."
229 token: str = typer.Argument(..., envvar=
"GH_TOKEN"),
231 dry_run: bool =
False,
234 async with aiohttp.ClientSession(loop=asyncio.get_event_loop())
as session:
235 gh = GitHubAPI(session, __name__, oauth_token=token)
237 version_file = Path(
"version_number")
238 current_version = version_file.read_text()
240 tag_hash =
str(
git(
"rev-list",
"-n",
"1", f
"v{current_version}").strip())
241 print(
"current_version:", current_version,
"[" + tag_hash[:8] +
"]")
243 sha =
git(
"rev-parse",
"HEAD").strip()
250 start=sha, end=tag_hash, repo=repo, gh=gh, edit=edit
256 print(
"-> nothing to do")
258 next_version = get_new_version(current_version, bump)
259 print(
"next version:", next_version)
260 next_tag = f
"v{next_version}"
268 version_file.write_text(next_version)
269 git.add(version_file)
271 zenodo_file = Path(
".zenodo.json")
275 citation_file = Path(
"CITATION.cff")
277 git.add(citation_file)
279 git.commit(m=f
"Bump to version {next_tag}")
281 target_hash =
str(
git(
"rev-parse",
"HEAD")).strip()
282 print(
"target_hash:", target_hash)
287 print(
"Waiting for commit", target_hash[:8],
"to be received")
288 for _
in range(RETRY_COUNT):
290 url = f
"/repos/{repo}/commits/{target_hash}"
291 await gh.getitem(url)
295 print(
"Commit", target_hash[:8],
"not received yet")
297 await asyncio.sleep(RETRY_INTERVAL)
300 print(
"Commit", target_hash[:8],
"was not created on remote")
303 print(
"Commit", target_hash[:8],
"received")
306 f
"/repos/{repo}/releases",
309 "tag_name": next_tag,
312 "target_commitish": target_hash,
318 repo: str, target_branch: str, gh: GitHubAPI
320 content = await gh.getitem(
321 f
"repos/{repo}/contents/version_number?ref={target_branch}"
323 assert content[
"type"] ==
"file"
324 return base64.b64decode(content[
"content"]).decode(
"utf-8")
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")
335 for _
in range(RETRY_COUNT):
336 pull = await gh.getitem(f
"repos/{repo}/pulls/{pr}")
337 if pull[
"mergeable"]
is None:
339 await asyncio.sleep(RETRY_INTERVAL)
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")
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:
355 existing_release =
None
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:
363 return existing_release
371 token: Optional[str] = typer.Option(
None, envvar=
"GH_TOKEN"),
372 repo: Optional[str] = typer.Option(
None, envvar=
"GH_REPO"),
375 print(
"::group::Information")
377 context = os.environ.get(
"GITHUB_CONTEXT")
379 if context
is not None:
380 context = json.loads(context)
381 repo = context[
"repository"]
382 token = context[
"token"]
384 if token
is None or repo
is None:
385 raise ValueError(
"No context, need token and repo")
387 raise ValueError(
"No context, need explicit PR to run on")
389 async with aiohttp.ClientSession(loop=asyncio.get_event_loop())
as session:
390 gh = GitHubAPI(session, __name__, oauth_token=token)
393 pr = await gh.getitem(f
"repos/{repo}/pulls/{pr}")
395 pr = context[
"event"][
"pull_request"]
397 target_branch = pr[
"base"][
"ref"]
398 print(
"Target branch:", target_branch)
399 sha = pr[
"head"][
"sha"]
400 print(
"Source hash:", sha)
407 print(
"Merge commit sha:", merge_commit_sha)
411 tag_hash = await
get_tag_hash(f
"v{current_version}", repo, gh)
412 print(
"current_version:", current_version,
"[" + tag_hash[:8] +
"]")
415 start=merge_commit_sha, end=tag_hash, repo=repo, gh=gh
420 next_version = get_new_version(current_version, bump)
421 print(
"next version:", next_version)
422 next_tag = f
"v{next_version}"
424 print(
"::endgroup::")
430 title = f
"Release: {current_version} -> {next_version}"
432 existing_release = await
get_release(next_tag, repo, gh)
433 existing_tag = await
get_tag(next_tag, repo, gh)
435 body += f
"# `v{current_version}` -> `v{next_version}`\n"
439 if existing_release
is not None or existing_tag
is not None:
441 if current_version == next_version:
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"
446 print(
"::warning::Merging this will not result in a new version")
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")
457 f
"## :warning: **WARNING**: A tag '{next_tag}' already exists"
459 print(f
"::error::A tag '{next_tag}' already exists")
462 body +=
":no_entry_sign: I recommend to **NOT** merge this and double check the target branch!\n\n"
465 body += f
"## Merging this PR will create a new release `v{next_version}`\n"
467 if len(unparsed_commits) > 0:
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!**"
477 body +=
"### Changelog"
481 print(
"::group::PR message")
483 print(
"::endgroup::")
485 await gh.post(pr[
"url"], data={
"body": body,
"title": title})
491 if __name__ ==
"__main__":