{ "cells": [ { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#hide\n", "# default_exp core" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Create software releases\n", "\n", "> API for auto-generated tagged releases, and release notes (from GitHub issues)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "from fastcore.imports import *\n", "from fastcore.utils import *\n", "from fastcore.foundation import *\n", "\n", "from datetime import datetime\n", "from configparser import ConfigParser\n", "import json,subprocess,shutil\n", "from urllib.request import HTTPError\n", "from fastcore.script import *" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#hide\n", "from nbdev.showdoc import show_doc" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "GH_HOST = \"https://api.github.com\"" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "def find_config(cfg_name=\"settings.ini\"):\n", " cfg_path = Path().absolute()\n", " while cfg_path != cfg_path.parent and not (cfg_path/cfg_name).exists(): cfg_path = cfg_path.parent\n", " config_file = cfg_path/cfg_name\n", " assert config_file.exists(), f\"Couldn't find {cfg_name}\"\n", " config = ConfigParser()\n", " config.read(config_file)\n", " return config['DEFAULT'],cfg_path" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "def _issue_txt(issue):\n", " res = '- {} ([#{}]({}))'.format(issue.title.strip(), issue.number, issue.html_url)\n", " if hasattr(issue, 'pull_request'): res += ', thanks to [@{}]({})'.format(issue.user.login, issue.user.html_url)\n", " res += '\\n'\n", " if not issue.body: return res\n", " return res + f\" - {issue.body.strip()}\\n\"\n", "\n", "def _issues_txt(iss, label):\n", " if not iss: return ''\n", " res = f\"### {label}\\n\\n\"\n", " return res + '\\n'.join(map(_issue_txt, iss))\n", "\n", "def _load_json(cfg, k):\n", " try: return json.loads(cfg[k])\n", " except json.JSONDecodeError as e: raise Exception(f\"Key: `{k}` in .ini file is not a valid JSON string: {e}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## FastRelease -" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "class FastRelease:\n", " def __init__(self, owner=None, repo=None, token=None, **groups):\n", " \"Create CHANGELOG.md from GitHub issues\"\n", " self.cfg,cfg_path = find_config()\n", " self.changefile = cfg_path/'CHANGELOG.md'\n", " if not groups:\n", " default_groups=dict(breaking=\"Breaking Changes\", enhancement=\"New Features\", bug=\"Bugs Squashed\")\n", " groups=_load_json(self.cfg, 'label_groups') if 'label_groups' in self.cfg else default_groups\n", " os.chdir(cfg_path)\n", " if not owner: owner = self.cfg['user']\n", " if not repo: repo = self.cfg['lib_name']\n", " token = ifnone(token, os.getenv('FASTRELEASE_TOKEN',None))\n", " if not token and Path('token').exists(): token = Path('token').read_text().strip()\n", " if not token: raise Exception('Failed to find token')\n", " self.headers = { 'Authorization' : 'token ' + token }\n", " self.owner,self.repo,self.groups = owner,repo,groups\n", " self.repo_url = f\"{GH_HOST}/repos/{owner}/{repo}\"\n", "\n", " def gh(self, path, complete=False, post=False, **data):\n", " \"Call GitHub API `path`\"\n", " if not complete: path = f\"{self.repo_url}/{path}\"\n", " return dict2obj(do_request(path, headers=self.headers, post=post, **data))\n", "\n", " def _tag_date(self, tag):\n", " try: tag_d = self.gh(f\"git/ref/tags/{tag}\")\n", " except HTTPError: raise Exception(f\"Failed to find tag {tag}\")\n", " commit_d = self.gh(tag_d.object.url, complete=True)\n", " self.commit_date = commit_d.committer.date\n", " return self.commit_date\n", "\n", " def _issues(self, label):\n", " return self.gh(\"issues\", state='closed', sort='created', filter='all',\n", " since=self.commit_date, labels=label)\n", " \n", " def _issue_groups(self): return parallel(self._issues, self.groups.keys(), progress=False)\n", " def _latest_release(self): return self.gh(\"releases/latest\").tag_name\n", "\n", " def changelog(self, debug=False):\n", " \"Create the CHANGELOG.md file, or return the proposed text if `debug` is `True`\"\n", " if not self.changefile.exists(): self.changefile.write_text(\"# Release notes\\n\\n\\n\")\n", " marker = '\\n'\n", " try:\n", " latest = self._latest_release()\n", " self._tag_date(latest)\n", " except HTTPError: # no prior releases\n", " self.commit_date = '2000-01-01T00:00:004Z'\n", " res = f\"\\n## {self.cfg['version']}\\n\"\n", " issues = self._issue_groups()\n", " res += '\\n'.join(_issues_txt(*o) for o in zip(issues, self.groups.values()))\n", " if debug: return res\n", " res = self.changefile.read_text().replace(marker, marker+res+\"\\n\")\n", " shutil.copy(self.changefile, self.changefile.with_suffix(\".bak\"))\n", " self.changefile.write_text(res)\n", "\n", " def release(self):\n", " \"Tag and create a release in GitHub for the current version\"\n", " ver = self.cfg['version']\n", " run(f'git tag {ver}')\n", " run('git push --tags')\n", " run('git pull --tags')\n", " notes = self.latest_notes()\n", " if not notes.startswith(ver): notes = ''\n", " self.gh(\"releases\", post=True, tag_name=ver, name=ver, body=notes)\n", " return ver\n", " \n", " def latest_notes(self):\n", " \"Latest CHANGELOG entry\"\n", " if not self.changefile.exists(): return ''\n", " its = re.split(r'^## ', self.changefile.read_text(), flags=re.MULTILINE)\n", " if not len(its)>0: return ''\n", " return its[1].strip()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To create a markdown changelog, first create a `FastRelease` object, passing a mapping from GitHub labels to markdown titles. Put your github token in a file named `token` at the root of your repo. `FastRelease` attempts to fetch values for arguments from the following locations if not supplied:\n", "\n", "- **owner:** fetched from the field `user` in `settings.ini`. This is the owner name of the repository on GitHub. For example for the repo `fastai/fastcore` the owner would be `fastai`.\n", "- **repo:** fetched from the field `lib_name` in `settings.ini`. This is the name of the repository on GitHub. For example for the repo `fastai/fastcore` the owner would be `fastcore`.\n", "- **token:** fetched from a file named `token` at the root of your repo. Creating a token is discussed in [the setup](https://fastrelease.fast.ai/#Set-up) section.\n", "- **groups:** (optional) fetched from the field `label_groups` in `settings.ini`, which is a JSON string. This is a mapping from label names to titles in your release notes. For example, this is a valid way of setting your label_groups:\n", "\n", " ```python\n", " label_gr{\"breaking\": \"Breaking Changes\", \n", " \"enhancement\":\"New Features\", \n", " \"bug\":\"Bugs Squashed\"}\n", " ```\n", "\n", " \n", " If not specified, this defaults to:\n", " ```python\n", " {\"breaking\": \"Breaking Changes\", \n", " \"enhancement\":\"New Features\", \n", " \"bug\":\"Bugs Squashed\"}\n", " ```" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "
FastRelease.changelog
[source]FastRelease.changelog
(**`debug`**=*`False`*)\n",
"\n",
"Create the CHANGELOG.md file, or return the proposed text if `debug` is `True`"
],
"text/plain": [
"FastRelease.release
[source]FastRelease.release
()\n",
"\n",
"Tag and create a release in GitHub for the current version"
],
"text/plain": [
"FastRelease.gh
[source]FastRelease.gh
(**`path`**, **`complete`**=*`False`*, **`post`**=*`False`*, **\\*\\*`data`**)\n",
"\n",
"Call GitHub API `path`"
],
"text/plain": [
"