{ "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", "from fastcore.script import *\n", "from ghapi.core import *\n", "\n", "from datetime import datetime\n", "from configparser import ConfigParser\n", "import shutil,subprocess" ] }, { "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", " owner,repo = owner or self.cfg['user'], repo or 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.gh = GhApi(owner, repo, token)\n", " self.groups = groups\n", "\n", " def _issues(self, label):\n", " return self.gh.issues.list_for_repo(state='closed', sort='created', filter='all', since=self.commit_date, labels=label)\n", " def _issue_groups(self): return parallel(self._issues, self.groups.keys(), progress=False)\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: self.commit_date = self.gh.repos.get_latest_release().published_at\n", " except HTTP404NotFoundError: 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", " run(f'git add {self.changefile}')\n", "\n", " def release(self):\n", " \"Tag and create a release in GitHub for the current version\"\n", " ver = self.cfg['version']\n", " notes = self.latest_notes()\n", " self.gh.create_release(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 '\\n'.join(its[1].splitlines()[1:]).strip()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To create a markdown changelog, first create a `FastRelease` object, optionally 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. If not specified, this defaults to:\n", "\n", "```python\n", "{\"breaking\": \"Breaking Changes\", \"enhancement\":\"New Features\", \"bug\":\"Bugs Squashed\"}\n", "```" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "rel = FastRelease()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "

FastRelease.changelog[source]

\n", "\n", "> FastRelease.changelog(**`debug`**=*`False`*)\n", "\n", "Create the CHANGELOG.md file, or return the proposed text if `debug` is `True`" ], "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "show_doc(FastRelease.changelog)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "All relevant pull requests and issues are fetched from the GitHub API, and are categorized according to a user-supplied mapping from labels to markdown headings." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# print(rel.changelog(debug=True))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "

FastRelease.release[source]

\n", "\n", "> FastRelease.release()\n", "\n", "Tag and create a release in GitHub for the current version" ], "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "show_doc(FastRelease.release)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This uses the version information from your `settings.ini`." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## CLI functions" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "@call_parse\n", "def fastrelease_changelog(debug:Param(\"Print info to be added to CHANGELOG, instead of updating file\", store_true)=False):\n", " \"Create a CHANGELOG.md file from closed and labeled GitHub issues\"\n", " FastRelease().changelog(debug=debug)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "@call_parse\n", "def fastrelease_release(token:Param(\"Optional GitHub token (otherwise `token` file is used)\", str)=None):\n", " \"Tag and create a release in GitHub for the current version\"\n", " ver = FastRelease(token=token).release()\n", " print(f\"Released {ver}\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "@call_parse\n", "def fastrelease(debug:Param(\"Print info to be added to CHANGELOG, instead of updating file\", store_true)=False,\n", " token:Param(\"Optional GitHub token (otherwise `token` file is used)\", str)=None):\n", " \"Calls `fastrelease_changelog`, lets you edit the result, then pushes to git and calls `fastrelease_release`\"\n", " cfg,cfg_path = find_config()\n", " FastRelease().changelog()\n", " if debug: return\n", " subprocess.run([os.environ.get('EDITOR','nano'), cfg_path/'CHANGELOG.md'])\n", " if not input(\"Make release now? (y/n) \").lower().startswith('y'): sys.exit(1)\n", " run('git commit -am release')\n", " run('git push')\n", " ver = FastRelease(token=token).release()\n", " print(f\"Released {ver}\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "def bump_version(version, part=2):\n", " version = version.split('.')\n", " version[part] = str(int(version[part]) + 1)\n", " for i in range(part+1, 3): version[i] = '0'\n", " return '.'.join(version)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "test_eq(bump_version('0.1.1' ), '0.1.2')\n", "test_eq(bump_version('0.1.1', 1), '0.2.0')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "@call_parse\n", "def fastrelease_bump_version(part:Param(\"Part of version to bump\", int)=2):\n", " \"Increment version in `settings.py` by one\"\n", " cfg = Config()\n", " print(f'Old version: {cfg.version}')\n", " cfg.d['version'] = bump_version(Config().version, part)\n", " cfg.save()\n", " print(f'New version: {cfg.version}')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Export-" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Converted 00_core.ipynb.\n", "Converted 01_conda.ipynb.\n", "Converted index.ipynb.\n" ] } ], "source": [ "#hide\n", "from nbdev.export import notebook2script\n", "notebook2script()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" } }, "nbformat": 4, "nbformat_minor": 4 }