{ "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 datetime import datetime\n", "from textwrap import fill\n", "from urllib.request import Request,urlopen\n", "from urllib.error import HTTPError\n", "from urllib.parse import urlencode\n", "from concurrent.futures import ProcessPoolExecutor\n", "from pathlib import Path\n", "from configparser import ConfigParser\n", "import os,shutil,json,subprocess\n", "from fastrelease.fastscript 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 _issue_txt(issue):\n", " res = '- {} ([#{}]({}))\\n'.format(issue[\"title\"].strip(), issue[\"number\"], issue[\"url\"])\n", " body = issue['body']\n", " if not body: return res\n", " return res + fill(body.strip(), initial_indent=\" - \", subsequent_indent=\" \") + \"\\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 _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\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": [ "## Helper functions" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "def run_proc(*args):\n", " res = subprocess.run(args, capture_output=True)\n", " if res.returncode: raise IOError(subprocess.stdout + \";;\" + subprocess.stderr)\n", " return res.stdout" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#export\n", "def do_request(url, post=False, headers=None, **data):\n", " \"Call GET or json-encoded POST on `url`, depending on `post`\"\n", " if data:\n", " if post: data = json.dumps(data).encode('ascii')\n", " else:\n", " url += \"?\" + urlencode(data)\n", " data = None\n", " with urlopen(Request(url, headers=headers, data=data or None)) as res: return json.loads(res.read())" ] }, { "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 = _config()\n", " if not groups:\n", " default_groups=dict(breaking=\"Breaking Changes\", enhancement=\"New Features\", bug=\"Bugs Squashed\")\n", " groups=_load_json(cfg, 'label_groups') if 'label_groups' in 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", " if not token:\n", " assert Path('token').exists, \"Failed to find token\"\n", " token = Path('token').read_text().strip()\n", " self.owner,self.repo,self.token,self.groups = owner,repo,token,groups\n", " self.headers = { 'Authorization' : 'token ' + token }\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 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):\n", " with ProcessPoolExecutor() as ex: return ex.map(self._issues, self.groups.keys())\n", " \n", " def latest_release(self):\n", " \"Tag for the latest release\"\n", " 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", " fn = Path('CHANGELOG.md')\n", " if not fn.exists(): fn.write_text(\"# Release notes\\n\\n\\n\")\n", " txt = fn.read_text()\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", " res = txt.replace(marker, marker+res+\"\\n\")\n", " if debug: return res\n", " shutil.copy(fn, fn.with_suffix(\".bak\"))\n", " Path(fn).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_proc('git', 'tag', ver)\n", " run_proc('git', 'push', '--tags')\n", " run_proc('git', 'pull', '--tags')\n", " self.gh(\"releases\", post=True, tag_name=ver, name=ver, body=ver)" ] }, { "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.latest_release
[source]FastRelease.latest_release
()\n",
"\n",
"Tag for the latest release"
],
"text/plain": [
"FastRelease.gh
[source]FastRelease.gh
(**`path`**, **`complete`**=*`False`*, **`post`**=*`False`*, **\\*\\*`data`**)\n",
"\n",
"Call GitHub API `path`"
],
"text/plain": [
"