]> source.dussan.org Git - gitblit.git/commitdiff
Add deployment of a release to GitHub
authorFlorian Zschocke <florian.zschocke@devolo.de>
Sun, 26 Jan 2020 15:47:44 +0000 (16:47 +0100)
committerFlorian Zschocke <florian.zschocke@devolo.de>
Mon, 27 Jan 2020 20:48:20 +0000 (21:48 +0100)
Add Ant tasks and macros to deploy binaries to GitHub,
using GitHub's releases.

Adds an Awk script to extract GH flavoured markdown release notes
from the release.moxie file.

Adds `ok.sh` to the repository so that it is readily available.
This is a Bourne shell GitHub API client, used to create a release
on GitHub and upload the binaries.

.github/ok.sh [new file with mode: 0755]
build.xml
src/site/templates/ghreleasenotes.awk [new file with mode: 0755]

diff --git a/.github/ok.sh b/.github/ok.sh
new file mode 100755 (executable)
index 0000000..6ac429d
--- /dev/null
@@ -0,0 +1,2560 @@
+#!/usr/bin/env sh
+# # A GitHub API client library written in POSIX sh
+#
+# https://github.com/whiteinge/ok.sh
+# BSD licensed.
+#
+# ## Requirements
+#
+# * A POSIX environment (tested against Busybox v1.19.4)
+# * curl (tested against 7.32.0)
+#
+# ## Optional requirements
+#
+# * jq <http://stedolan.github.io/jq/> (tested against 1.3)
+#   If jq is not installed commands will output raw JSON; if jq is installed
+#   the output will be formatted and filtered for use with other shell tools.
+#
+# ## Setup
+#
+# Authentication credentials are read from a `$HOME/.netrc` file on UNIX
+# machines or a `_netrc` file in `%HOME%` for UNIX environments under Windows.
+# [Generate the token on GitHub](https://github.com/settings/tokens) under
+# "Account Settings -> Applications".
+# Restrict permissions on that file with `chmod 600 ~/.netrc`!
+#
+#     machine api.github.com
+#         login <username>
+#         password <token>
+#
+#     machine uploads.github.com
+#         login <username>
+#         password <token>
+#
+# Or set an environment `GITHUB_TOKEN=token`
+#
+# ## Configuration
+#
+# The following environment variables may be set to customize ${NAME}.
+#
+# * OK_SH_URL=${OK_SH_URL}
+#   Base URL for GitHub or GitHub Enterprise.
+# * OK_SH_ACCEPT=${OK_SH_ACCEPT}
+#   The 'Accept' header to send with each request.
+# * OK_SH_JQ_BIN=${OK_SH_JQ_BIN}
+#   The name of the jq binary, if installed.
+# * OK_SH_VERBOSE=${OK_SH_VERBOSE}
+#   The debug logging verbosity level. Same as the verbose flag.
+# * OK_SH_RATE_LIMIT=${OK_SH_RATE_LIMIT}
+#   Output current GitHub rate limit information to stderr.
+# * OK_SH_DESTRUCTIVE=${OK_SH_DESTRUCTIVE}
+#   Allow destructive operations without prompting for confirmation.
+# * OK_SH_MARKDOWN=${OK_SH_MARKDOWN}
+#   Output some text in Markdown format.
+
+export NAME=$(basename "$0")
+export VERSION='0.5.1'
+
+export OK_SH_URL=${OK_SH_URL:-'https://api.github.com'}
+export OK_SH_ACCEPT=${OK_SH_ACCEPT:-'application/vnd.github.v3+json'}
+export OK_SH_JQ_BIN="${OK_SH_JQ_BIN:-jq}"
+export OK_SH_VERBOSE="${OK_SH_VERBOSE:-0}"
+export OK_SH_RATE_LIMIT="${OK_SH_RATE_LIMIT:-0}"
+export OK_SH_DESTRUCTIVE="${OK_SH_DESTRUCTIVE:-0}"
+export OK_SH_MARKDOWN="${OK_SH_MARKDOWN:-0}"
+
+# Detect if jq is installed.
+command -v "$OK_SH_JQ_BIN" 1>/dev/null 2>/dev/null
+NO_JQ=$?
+
+# Customizable logging output.
+exec 4>/dev/null
+exec 5>/dev/null
+exec 6>/dev/null
+export LINFO=4      # Info-level log messages.
+export LDEBUG=5     # Debug-level log messages.
+export LSUMMARY=6   # Summary output.
+
+# Generate a carriage return so we can match on it.
+# Using a variable because these are tough to specify in a portable way.
+crlf=$(printf '\r\n')
+
+# ## Main
+# Generic functions not necessarily specific to working with GitHub.
+
+# ### Help
+# Functions for fetching and formatting help text.
+
+ _cols() {
+    sort | awk '
+        { w[NR] = $0 }
+        END {
+            cols = 3
+            per_col = sprintf("%.f", NR / cols + 0.5)  # Round up if decimal.
+
+            for (i = 1; i < per_col + 1; i += 1) {
+                for (j = 0; j < cols; j += 1) {
+                    printf("%-24s", w[i + per_col * j])
+                }
+                printf("\n")
+            }
+        }
+    '
+ }
+ _links() { awk '{ print "* [" $0 "](#" $0 ")" }'; }
+ _funcsfmt() { if [ "$OK_SH_MARKDOWN" -eq 0 ]; then _cols; else _links; fi; }
+
+help() {
+    # Output the help text for a command
+    #
+    # Usage:
+    #
+    #     help commandname
+    #
+    # Positional arguments
+    #
+    local fname="$1"
+    #   Function name to search for; if omitted searches whole file.
+
+    # Short-circuit if only producing help for a single function.
+    if [ $# -gt 0 ]; then
+        awk -v fname="^$fname\\\(\\\) \\\{$" '$0 ~ fname, /^}/ { print }' "$0" \
+            | _helptext
+        return
+    fi
+
+    _helptext < "$0"
+    printf '\n'
+    help __main
+    printf '\n'
+
+    printf '## Table of Contents\n'
+    printf '\n### Utility and request/response commands\n\n'
+    _all_funcs public=0 | _funcsfmt
+    printf '\n### GitHub commands\n\n'
+    _all_funcs private=0 | _funcsfmt
+    printf '\n## Commands\n\n'
+
+    for cmd in $(_all_funcs public=0); do
+        printf '### %s\n\n' "$cmd"
+        help "$cmd"
+        printf '\n'
+    done
+
+    for cmd in $(_all_funcs private=0); do
+        printf '### %s\n\n' "$cmd"
+        help "$cmd"
+        printf '\n'
+    done
+}
+
+_all_funcs() {
+    # List all functions found in the current file in the order they appear
+    #
+    # Keyword arguments
+    #
+    local public=1
+    #   `0` do not output public functions.
+    local private=1
+    #   `0` do not output private functions.
+
+    for arg in "$@"; do
+        case $arg in
+            (public=*) public="${arg#*=}";;
+            (private=*) private="${arg#*=}";;
+        esac
+    done
+
+    awk -v public="$public" -v private="$private" '
+        $1 !~ /^__/ && /^[a-zA-Z0-9_]+\s*\(\)/ {
+            sub(/\(\)$/, "", $1)
+            if (!public && substr($1, 1, 1) != "_") next
+            if (!private && substr($1, 1, 1) == "_") next
+            print $1
+        }
+    ' "$0"
+}
+
+__main() {
+    # ## Usage
+    #
+    # `${NAME} [<flags>] (command [<arg>, <name=value>...])`
+    #
+    #     ${NAME} -h              # Short, usage help text.
+    #     ${NAME} help            # All help text. Warning: long!
+    #     ${NAME} help command    # Command-specific help text.
+    #     ${NAME} command         # Run a command with and without args.
+    #     ${NAME} command foo bar baz=Baz qux='Qux arg here'
+    #
+    # Flag | Description
+    # ---- | -----------
+    # -V   | Show version.
+    # -h   | Show this screen.
+    # -j   | Output raw JSON; don't process with jq.
+    # -q   | Quiet; don't print to stdout.
+    # -r   | Print current GitHub API rate limit to stderr.
+    # -v   | Logging output; specify multiple times: info, debug, trace.
+    # -x   | Enable xtrace debug logging.
+    # -y   | Answer 'yes' to any prompts.
+    #
+    # Flags _must_ be the first argument to `${NAME}`, before `command`.
+
+    local cmd
+    local ret
+    local opt
+    local OPTARG
+    local OPTIND
+    local quiet=0
+    local temp_dir="${TMPDIR-/tmp}/${NAME}.${$}.$(awk \
+        'BEGIN {srand(); printf "%d\n", rand() * 10^10}')"
+    local summary_fifo="${temp_dir}/oksh_summary.fifo"
+
+    # shellcheck disable=SC2154
+    trap '
+        excode=$?; trap - EXIT;
+        exec 4>&-
+        exec 5>&-
+        exec 6>&-
+        rm -rf '"$temp_dir"'
+        exit $excode
+    ' INT TERM EXIT
+
+    while getopts Vhjqrvxy opt; do
+        case $opt in
+        V)  printf 'Version: %s\n' $VERSION
+            exit;;
+        h) help __main
+            printf '\nAvailable commands:\n\n'
+            _all_funcs private=0 | _cols
+            printf '\n'
+            exit;;
+        j)  NO_JQ=1;;
+        q)  quiet=1;;
+        r)  OK_SH_RATE_LIMIT=1;;
+        v)  OK_SH_VERBOSE=$(( OK_SH_VERBOSE + 1 ));;
+        x)  set -x;;
+        y)  OK_SH_DESTRUCTIVE=1;;
+        esac
+    done
+    shift $(( OPTIND - 1 ))
+
+    if [ -z "$1" ] ; then
+        printf 'No command given. Available commands:\n\n%s\n' \
+            "$(_all_funcs private=0 | _cols)" 1>&2
+        exit 1
+    fi
+
+    [ $OK_SH_VERBOSE -gt 0 ] && exec 4>&2
+    [ $OK_SH_VERBOSE -gt 1 ] && exec 5>&2
+    if [ $quiet -eq 1 ]; then
+        exec 1>/dev/null 2>/dev/null
+    fi
+
+    if [ "$OK_SH_RATE_LIMIT" -eq 1 ] ; then
+        mkdir -m 700 "$temp_dir" || {
+            printf 'failed to create temp_dir\n' >&2; exit 1;
+        }
+        mkfifo "$summary_fifo"
+        # Hold the fifo open so it will buffer input until emptied.
+        exec 6<>"$summary_fifo"
+    fi
+
+    # Run the command.
+    cmd="$1" && shift
+    _log debug "Running command ${cmd}."
+    "$cmd" "$@"
+    ret=$?
+    _log debug "Command ${cmd} exited with ${?}."
+
+    # Output any summary messages.
+    if [ "$OK_SH_RATE_LIMIT" -eq 1 ] ; then
+        cat "$summary_fifo" 1>&2 &
+        exec 6>&-
+    fi
+
+    exit $ret
+}
+
+_log() {
+    # A lightweight logging system based on file descriptors
+    #
+    # Usage:
+    #
+    #     _log debug 'Starting the combobulator!'
+    #
+    # Positional arguments
+    #
+    local level="${1:?Level is required.}"
+    #   The level for a given log message. (info or debug)
+    local message="${2:?Message is required.}"
+    #   The log message.
+
+    shift 2
+
+    local lname
+
+    case "$level" in
+        info) lname='INFO'; level=$LINFO ;;
+        debug) lname='DEBUG'; level=$LDEBUG ;;
+        *) printf 'Invalid logging level: %s\n' "$level" ;;
+    esac
+
+    printf '%s %s: %s\n' "$NAME" "$lname" "$message" 1>&$level
+}
+
+_helptext() {
+    # Extract contiguous lines of comments and function params as help text
+    #
+    # Indentation will be ignored. She-bangs will be ignored. Local variable
+    # declarations and their default values can also be pulled in as
+    # documentation. Exits upon encountering the first blank line.
+    #
+    # Exported environment variables can be used for string interpolation in
+    # the extracted commented text.
+    #
+    # Input
+    #
+    # * (stdin)
+    #   The text of a function body to parse.
+
+    awk '
+    NR != 1 && /^\s*#/ {
+        line=$0
+        while(match(line, "[$]{[^}]*}")) {
+            var=substr(line, RSTART+2, RLENGTH -3)
+            gsub("[$]{"var"}", ENVIRON[var], line)
+        }
+        gsub(/^\s*#\s?/, "", line)
+        print line
+    }
+    /^\s*local/ {
+        sub(/^\s*local /, "")
+        sub(/\$\{/, "$", $0)
+        sub(/:.*}/, "", $0)
+        print "* `" $0 "`\n"
+    }
+    !NF { exit }'
+}
+
+# ### Request-response
+# Functions for making HTTP requests and processing HTTP responses.
+
+_format_json() {
+    # Create formatted JSON from name=value pairs
+    #
+    # Usage:
+    # ```
+    # ok.sh _format_json foo=Foo bar=123 baz=true qux=Qux=Qux quux='Multi-line
+    # string' quuz=\'5.20170918\' \
+    #   corge="$(ok.sh _format_json grault=Grault)" \
+    #   garply="$(ok.sh _format_json -a waldo true 3)"
+    # ```
+    #
+    # Return:
+    # ```
+    # {
+    #   "garply": [
+    #     "waldo",
+    #     true,
+    #     3
+    #   ],
+    #   "foo": "Foo",
+    #   "corge": {
+    #     "grault": "Grault"
+    #   },
+    #   "baz": true,
+    #   "qux": "Qux=Qux",
+    #   "quux": "Multi-line\nstring",
+    #   "quuz": "5.20170918",
+    #   "bar": 123
+    # }
+    # ```
+    #
+    # Tries not to quote numbers, booleans, nulls, or nested structures.
+    # Note, nested structures must be quoted since the output contains spaces.
+    #
+    # The `-a` option will create an array instead of an object. This option
+    # must come directly after the _format_json command and before any
+    # operands. E.g., `_format_json -a foo bar baz`.
+    #
+    # If jq is installed it will also validate the output.
+    #
+    # Positional arguments
+    #
+    # * $1 - $9
+    #
+    #   Each positional arg must be in the format of `name=value` which will be
+    #   added to a single, flat JSON object.
+
+    local opt
+    local OPTIND
+    local is_array=0
+    local use_env=1
+    while getopts a opt; do
+        case $opt in
+        a)  is_array=1; unset use_env;;
+        esac
+    done
+    shift $(( OPTIND - 1 ))
+
+    _log debug "Formatting ${#} parameters as JSON."
+
+    env -i -- ${use_env+"$@"} awk -v is_array="$is_array" '
+    function isnum(x){ return (x == x + 0) }
+    function isnull(x){ return (x == "null" ) }
+    function isbool(x){ if (x == "true" || x == "false") return 1 }
+    function isnested(x) { if (substr(x, 0, 1) == "[" \
+        || substr(x, 0, 1) == "{") return 1 }
+    function castOrQuote(val) {
+        if (!isbool(val) && !isnum(val) && !isnull(val) && !isnested(val)) {
+            sub(/^('\''|")/, "", val) # Remove surrounding quotes
+            sub(/('\''|")$/, "", val)
+
+            gsub(/"/, "\\\"", val)  # Escape double-quotes.
+            gsub(/\n/, "\\n", val)  # Replace newlines with \n text.
+            val = "\"" val "\""
+            return val
+        } else {
+            return val
+        }
+    }
+
+    BEGIN {
+        printf("%s", is_array ? "[" : "{")
+
+        for (i = 1; i < length(ARGV); i += 1) {
+            arg = ARGV[i]
+
+            if (is_array == 1) {
+                val = castOrQuote(arg)
+                printf("%s%s", sep, val)
+            } else {
+                name = substr(arg, 0, index(arg, "=") - 1)
+                val = castOrQuote(ENVIRON[name])
+                printf("%s\"%s\": %s", sep, name, val)
+            }
+
+            sep = ", "
+            ARGV[i] = ""
+        }
+        printf("%s", is_array ? "]" : "}")
+    }' "$@"
+}
+
+_format_urlencode() {
+    # URL encode and join name=value pairs
+    #
+    # Usage:
+    # ```
+    # _format_urlencode foo='Foo Foo' bar='<Bar>&/Bar/'
+    # ```
+    #
+    # Return:
+    # ```
+    # foo=Foo%20Foo&bar=%3CBar%3E%26%2FBar%2F
+    # ```
+    #
+    # Ignores pairs if the value begins with an underscore.
+
+    _log debug "Formatting ${#} parameters as urlencoded"
+
+    env -i -- "$@" awk '
+    function escape(str, c, i, len, res) {
+        len = length(str)
+        res = ""
+        for (i = 1; i <= len; i += 1) {
+            c = substr(str, i, 1);
+            if (c ~ /[0-9A-Za-z]/)
+                res = res c
+            else
+                res = res "%" sprintf("%02X", ord[c])
+        }
+        return res
+    }
+
+    BEGIN {
+        for (i = 0; i <= 255; i += 1) ord[sprintf("%c", i)] = i;
+
+        for (j = 1; j < length(ARGV); j += 1) {
+            arg = ARGV[j]
+            name = substr(arg, 0, index(arg, "=") - 1)
+            if (substr(name, 1, 1) == "_") continue
+            val = ENVIRON[name]
+
+            printf("%s%s=%s", sep, name, escape(val))
+            sep = "&"
+            ARGV[j] = ""
+        }
+    }' "$@"
+}
+
+_filter_json() {
+    # Filter JSON input using jq; outputs raw JSON if jq is not installed
+    #
+    # Usage:
+    #
+    #     printf '[{"foo": "One"}, {"foo": "Two"}]' | \
+    #         ok.sh _filter_json '.[] | "\(.foo)"'
+    #
+    # * (stdin)
+    #   JSON input.
+    local _filter="$1"
+    #   A string of jq filters to apply to the input stream.
+
+    _log debug 'Filtering JSON.'
+
+    if [ $NO_JQ -ne 0 ] ; then
+        _log debug 'Bypassing jq processing.'
+        cat
+        return
+    fi
+
+    "${OK_SH_JQ_BIN}" -c -r "${_filter}"
+    [ $? -eq 0 ] || printf 'jq parse error; invalid JSON.\n' 1>&2
+}
+
+_get_mime_type() {
+    # Guess the mime type for a file based on the file extension
+    #
+    # Usage:
+    #
+    #     local mime_type
+    #     _get_mime_type "foo.tar"; printf 'mime is: %s' "$mime_type"
+    #
+    # Sets the global variable `mime_type` with the result. (If this function
+    # is called from within a function that has declared a local variable of
+    # that name it will update the local copy and not set a global.)
+    #
+    # Positional arguments
+    #
+    local filename="${1:?Filename is required.}"
+    #   The full name of the file, with extension.
+
+    # Taken from Apache's mime.types file (public domain).
+    case "$filename" in
+        *.bz2) mime_type=application/x-bzip2 ;;
+        *.exe) mime_type=application/x-msdownload ;;
+        *.tar.gz | *.gz | *.tgz) mime_type=application/x-gzip ;;
+        *.jpg | *.jpeg | *.jpe | *.jfif) mime_type=image/jpeg ;;
+        *.json) mime_type=application/json ;;
+        *.pdf) mime_type=application/pdf ;;
+        *.png) mime_type=image/png ;;
+        *.rpm) mime_type=application/x-rpm ;;
+        *.svg | *.svgz) mime_type=image/svg+xml ;;
+        *.tar) mime_type=application/x-tar ;;
+        *.txt) mime_type=text/plain ;;
+        *.yaml) mime_type=application/x-yaml ;;
+        *.apk) mime_type=application/vnd.android.package-archive ;;
+        *.zip) mime_type=application/zip ;;
+        *.jar) mime_type=application/java-archive ;;
+        *.war) mime_type=application/zip ;;
+    esac
+
+    _log debug "Guessed mime type of '${mime_type}' for '${filename}'."
+}
+
+_get_confirm() {
+    # Prompt the user for confirmation
+    #
+    # Usage:
+    #
+    #     local confirm; _get_confirm
+    #     [ "$confirm" -eq 1 ] && printf 'Good to go!\n'
+    #
+    # If global confirmation is set via `$OK_SH_DESTRUCTIVE` then the user
+    # is not prompted. Assigns the user's confirmation to the `confirm` global
+    # variable. (If this function is called within a function that has a local
+    # variable of that name, the local variable will be updated instead.)
+    #
+    # Positional arguments
+    #
+    local message="${1:-Are you sure?}"
+    #   The message to prompt the user with.
+
+    local answer
+
+    if [ "$OK_SH_DESTRUCTIVE" -eq 1 ] ; then
+        confirm=$OK_SH_DESTRUCTIVE
+        return
+    fi
+
+    printf '%s ' "$message"
+    read -r answer
+
+    ! printf '%s\n' "$answer" | grep -Eq "$(locale yesexpr)"
+    confirm=$?
+}
+
+_opts_filter() {
+    # Extract common jq filter keyword options and assign to vars
+    #
+    # Usage:
+    #
+    #     local filter
+    #     _opts_filter "$@"
+
+    for arg in "$@"; do
+        case $arg in
+            (_filter=*) _filter="${arg#*=}";;
+        esac
+    done
+}
+
+_opts_pagination() {
+    # Extract common pagination keyword options and assign to vars
+    #
+    # Usage:
+    #
+    #     local _follow_next
+    #     _opts_pagination "$@"
+
+    for arg in "$@"; do
+        case $arg in
+            (_follow_next=*) _follow_next="${arg#*=}";;
+            (_follow_next_limit=*) _follow_next_limit="${arg#*=}";;
+        esac
+    done
+}
+
+_opts_qs() {
+    # Extract common query string keyword options and assign to vars
+    #
+    # Usage:
+    #
+    #     local qs
+    #     _opts_qs "$@"
+    #     _get "/some/path${qs}"
+
+    local querystring=$(_format_urlencode "$@")
+    qs="${querystring:+?$querystring}"
+}
+
+_request() {
+    # A wrapper around making HTTP requests with curl
+    #
+    # Usage:
+    # ```
+    # # Get JSON for all issues:
+    # _request /repos/saltstack/salt/issues
+    #
+    # # Send a POST request; parse response using jq:
+    # printf '{"title": "%s", "body": "%s"}\n' "Stuff" "Things" \
+    #   | _request /some/path | jq -r '.[url]'
+    #
+    # # Send a PUT request; parse response using jq:
+    # printf '{"title": "%s", "body": "%s"}\n' "Stuff" "Things" \
+    #   | _request /repos/:owner/:repo/issues method=PUT | jq -r '.[url]'
+    #
+    # # Send a conditional-GET request:
+    # _request /users etag=edd3a0d38d8c329d3ccc6575f17a76bb
+    # ```
+    #
+    # Input
+    #
+    # * (stdin)
+    #   Data that will be used as the request body.
+    #
+    # Positional arguments
+    #
+    local path="${1:?Path is required.}"
+    #   The URL path for the HTTP request.
+    #   Must be an absolute path that starts with a `/` or a full URL that
+    #   starts with http(s). Absolute paths will be append to the value in
+    #   `$OK_SH_URL`.
+    #
+    # Keyword arguments
+    #
+    local method='GET'
+    #   The method to use for the HTTP request.
+    local content_type='application/json'
+    #   The value of the Content-Type header to use for the request.
+    local etag
+    #   An optional Etag to send as the If-None-Match header.
+
+    shift 1
+
+    local cmd
+    local arg
+    local has_stdin
+    local trace_curl
+
+    case $path in
+        (http*) : ;;
+        *) path="${OK_SH_URL}${path}" ;;
+    esac
+
+    for arg in "$@"; do
+        case $arg in
+            (method=*) method="${arg#*=}";;
+            (content_type=*) content_type="${arg#*=}";;
+            (etag=*) etag="${arg#*=}";;
+        esac
+    done
+
+    case "$method" in
+        POST | PUT | PATCH) has_stdin=1;;
+    esac
+
+    [ $OK_SH_VERBOSE -eq 3 ] && trace_curl=1
+
+    [ "$OK_SH_VERBOSE" -eq 1 ] && set -x
+    # shellcheck disable=SC2086
+    curl -nsSig \
+        -H "Accept: ${OK_SH_ACCEPT}" \
+        -H "Content-Type: ${content_type}" \
+        ${GITHUB_TOKEN:+-H "Authorization: token ${GITHUB_TOKEN}"} \
+        ${etag:+-H "If-None-Match: \"${etag}\""} \
+        ${has_stdin:+--data-binary @-} \
+        ${trace_curl:+--trace-ascii /dev/stderr} \
+        -X "${method}" \
+        "${path}"
+    set +x
+}
+
+_response() {
+    # Process an HTTP response from curl
+    #
+    # Output only headers of interest followed by the response body. Additional
+    # processing is performed on select headers to make them easier to parse
+    # using shell tools.
+    #
+    # Usage:
+    # ```
+    # # Send a request; output the response and only select response headers:
+    # _request /some/path | _response status_code ETag Link_next
+    #
+    # # Make request using curl; output response with select response headers;
+    # # assign response headers to local variables:
+    # curl -isS example.com/some/path | _response status_code status_text | {
+    #   local status_code status_text
+    #   read -r status_code
+    #   read -r status_text
+    # }
+    # ```
+    #
+    # Header reformatting
+    #
+    # * HTTP Status
+    #
+    #   The HTTP line is split into separate `http_version`, `status_code`, and
+    #   `status_text` variables.
+    #
+    # * ETag
+    #
+    #   The surrounding quotes are removed.
+    #
+    # * Link
+    #
+    #   Each URL in the Link header is expanded with the URL type appended to
+    #   the name. E.g., `Link_first`, `Link_last`, `Link_next`.
+    #
+    # Positional arguments
+    #
+    # * $1 - $9
+    #
+    #   Each positional arg is the name of an HTTP header. Each header value is
+    #   output in the same order as each argument; each on a single line. A
+    #   blank line is output for headers that cannot be found.
+
+    local hdr
+    local val
+    local http_version
+    local status_code=100
+    local status_text
+    local headers output
+
+    _log debug 'Processing response.'
+
+    while [ "${status_code}" = "100" ]; do
+        read -r http_version status_code status_text
+        status_text="${status_text%${crlf}}"
+        http_version="${http_version#HTTP/}"
+
+        _log debug "Response status is: ${status_code} ${status_text}"
+
+        if [ "${status_code}" = "100" ]; then
+            _log debug "Ignoring response '${status_code} ${status_text}', skipping to real response."
+            while IFS=": " read -r hdr val; do
+                # Headers stop at the first blank line.
+                [ "$hdr" = "$crlf" ] && break
+                val="${val%${crlf}}"
+                _log debug "Unexpected additional header: ${hdr}: ${val}"
+            done
+
+        fi
+    done
+
+    headers="http_version: ${http_version}
+status_code: ${status_code}
+status_text: ${status_text}
+"
+    while IFS=": " read -r hdr val; do
+        # Headers stop at the first blank line.
+        [ "$hdr" = "$crlf" ] && break
+        val="${val%${crlf}}"
+
+        # Process each header; reformat some to work better with sh tools.
+        case "$hdr" in
+            # Update the GitHub rate limit trackers.
+            X-RateLimit-Remaining)
+                printf 'GitHub remaining requests: %s\n' "$val" 1>&$LSUMMARY ;;
+            X-RateLimit-Reset)
+                awk -v gh_reset="$val" 'BEGIN {
+                    srand(); curtime = srand()
+                    print "GitHub seconds to reset: " gh_reset - curtime
+                }' 1>&$LSUMMARY ;;
+
+            # Remove quotes from the etag header.
+            ETag) val="${val#\"}"; val="${val%\"}" ;;
+
+            # Split the URLs in the Link header into separate pseudo-headers.
+            Link) headers="${headers}$(printf '%s' "$val" | awk '
+                BEGIN { RS=", "; FS="; "; OFS=": " }
+                {
+                    sub(/^rel="/, "", $2); sub(/"$/, "", $2)
+                    sub(/^ *</, "", $1); sub(/>$/, "", $1)
+                    print "Link_" $2, $1
+                }')
+"  # need trailing newline
+            ;;
+        esac
+
+        headers="${headers}${hdr}: ${val}
+"  # need trailing newline
+
+    done
+
+    # Output requested headers in deterministic order.
+    for arg in "$@"; do
+        _log debug "Outputting requested header '${arg}'."
+        output=$(printf '%s' "$headers" | while IFS=": " read -r hdr val; do
+            [ "$hdr" = "$arg" ] && printf '%s' "$val"
+        done)
+        printf '%s\n' "$output"
+    done
+
+    # Output the response body.
+    cat
+}
+
+_get() {
+    # A wrapper around _request() for common GET patterns
+    #
+    # Will automatically follow 'next' pagination URLs in the Link header.
+    #
+    # Usage:
+    #
+    #     _get /some/path
+    #     _get /some/path _follow_next=0
+    #     _get /some/path _follow_next_limit=200 | jq -c .
+    #
+    # Positional arguments
+    #
+    local path="${1:?Path is required.}"
+    #   The HTTP path or URL to pass to _request().
+    #
+    # Keyword arguments
+    #
+    # * _follow_next=1
+    #
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    #
+    # * _follow_next_limit=50
+    #
+    #   Maximum number of 'next' URLs to follow before stopping.
+
+    shift 1
+    local status_code
+    local status_text
+    local next_url
+
+    # If the variable is unset or empty set it to a default value. Functions
+    # that call this function can pass these parameters in one of two ways:
+    # explicitly as a keyword arg or implicitly by setting variables of the same
+    # names within the local scope.
+    # shellcheck disable=SC2086
+    if [ -z ${_follow_next+x} ] || [ -z "${_follow_next}" ]; then
+        local _follow_next=1
+    fi
+    # shellcheck disable=SC2086
+    if [ -z ${_follow_next_limit+x} ] || [ -z "${_follow_next_limit}" ]; then
+        local _follow_next_limit=50
+    fi
+
+    _opts_pagination "$@"
+
+    _request "$path" | _response status_code status_text Link_next | {
+        read -r status_code
+        read -r status_text
+        read -r next_url
+
+        case "$status_code" in
+            20*) : ;;
+            4*) printf 'Client Error: %s %s\n' \
+                "$status_code" "$status_text" 1>&2; exit 1 ;;
+            5*) printf 'Server Error: %s %s\n' \
+                "$status_code" "$status_text" 1>&2; exit 1 ;;
+        esac
+
+        # Output response body.
+        cat
+
+        [ "$_follow_next" -eq 1 ] || return
+
+        _log info "Remaining next link follows: ${_follow_next_limit}"
+        if [ -n "$next_url" ] && [ $_follow_next_limit -gt 0 ] ; then
+            _follow_next_limit=$(( _follow_next_limit - 1 ))
+
+            _get "$next_url" "_follow_next_limit=${_follow_next_limit}"
+        fi
+    }
+}
+
+_post() {
+    # A wrapper around _request() for common POST / PUT patterns
+    #
+    # Usage:
+    #
+    #     _format_json foo=Foo bar=Bar | _post /some/path
+    #     _format_json foo=Foo bar=Bar | _post /some/path method='PUT'
+    #     _post /some/path filename=somearchive.tar
+    #     _post /some/path filename=somearchive.tar mime_type=application/x-tar
+    #     _post /some/path filename=somearchive.tar \
+    #       mime_type=$(file -b --mime-type somearchive.tar)
+    #
+    # Input
+    #
+    # * (stdin)
+    #   Optional. See the `filename` argument also.
+    #   Data that will be used as the request body.
+    #
+    # Positional arguments
+    #
+    local path="${1:?Path is required.}"
+    #   The HTTP path or URL to pass to _request().
+    #
+    # Keyword arguments
+    #
+    local method='POST'
+    #   The method to use for the HTTP request.
+    local filename
+    #   Optional. See the `stdin` option above also.
+    #   Takes precedence over any data passed as stdin and loads a file off the
+    #   file system to serve as the request body.
+    local mime_type
+    #   The value of the Content-Type header to use for the request.
+    #   If the `filename` argument is given this value will be guessed from the
+    #   file extension. If the `filename` argument is not given (i.e., using
+    #   stdin) this value defaults to `application/json`. Specifying this
+    #   argument overrides all other defaults or guesses.
+
+    shift 1
+
+    for arg in "$@"; do
+        case $arg in
+            (method=*) method="${arg#*=}";;
+            (filename=*) filename="${arg#*=}";;
+            (mime_type=*) mime_type="${arg#*=}";;
+        esac
+    done
+
+    # Make either the file or stdin available as fd7.
+    if [ -n "$filename" ] ; then
+        if [ -r "$filename" ] ; then
+            _log debug "Using '${filename}' as POST data."
+            [ -n "$mime_type" ] || _get_mime_type "$filename"
+            : ${mime_type:?The MIME type could not be guessed.}
+            exec 7<"$filename"
+        else
+            printf 'File could not be found or read.\n' 1>&2
+            exit 1
+        fi
+    else
+        _log debug "Using stdin as POST data."
+        mime_type='application/json'
+        exec 7<&0
+    fi
+
+    _request "$path" method="$method" content_type="$mime_type" 0<&7 \
+            | _response status_code status_text \
+            | {
+        read -r status_code
+        read -r status_text
+
+        case "$status_code" in
+            20*) : ;;
+            4*) printf 'Client Error: %s %s\n' \
+                "$status_code" "$status_text" 1>&2; exit 1 ;;
+            5*) printf 'Server Error: %s %s\n' \
+                "$status_code" "$status_text" 1>&2; exit 1 ;;
+        esac
+
+        # Output response body.
+        cat
+    }
+}
+
+_delete() {
+    # A wrapper around _request() for common DELETE patterns
+    #
+    # Usage:
+    #
+    #     _delete '/some/url'
+    #
+    # Return: 0 for success; 1 for failure.
+    #
+    # Positional arguments
+    #
+    local url="${1:?URL is required.}"
+    #   The URL to send the DELETE request to.
+
+    local status_code
+
+    _request "${url}" method='DELETE' | _response status_code | {
+        read -r status_code
+        [ "$status_code" = "204" ]
+        exit $?
+    }
+}
+
+# ## GitHub
+# Friendly functions for common GitHub tasks.
+
+# ### Authorization
+# Perform authentication and authorization.
+
+show_scopes() {
+    # Show the permission scopes for the currently authenticated user
+    #
+    # Usage:
+    #
+    #     show_scopes
+
+    local oauth_scopes
+
+    _request '/' | _response X-OAuth-Scopes | {
+        read -r oauth_scopes
+
+        printf '%s\n' "$oauth_scopes"
+
+        # Dump any remaining response body.
+        cat >/dev/null
+    }
+}
+
+# ### Repository
+# Create, update, delete, list repositories.
+
+org_repos() {
+    # List organization repositories
+    #
+    # Usage:
+    #
+    #     org_repos myorg
+    #     org_repos myorg type=private per_page=10
+    #     org_repos myorg _filter='.[] | "\(.name)\t\(.owner.login)"'
+    #
+    # Positional arguments
+    #
+    local org="${1:?Org name required.}"
+    #   Organization GitHub login or id for which to list repos.
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.name)\t\(.ssh_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Querystring arguments may also be passed as keyword arguments:
+    #
+    # * `per_page`
+    # * `type`
+
+    shift 1
+    local qs
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    _get "/orgs/${org}/repos${qs}" | _filter_json "${_filter}"
+}
+
+org_teams() {
+    # List teams
+    #
+    # Usage:
+    #
+    #     org_teams org
+    #
+    # Positional arguments
+    #
+    local org="${1:?Org name required.}"
+    #   Organization GitHub login or id.
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.name)\t\(.id)\t\(.permission)"'
+    #   A jq filter to apply to the return data.
+
+    shift 1
+
+    _opts_filter "$@"
+
+    _get "/orgs/${org}/teams" \
+        | _filter_json "${_filter}"
+}
+
+org_members() {
+    # List organization members
+    #
+    # Usage:
+    #
+    #     org_members org
+    #
+    # Positional arguments
+    #
+    local org="${1:?Org name required.}"
+    #   Organization GitHub login or id.
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.login)\t\(.id)"'
+    #   A jq filter to apply to the return data.
+
+    shift 1
+
+    _opts_filter "$@"
+
+    _get "/orgs/${org}/members" \
+        | _filter_json "${_filter}"
+}
+
+team_members() {
+    # List team members
+    #
+    # Usage:
+    #
+    #     team_members team_id
+    #
+    # Positional arguments
+    #
+    local team_id="${1:?Team id required.}"
+    #   Team id.
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.login)\t\(.id)"'
+    #   A jq filter to apply to the return data.
+
+    shift 1
+
+    _opts_filter "$@"
+
+    _get "/teams/${team_id}/members" \
+        | _filter_json "${_filter}"
+
+}
+
+list_repos() {
+    # List user repositories
+    #
+    # Usage:
+    #
+    #     list_repos
+    #     list_repos user
+    #
+    # Positional arguments
+    #
+    local user="$1"
+    #   Optional GitHub user login or id for which to list repos.
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.name)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Querystring arguments may also be passed as keyword arguments:
+    #
+    # * `direction`
+    # * `per_page`
+    # * `sort`
+    # * `type`
+
+
+    shift 1
+    local qs
+
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    if [ -n "$user" ] ; then
+        url="/users/${user}/repos"
+    else
+        url='/user/repos'
+    fi
+
+    _get "${url}${qs}" | _filter_json "${_filter}"
+}
+
+list_branches() {
+    # List branches of a specified repository.
+    # ( https://developer.github.com/v3/repos/#list_branches )
+    #
+    # Usage:
+    #
+    #     list_branches user repo
+    #
+    # Positional arguments
+    #
+    #   GitHub user login or id for which to list branches
+    #   Name of the repo for which to list branches
+    #
+    local user="${1:?User name required.}"
+    local repo="${2:?Repo name required.}"
+    shift 2
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.name)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Querystring arguments may also be passed as keyword arguments:
+    #
+    # * `direction`
+    # * `per_page`
+    # * `sort`
+    # * `type`
+
+    local qs
+
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    url="/repos/${user}/${repo}/branches"
+
+    _get "${url}${qs}" | _filter_json "${_filter}"
+}
+
+list_contributors() {
+    # List contributors to the specified repository, sorted by the number of commits per contributor in descending order.
+    # ( https://developer.github.com/v3/repos/#list-contributors )
+    #
+    # Usage:
+    #
+    #     list_contributors user repo
+    #
+    # Positional arguments
+    #
+    local user="${1:?User name required.}"
+    #   GitHub user login or id for which to list contributors
+    local repo="${2:?Repo name required.}"
+    #   Name of the repo for which to list contributors
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.login)\t\(.type)\tType:\(.type)\tContributions:\(.contributions)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Querystring arguments may also be passed as keyword arguments:
+    #
+    # * `direction`
+    # * `per_page`
+    # * `sort`
+    # * `type`
+
+    shift 2
+
+    local qs
+
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    url="/repos/${user}/${repo}/contributors"
+
+    _get "${url}${qs}" | _filter_json "${_filter}"
+}
+
+list_collaborators() {
+    # List collaborators to the specified repository, sorted by the number of commits per collaborator in descending order.
+    # ( https://developer.github.com/v3/repos/#list-collaborators )
+    #
+    # Usage:
+    #
+    #     list_collaborators someuser/somerepo
+    #
+    # Positional arguments
+    #   GitHub user login or id for which to list collaborators
+    #   Name of the repo for which to list collaborators
+    #
+    local repo="${1:?Repo name required.}"
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.login)\t\(.type)\tType:\(.type)\tPermissions:\(.permissions)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Querystring arguments may also be passed as keyword arguments:
+    #
+    # * `direction`
+    # * `per_page`
+    # * `sort`
+    # * `type`
+
+    shift 1
+
+    local qs
+
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    url="/repos/${repo}/collaborators"
+
+    _get "${url}${qs}" | _filter_json "${_filter}"
+}
+
+list_hooks() {
+    # List webhooks from the specified repository.
+    # ( https://developer.github.com/v3/repos/hooks/#list-hooks )
+    #
+    # Usage:
+    #
+    #     list_hooks owner/repo
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   Name of the repo for which to list contributors
+    #   Owner is mandatory, like 'owner/repo'
+    #
+    local _filter='.[] | "\(.name)\t\(.config.url)"'
+    #   A jq filter to apply to the return data.
+    #
+
+    shift 1
+
+    _opts_filter "$@"
+
+    url="/repos/${repo}/hooks"
+
+    _get "${url}" | _filter_json "${_filter}"
+}
+
+list_gists() {
+    # List gists for the current authenticated user or a specific user
+    #
+    # https://developer.github.com/v3/gists/#list-a-users-gists
+    #
+    # Usage:
+    #
+    #     list_gists
+    #     list_gists <username>
+    #
+    # Positional arguments
+    #
+    local username="$1"
+    #   An optional user to filter listing
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.id)\t\(.description)"'
+    #   A jq filter to apply to the return data.
+
+    local url
+    case "$username" in
+        ('') url='/gists';;
+        (*=*) url='/gists';;
+        (*) url="/users/${username}/gists"; shift 1;;
+    esac
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+
+    _get "${url}" | _filter_json "${_filter}"
+}
+
+public_gists() {
+    # List public gists
+    #
+    # https://developer.github.com/v3/gists/#list-all-public-gists
+    #
+    # Usage:
+    #
+    #     public_gists
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.id)\t\(.description)"'
+    #   A jq filter to apply to the return data.
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+
+    _get '/gists/public' | _filter_json "${_filter}"
+}
+
+gist() {
+    # Get a single gist
+    #
+    # https://developer.github.com/v3/gists/#get-a-single-gist
+    #
+    # Usage:
+    #
+    #     get_gist
+    #
+    # Positional arguments
+    #
+    local gist_id="${1:?Gist ID required.}"
+    #   ID of gist to fetch.
+    #
+    # Keyword arguments
+    #
+    local _filter='.files | keys | join(", ")'
+    #   A jq filter to apply to the return data.
+
+    shift 1
+
+    _opts_filter "$@"
+
+    _get "/gists/${gist_id}" | _filter_json "${_filter}"
+}
+
+add_collaborator() {
+    # Add a collaborator to a repository
+    #
+    # Usage:
+    #
+    #     add_collaborator someuser/somerepo collaboratoruser permission
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   A GitHub repository.
+    local collaborator="${2:?Collaborator name required.}"
+    #   A new collaborator.
+    local permission="${3:?Permission required. One of: push pull admin}"
+    #   The permission level for this collaborator. One of `push`, `pull`,
+    #   `admin`. The `pull` and `admin` permissions are valid for organization
+    #   repos only.
+    case $permission in
+        push|pull|admin) :;;
+        *) printf 'Permission invalid: %s\nMust be one of: push pull admin\n' \
+                "$permission" 1>&2; exit 1 ;;
+    esac
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.name)\t\(.color)"'
+    #   A jq filter to apply to the return data.
+
+    _opts_filter "$@"
+
+    _format_json permission="$permission" \
+        | _post "/repos/${repo}/collaborators/${collaborator}" method='PUT' \
+        | _filter_json "$_filter"
+}
+
+delete_collaborator() {
+    # Delete a collaborator to a repository
+    #
+    # Usage:
+    #
+    #     delete_collaborator someuser/somerepo collaboratoruser permission
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   A GitHub repository.
+    local collaborator="${2:?Collaborator name required.}"
+    #   A new collaborator.
+
+    shift 2
+
+    local confirm
+
+    _get_confirm 'This will permanently delete the collaborator from this repo. Continue?'
+    [ "$confirm" -eq 1 ] || exit 0
+
+    _delete "/repos/${repo}/collaborators/${collaborator}"
+    exit $?
+}
+
+create_repo() {
+    # Create a repository for a user or organization
+    #
+    # Usage:
+    #
+    #     create_repo foo
+    #     create_repo bar description='Stuff and things' homepage='example.com'
+    #     create_repo baz organization=myorg
+    #
+    # Positional arguments
+    #
+    local name="${1:?Repo name required.}"
+    #   Name of the new repo
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.name)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # POST data may also be passed as keyword arguments:
+    #
+    # * `auto_init`,
+    # * `description`
+    # * `gitignore_template`
+    # * `has_downloads`
+    # * `has_issues`
+    # * `has_wiki`,
+    # * `homepage`
+    # * `organization`
+    # * `private`
+    # * `team_id`
+
+    shift 1
+
+    _opts_filter "$@"
+
+    local url
+    local organization
+
+    for arg in "$@"; do
+        case $arg in
+            (organization=*) organization="${arg#*=}";;
+        esac
+    done
+
+    if [ -n "$organization" ] ; then
+        url="/orgs/${organization}/repos"
+    else
+        url='/user/repos'
+    fi
+
+    _format_json "name=${name}" "$@" | _post "$url" | _filter_json "${_filter}"
+}
+
+delete_repo() {
+    # Delete a repository for a user or organization
+    #
+    # Usage:
+    #
+    #     delete_repo owner repo
+    #
+    # The currently authenticated user must have the `delete_repo` scope. View
+    # current scopes with the `show_scopes()` function.
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   Name of the new repo
+    local repo="${2:?Repo name required.}"
+    #   Name of the new repo
+
+    shift 2
+
+    local confirm
+
+    _get_confirm 'This will permanently delete a repository! Continue?'
+    [ "$confirm" -eq 1 ] || exit 0
+
+    _delete "/repos/${owner}/${repo}"
+    exit $?
+}
+
+fork_repo() {
+    # Fork a repository from a user or organization to own account or organization
+    #
+    # Usage:
+    #
+    #     fork_repo owner repo
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   Name of existing user or organization
+    local repo="${2:?Repo name required.}"
+    #   Name of the existing repo
+    #
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.clone_url)\t\(.ssh_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # POST data may also be passed as keyword arguments:
+    # 
+    # * `organization` (The organization to clone into; default: your personal account)
+
+    shift 2
+
+    _opts_filter "$@"
+
+    _format_json "$@" | _post "/repos/${owner}/${repo}/forks" \
+        | _filter_json "${_filter}"
+    exit $?  # might take a bit time...
+}
+
+# ### Releases
+# Create, update, delete, list releases.
+
+list_releases() {
+    # List releases for a repository
+    #
+    # https://developer.github.com/v3/repos/releases/#list-releases-for-a-repository
+    #
+    # Usage:
+    #
+    #     list_releases org repo '\(.assets[0].name)\t\(.name.id)'
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   A GitHub user or organization.
+    local repo="${2:?Repo name required.}"
+    #   A GitHub repository.
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.name)\t\(.tag_name)\t\(.id)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+
+    shift 2
+
+    _opts_filter "$@"
+
+    _get "/repos/${owner}/${repo}/releases" \
+        | _filter_json "${_filter}"
+}
+
+release() {
+    # Get a release
+    #
+    # https://developer.github.com/v3/repos/releases/#get-a-single-release
+    #
+    # Usage:
+    #
+    #     release user repo 1087855
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   A GitHub user or organization.
+    local repo="${2:?Repo name required.}"
+    #   A GitHub repository.
+    local release_id="${3:?Release ID required.}"
+    #   The unique ID of the release; see list_releases.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.author.login)\t\(.published_at)"'
+    #   A jq filter to apply to the return data.
+
+    shift 3
+
+    _opts_filter "$@"
+
+    _get "/repos/${owner}/${repo}/releases/${release_id}" \
+        | _filter_json "${_filter}"
+}
+
+create_release() {
+    # Create a release
+    #
+    # https://developer.github.com/v3/repos/releases/#create-a-release
+    #
+    # Usage:
+    #
+    #     create_release org repo v1.2.3
+    #     create_release user repo v3.2.1 draft=true
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   A GitHub user or organization.
+    local repo="${2:?Repo name required.}"
+    #   A GitHub repository.
+    local tag_name="${3:?Tag name required.}"
+    #   Git tag from which to create release.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.name)\t\(.id)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # POST data may also be passed as keyword arguments:
+    #
+    # * `body`
+    # * `draft`
+    # * `name`
+    # * `prerelease`
+    # * `target_commitish`
+
+    shift 3
+
+    _opts_filter "$@"
+
+    _format_json "tag_name=${tag_name}" "$@" \
+        | _post "/repos/${owner}/${repo}/releases" \
+        | _filter_json "${_filter}"
+}
+
+edit_release() {
+    # Edit a release
+    #
+    # https://developer.github.com/v3/repos/releases/#edit-a-release
+    #
+    # Usage:
+    #
+    #     edit_release org repo 1087855 name='Foo Bar 1.4.6'
+    #     edit_release user repo 1087855 draft=false
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   A GitHub user or organization.
+    local repo="${2:?Repo name required.}"
+    #   A GitHub repository.
+    local release_id="${3:?Release ID required.}"
+    #   The unique ID of the release; see list_releases.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.tag_name)\t\(.name)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # POST data may also be passed as keyword arguments:
+    #
+    # * `tag_name`
+    # * `body`
+    # * `draft`
+    # * `name`
+    # * `prerelease`
+    # * `target_commitish`
+
+    shift 3
+
+    _opts_filter "$@"
+
+    _format_json "$@" \
+        | _post "/repos/${owner}/${repo}/releases/${release_id}" method="PATCH" \
+        | _filter_json "${_filter}"
+}
+
+delete_release() {
+    # Delete a release
+    #
+    # https://developer.github.com/v3/repos/releases/#delete-a-release
+    #
+    # Usage:
+    #
+    #     delete_release org repo 1087855
+    #
+    # Return: 0 for success; 1 for failure.
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   A GitHub user or organization.
+    local repo="${2:?Repo name required.}"
+    #   A GitHub repository.
+    local release_id="${3:?Release ID required.}"
+    #   The unique ID of the release; see list_releases.
+
+    shift 3
+
+    local confirm
+
+    _get_confirm 'This will permanently delete a release. Continue?'
+    [ "$confirm" -eq 1 ] || exit 0
+
+    _delete "/repos/${owner}/${repo}/releases/${release_id}"
+    exit $?
+}
+
+release_assets() {
+    # List release assets
+    #
+    # https://developer.github.com/v3/repos/releases/#list-assets-for-a-release
+    #
+    # Usage:
+    #
+    #     release_assets user repo 1087855
+    #
+    # Example of downloading release assets:
+    #
+    #     ok.sh release_assets <user> <repo> <release_id> \
+    #             _filter='.[] | .browser_download_url' \
+    #         | xargs -L1 curl -L -O
+    #
+    # Example of the multi-step process for grabbing the release ID for
+    # a specific version, then grabbing the release asset IDs, and then
+    # downloading all the release assets (whew!):
+    #
+    #     username='myuser'
+    #     repo='myrepo'
+    #     release_tag='v1.2.3'
+    #     ok.sh list_releases "$myuser" "$myrepo" \
+    #         | awk -F'\t' -v tag="$release_tag" '$2 == tag { print $3 }' \
+    #         | xargs -I{} ./ok.sh release_assets "$myuser" "$myrepo" {} \
+    #             _filter='.[] | .browser_download_url' \
+    #         | xargs -L1 curl -n -L -O
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   A GitHub user or organization.
+    local repo="${2:?Repo name required.}"
+    #   A GitHub repository.
+    local release_id="${3:?Release ID required.}"
+    #   The unique ID of the release; see list_releases.
+    #
+    # Keyword arguments
+    #
+    local _filter='.[] | "\(.id)\t\(.name)\t\(.updated_at)"'
+    #   A jq filter to apply to the return data.
+
+    shift 3
+
+    _opts_filter "$@"
+
+    _get "/repos/${owner}/${repo}/releases/${release_id}/assets" \
+        | _filter_json "$_filter"
+}
+
+upload_asset() {
+    # Upload a release asset
+    #
+    # https://developer.github.com/v3/repos/releases/#upload-a-release-asset
+    #
+    # Usage:
+    #
+    #     upload_asset https://<upload-url> /path/to/file.zip
+    #
+    # The upload URL can be gotten from `release()`. There are multiple steps
+    # required to upload a file: get the release ID, get the upload URL, parse
+    # the upload URL, then finally upload the file. For example:
+    #
+    # ```sh
+    # USER="someuser"
+    # REPO="somerepo"
+    # TAG="1.2.3"
+    # FILE_NAME="foo.zip"
+    # FILE_PATH="/path/to/foo.zip"
+    #
+    # # Create a release then upload a file:
+    # ok.sh create_release "$USER" "$REPO" "$TAG" _filter='.upload_url' \
+    #     | sed 's/{.*$/?name='"$FILE_NAME"'/' \
+    #     | xargs -I@ ok.sh upload_asset @ "$FILE_PATH"
+    #
+    # # Find a release by tag then upload a file:
+    # ok.sh list_releases "$USER" "$REPO" \
+    #     | awk -v "tag=$TAG" -F'\t' '$2 == tag { print $3 }' \
+    #     | xargs -I@ ok.sh release "$USER" "$REPO" @ _filter='.upload_url' \
+    #     | sed 's/{.*$/?name='"$FILE_NAME"'/' \
+    #     | xargs -I@ ok.sh upload_asset @ "$FILE_PATH"
+    # ```
+    #
+    # Positional arguments
+    #
+    local upload_url="${1:?upload_url is required.}"
+    # The _parsed_ upload_url returned from GitHub.
+    #
+    local file_path="${2:?file_path is required.}"
+    #   A path to the file that should be uploaded.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.state)\t\(.browser_download_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Also any other keyword arguments accepted by `_post()`.
+
+    shift 2
+
+    _opts_filter "$@"
+
+    _post "$upload_url" filename="$file_path" "$@" \
+        | _filter_json "$_filter"
+}
+
+# ### Issues
+# Create, update, edit, delete, list issues and milestones.
+
+list_milestones() {
+    # List milestones for a repository
+    #
+    # Usage:
+    #
+    #     list_milestones someuser/somerepo
+    #     list_milestones someuser/somerepo state=closed
+    #
+    # Positional arguments
+    #
+    local repository="${1:?Repo name required.}"
+    #   A GitHub repository.
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.number)\t\(.open_issues)/\(.closed_issues)\t\(.title)"'
+    #   A jq filter to apply to the return data.
+    #
+    # GitHub querystring arguments may also be passed as keyword arguments:
+    #
+    # * `direction`
+    # * `per_page`
+    # * `sort`
+    # * `state`
+
+    shift 1
+    local qs
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    _get "/repos/${repository}/milestones${qs}" | _filter_json "$_filter"
+}
+
+create_milestone() {
+    # Create a milestone for a repository
+    #
+    # Usage:
+    #
+    #     create_milestone someuser/somerepo MyMilestone
+    #
+    #     create_milestone someuser/somerepo MyMilestone \
+    #         due_on=2015-06-16T16:54:00Z \
+    #         description='Long description here
+    #     that spans multiple lines.'
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   A GitHub repository.
+    local title="${2:?Milestone name required.}"
+    #   A unique title.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.number)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Milestone options may also be passed as keyword arguments:
+    #
+    # * `description`
+    # * `due_on`
+    # * `state`
+
+    shift 2
+
+    _opts_filter "$@"
+
+    _format_json title="$title" "$@" \
+        | _post "/repos/${repo}/milestones" \
+        | _filter_json "$_filter"
+}
+
+add_comment() {
+    # Add a comment to an issue
+    #
+    # Usage:
+    #
+    #     add_comment someuser/somerepo 123 'This is a comment'
+    #
+    # Positional arguments
+    #
+    local repository="${1:?Repo name required}"
+    #   A GitHub repository
+    local number="${2:?Issue number required}"
+    #   Issue Number
+    local comment="${3:?Comment required}"
+    #   Comment to be added
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.id)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+
+    shift 3
+    _opts_filter "$@"
+
+    _format_json body="$comment" \
+        | _post "/repos/${repository}/issues/${number}/comments" \
+        | _filter_json "${_filter}"
+}
+
+add_commit_comment() {
+    # Add a comment to a commit
+    #
+    # Usage:
+    #
+    #     add_commit_comment someuser/somerepo 123 'This is a comment'
+    #
+    # Positional arguments
+    #
+    local repository="${1:?Repo name required}"
+    #   A GitHub repository
+    local hash="${2:?Commit hash required}"
+    #   Commit hash
+    local comment="${3:?Comment required}"
+    #   Comment to be added
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.id)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+
+    shift 3
+    _opts_filter "$@"
+
+    _format_json body="$comment" \
+        | _post "/repos/${repository}/commits/${hash}/comments" \
+        | _filter_json "${_filter}"
+}
+
+close_issue() {
+    # Close an issue
+    #
+    # Usage:
+    #
+    #     close_issue someuser/somerepo 123
+    #
+    # Positional arguments
+    #
+    local repository="${1:?Repo name required}"
+    #   A GitHub repository
+    local number="${2:?Issue number required}"
+    #   Issue Number
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.id)\t\(.state)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # POST data may also be passed as keyword arguments:
+    #
+    # * `assignee`
+    # * `labels`
+    # * `milestone`
+
+    shift 2
+    _opts_filter "$@"
+
+    _format_json state="closed" "$@" \
+        | _post "/repos/${repository}/issues/${number}" method='PATCH' \
+        | _filter_json "${_filter}"
+}
+
+list_issues() {
+    # List issues for the authenticated user or repository
+    #
+    # Usage:
+    #
+    #     list_issues
+    #     list_issues someuser/somerepo
+    #     list_issues <any of the above> state=closed labels=foo,bar
+    #
+    # Positional arguments
+    #
+    # user or user/repository
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.number)\t\(.title)"'
+    #   A jq filter to apply to the return data.
+    #
+    # GitHub querystring arguments may also be passed as keyword arguments:
+    #
+    # * `assignee`
+    # * `creator`
+    # * `direction`
+    # * `labels`
+    # * `mentioned`
+    # * `milestone`
+    # * `per_page`
+    # * `since`
+    # * `sort`
+    # * `state`
+
+    local url
+    local qs
+
+    case $1 in
+        ('') url='/user/issues' ;;
+        (*=*) url='/user/issues' ;;
+        (*/*) url="/repos/${1}/issues"; shift 1 ;;
+    esac
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    _get "${url}${qs}" | _filter_json "$_filter"
+}
+
+user_issues() {
+    # List all issues across owned and member repositories for the authenticated user
+    #
+    # Usage:
+    #
+    #     user_issues
+    #     user_issues since=2015-60-11T00:09:00Z
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.repository.full_name)\t\(.number)\t\(.title)"'
+    #   A jq filter to apply to the return data.
+    #
+    # GitHub querystring arguments may also be passed as keyword arguments:
+    #
+    # * `direction`
+    # * `filter`
+    # * `labels`
+    # * `per_page`
+    # * `since`
+    # * `sort`
+    # * `state`
+
+    local qs
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    _get "/issues${qs}" | _filter_json "$_filter"
+}
+
+create_issue() {
+    # Create an issue
+    #
+    # Usage:
+    #
+    #     create_issue owner repo 'Issue title' body='Add multiline body
+    #     content here' labels="$(./ok.sh _format_json -a foo bar)"
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   A GitHub repository.
+    local repo="${2:?Repo name required.}"
+    #   A GitHub repository.
+    local title="${3:?Issue title required.}"
+    #   A GitHub repository.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.id)\t\(.number)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Additional issue fields may be passed as keyword arguments:
+    #
+    # * `body` (string)
+    # * `assignee` (string)
+    # * `milestone` (integer)
+    # * `labels` (array of strings)
+    # * `assignees` (array of strings)
+
+    shift 3
+
+    _opts_filter "$@"
+
+    _format_json title="$title" "$@" \
+        | _post "/repos/${owner}/${repo}/issues" \
+        | _filter_json "$_filter"
+}
+
+org_issues() {
+    # List all issues for a given organization for the authenticated user
+    #
+    # Usage:
+    #
+    #     org_issues someorg
+    #
+    # Positional arguments
+    #
+    local org="${1:?Organization name required.}"
+    #   Organization GitHub login or id.
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.number)\t\(.title)"'
+    #   A jq filter to apply to the return data.
+    #
+    # GitHub querystring arguments may also be passed as keyword arguments:
+    #
+    # * `direction`
+    # * `filter`
+    # * `labels`
+    # * `per_page`
+    # * `since`
+    # * `sort`
+    # * `state`
+
+    shift 1
+    local qs
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    _get "/orgs/${org}/issues${qs}" | _filter_json "$_filter"
+}
+
+list_my_orgs() {
+    # List your organizations
+    #
+    # Usage:
+    #
+    #     list_my_orgs
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.login)\t\(.id)"'
+    #   A jq filter to apply to the return data.
+
+    local qs
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    _get "/user/orgs" | _filter_json "$_filter"
+}
+
+list_orgs() {
+    # List all organizations
+    #
+    # Usage:
+    #
+    #     list_orgs
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.login)\t\(.id)"'
+    #   A jq filter to apply to the return data.
+
+    local qs
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+    _opts_qs "$@"
+
+    _get "/organizations" | _filter_json "$_filter"
+}
+
+labels() {
+    # List available labels for a repository
+    #
+    # Usage:
+    #
+    #     labels someuser/somerepo
+    #
+    # Positional arguments
+    #
+    local repo="$1"
+    #   A GitHub repository.
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.name)\t\(.color)"'
+    #   A jq filter to apply to the return data.
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+
+    _get "/repos/${repo}/labels" | _filter_json "$_filter"
+}
+
+add_label() {
+    # Add a label to a repository
+    #
+    # Usage:
+    #
+    #     add_label someuser/somerepo LabelName color
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   A GitHub repository.
+    local label="${2:?Label name required.}"
+    #   A new label.
+    local color="${3:?Hex color required.}"
+    #   A color, in hex, without the leading `#`.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.name)\t\(.color)"'
+    #   A jq filter to apply to the return data.
+
+    _opts_filter "$@"
+
+    _format_json name="$label" color="$color" \
+        | _post "/repos/${repo}/labels" \
+        | _filter_json "$_filter"
+}
+
+update_label() {
+    # Update a label
+    #
+    # Usage:
+    #
+    #     update_label someuser/somerepo OldLabelName \
+    #         label=NewLabel color=newcolor
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   A GitHub repository.
+    local label="${2:?Label name required.}"
+    #   The name of the label which will be updated
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.name)\t\(.color)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Label options may also be passed as keyword arguments, these will update
+    # the existing values:
+    #
+    # * `color`
+    # * `name`
+
+    shift 2
+
+    _opts_filter "$@"
+
+    _format_json "$@" \
+        | _post "/repos/${repo}/labels/${label}" method='PATCH' \
+        | _filter_json "$_filter"
+}
+
+add_team_repo() {
+    # Add a team repository
+    #
+    # Usage:
+    #
+    #     add_team_repo team_id organization repository_name permission
+    #
+    # Positional arguments
+    #
+    local team_id="${1:?Team id required.}"
+    #   Team id to add repository to
+    local organization="${2:?Organization required.}"
+    #   Organization to add repository to
+    local repository_name="${3:?Repository name required.}"
+    #   Repository name to add
+    local permission="${4:?Permission required.}"
+    #   Permission to grant: pull, push, admin
+    #
+    local url="/teams/${team_id}/repos/${organization}/${repository_name}"
+
+    export OK_SH_ACCEPT="application/vnd.github.ironman-preview+json"
+
+    _format_json "name=${name}" "permission=${permission}" | _post "$url" method='PUT' | _filter_json "${_filter}"
+}
+
+list_pulls() {
+    # Lists the pull requests for a repository
+    #
+    # Usage:
+    #
+    #     list_pulls user repo
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner required.}"
+    #   A GitHub owner.
+    local repo="${2:?Repo name required.}"
+    #   A GitHub repository.
+    #
+    # Keyword arguments
+    #
+    local _follow_next
+    #   Automatically look for a 'Links' header and follow any 'next' URLs.
+    local _follow_next_limit
+    #   Maximum number of 'next' URLs to follow before stopping.
+    local _filter='.[] | "\(.number)\t\(.user.login)\t\(.head.repo.clone_url)\t\(.head.ref)"'
+    #   A jq filter to apply to the return data.
+
+    _opts_pagination "$@"
+    _opts_filter "$@"
+
+    _get "/repos/${owner}/${repo}/pulls" | _filter_json "$_filter"
+}
+
+create_pull_request() {
+    # Create a pull request for a repository
+    #
+    # Usage:
+    #
+    #     create_pull_request someuser/somerepo title head base
+    #
+    #     create_pull_request someuser/somerepo title head base body='Description here.'
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   A GitHub repository.
+    local title="${2:?Pull request title required.}"
+    #   A title.
+    local head="${3:?Pull request head required.}"
+    #   A head.
+    local base="${4:?Pull request base required.}"
+    #   A base.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.number)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Pull request options may also be passed as keyword arguments:
+    #
+    # * `body`
+    # * `maintainer_can_modify`
+
+    shift 4
+
+    _opts_filter "$@"
+
+    _format_json title="$title" head="$head" base="$base" "$@" \
+        | _post "/repos/${repo}/pulls" \
+        | _filter_json "$_filter"
+}
+
+update_pull_request() {
+    # Update a pull request for a repository
+    #
+    # Usage:
+    #
+    #     update_pull_request someuser/somerepo number title='New title' body='New body'
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   A GitHub repository.
+    local number="${2:?Pull request number required.}"
+    #   A pull request number.
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.number)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+    # Pull request options may also be passed as keyword arguments:
+    #
+    # * `base`
+    # * `body`
+    # * `maintainer_can_modify`
+    # * `state` (either open or closed)
+    # * `title`
+
+    shift 2
+
+    _opts_filter "$@"
+
+    _format_json "$@" \
+        | _post "/repos/${repo}/pulls/${number}" method='PATCH' \
+        | _filter_json "$_filter"
+}
+
+transfer_repo() {
+    # Transfer a repository to a user or organization
+    #
+    # Usage:
+    #
+    #     transfer_repo owner repo new_owner
+    #     transfer_repo owner repo new_owner team_ids='[ 12, 345 ]'
+    #
+    # Positional arguments
+    #
+    local owner="${1:?Owner name required.}"
+    #   Name of the current owner
+    #
+    local repo="${2:?Repo name required.}"
+    #   Name of the current repo
+    #
+    local new_owner="${3:?New owner name required.}"
+    #   Name of the new owner
+    #
+    # Keyword arguments
+    #
+    local _filter='"\(.name)"'
+    #   A jq filter to apply to the return data.
+    #
+    # POST data may also be passed as keyword arguments:
+    #
+    # * `team_ids`
+
+    shift 3
+
+    _opts_filter "$@"
+
+    export OK_SH_ACCEPT='application/vnd.github.nightshade-preview+json'
+    _format_json "new_owner=${new_owner}" "$@" | _post "/repos/${owner}/${repo}/transfer" | _filter_json "${_filter}"
+}
+
+archive_repo() {
+    # Archive a repo
+    #
+    # Usage:
+    #
+    #     archive_repo owner/repo
+    #
+    # Positional arguments
+    #
+    local repo="${1:?Repo name required.}"
+    #   A GitHub repository.
+    #
+    local _filter='"\(.name)\t\(.html_url)"'
+    #   A jq filter to apply to the return data.
+    #
+
+    shift 1
+
+    _opts_filter "$@"
+
+    _format_json "archived=true" \
+        | _post "/repos/${repo}" method='PATCH' \
+        | _filter_json "$_filter"
+}
+
+__main "$@"
index 68b31422b34f154fe6aefcb6f4287e2432620fe4..144aab084c0e532219f08f09305e773cbeb0338f 100644 (file)
--- a/build.xml
+++ b/build.xml
        <property name="project.src.dir" value="${basedir}/src/main/java" />    \r
        <property name="project.resources.dir" value="${basedir}/src/main/resources" /> \r
        <property name="project.distrib.dir" value="${basedir}/src/main/distrib" />\r
-       \r
+\r
+       <!-- Tools -->\r
+       <property name="octokit" location="${basedir}/.github/ok.sh" />\r
+       <property name="relnoawk" location="${basedir}/src/site/templates/ghreleasenotes.awk" />\r
+\r
+\r
        <!--\r
                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
                Initialize Moxie and setup build properties\r
@@ -50,6 +55,8 @@
                <property name="authority.zipfile" value="authority-${project.version}.zip" />\r
                <property name="gbapi.zipfile" value="gbapi-${project.version}.zip" />\r
                <property name="maven.directory" value="${basedir}/../gitblit-maven" />\r
+               <property name="releaselog" value="${basedir}/releases.moxie" />\r
+\r
 \r
                <!-- Download links -->\r
                <property name="gc.url" value="http://dl.bintray.com/gitblit/releases/" />\r
                </fileset>\r
                </delete>\r
        </target>\r
-               \r
-               \r
-       <!-- \r
+\r
+\r
+       <!--\r
                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
                Build the Gitblit Website\r
                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
                \r
                <echo>Building Gitblit Website ${project.version}</echo>\r
 \r
-               <property name="releaselog" value="${basedir}/releases.moxie" />\r
-\r
                <!-- Build Site -->\r
                <mx:doc googleplusid="114464678392593421684" googleanalyticsid="UA-24377072-1"\r
                        googlePlusOne="true" minify="true" customless="custom.less">\r
                <!-- Build gh-pages branch -->\r
                <mx:ghpages repositorydir="${basedir}" obliterate="true" />\r
        </target>\r
-       \r
+\r
 \r
        <!-- \r
                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
 \r
        </target>\r
 \r
-       \r
+\r
+       <!--\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+               Publish binaries to GitHub release\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+       -->\r
+       <target name="releaseBinaries" depends="prepare" description="Publish the Gitblit binaries to a GitHub release">\r
+               <property name="project.tag" value="v${project.version}" />\r
+\r
+               <ghReleaseDraft\r
+                       releaselog="${releaselog}"/>\r
+\r
+               <echo>Uploading Gitblit ${project.version} binaries</echo>\r
+\r
+               <!-- Upload Gitblit GO Windows ZIP file -->\r
+               <githubUpload\r
+                       source="${project.targetDirectory}/${distribution.zipfile}"\r
+                       target="gitblit-${project.version}.zip" />\r
+\r
+               <!-- Upload Gitblit GO Linux/Unix tar.gz file -->\r
+               <githubUpload\r
+                       source="${project.targetDirectory}/${distribution.tgzfile}"\r
+                       target="gitblit-${project.version}.tar.gz" />\r
+\r
+               <!-- Upload Gitblit WAR file -->\r
+               <githubUpload\r
+                       source="${project.targetDirectory}/${distribution.warfile}"\r
+                       target="gitblit-${project.version}.war" />\r
+\r
+               <!-- Upload Gitblit FedClient -->\r
+               <githubUpload\r
+                       source="${project.targetDirectory}/${fedclient.zipfile}"\r
+                       target="fedclient-${project.version}.zip" />\r
+\r
+               <!-- Upload Gitblit Manager -->\r
+               <githubUpload\r
+                       source="${project.targetDirectory}/${manager.zipfile}"\r
+                       target="manager-${project.version}.zip" />\r
+\r
+               <!-- Upload Gitblit API Library -->\r
+               <githubUpload\r
+                       source="${project.targetDirectory}/${gbapi.zipfile}"\r
+                       target="gbapi-${project.version}.zip" />\r
+\r
+\r
+       </target>\r
+\r
+\r
+       <!--\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+               Publish GH release draft\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+       -->\r
+       <target name="publishRelease" depends="prepare" description="Publish the GitHub release draft" >\r
+               <property name="project.tag" value="v${project.version}" />\r
+\r
+               <echo>Publishing Gitblit ${project.version} release draft on GitHub</echo>\r
+\r
+               <ghGetReleaseId\r
+                       releaseVersion="${project.version}"/>\r
+               <exec executable="bash" logError="true" >\r
+                       <arg value="-c" />\r
+                       <arg value="${octokit} -q edit_release fzs gitblit ${ghrelease.id} tag_name='${project.tag}'"></arg>\r
+               </exec>\r
+               <ghPublishReleaseDraft\r
+                       releaseid="${ghrelease.id}"/>\r
+\r
+       </target>\r
+\r
+\r
        <!--\r
                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ \r
                Publish site to site hosting service\r
                        </copy>\r
       </sequential>\r
        </macrodef>\r
-       \r
+\r
        <!--\r
                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ \r
                Macro to upload binaries to Bintray\r
                </sequential>\r
        </macrodef>\r
 \r
+       <!--\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+               Macro to create release draft on GitHub\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+       -->\r
+       <macrodef name="ghReleaseDraft">\r
+               <attribute name="releaselog" />\r
+               <sequential>\r
+                       <echo>Creating release ${project.tag} draft on GitHub</echo>\r
+                       <exec executable="bash" logError="true" failonerror="true" outputproperty="ghrelease.id">\r
+                               <arg value="-c" />\r
+                               <arg value="${octokit} create_release fzs gitblit ${project.tag} name=${project.version} draft=true | cut -f2"></arg>\r
+                       </exec>\r
+                       <exec executable="bash" logError="true" failonerror="true" outputproperty="ghrelease.upldUrl">\r
+                               <arg value="-c" />\r
+                               <arg value="${octokit} release fzs gitblit ${ghrelease.id} _filter=.upload_url | sed 's/{.*$/?name=/'"></arg>\r
+                       </exec>\r
+                       <exec executable="bash" logError="true" failonerror="true" outputproperty="ghrelease.notes">\r
+                               <arg value="-c" />\r
+                               <arg value="cat @{releaselog} | awk -f ${relnoawk} protect=true"></arg>\r
+                       </exec>\r
+                       <exec executable="bash" logError="true" >\r
+                               <arg value="-c" />\r
+                               <arg value="${octokit} -q edit_release fzs gitblit ${ghrelease.id} body='${ghrelease.notes}'"></arg>\r
+                       </exec>\r
+               </sequential>\r
+       </macrodef>\r
+\r
+       <!--\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+               Macro to upload binaries to GitHub\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+       -->\r
+       <macrodef name="githubUpload">\r
+               <attribute name="source"/>\r
+               <attribute name="target"/>\r
+               <sequential>\r
+                       <echo>uploading @{source} to GitHub release ${ghrelease.id}</echo>\r
+                       <exec executable="bash" logError="true" failonerror="true" >\r
+                               <arg value="-c" />\r
+                               <arg value="${octokit} upload_asset ${ghrelease.upldUrl}@{target} @{source}"></arg>\r
+                       </exec>\r
+               </sequential>\r
+       </macrodef>\r
+\r
+       <!--\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+               Macro to publish release draft on GitHub\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+       -->\r
+       <macrodef name="ghPublishReleaseDraft">\r
+               <attribute name="releaseid"/>\r
+               <sequential>\r
+                       <echo>publishing GitHub release draft @{releaseid}</echo>\r
+                       <exec executable="bash" logError="true" >\r
+                               <arg value="-c" />\r
+                               <arg value="${octokit} -q edit_release fzs gitblit @{releaseid} draft=false"></arg>\r
+                       </exec>\r
+               </sequential>\r
+       </macrodef>\r
+\r
+       <!--\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+               Macro to publish release draft on GitHub\r
+               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
+       -->\r
+       <macrodef name="ghGetReleaseId">\r
+               <attribute name="releaseVersion"/>\r
+               <sequential>\r
+                       <exec executable="bash" logError="true" failonerror="true" outputproperty="ghrelease.id">\r
+                               <arg value="-c" />\r
+                               <arg value="${octokit} list_releases fzs gitblit _filter='.[] | &quot;\(.name)\t\(.tag_name)\t\(.id)&quot;' | grep @{releaseVersion} | cut -f3"></arg>\r
+                       </exec>\r
+               </sequential>\r
+       </macrodef>\r
+\r
        <!--\r
                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\r
                Install Gitblit JAR for usage as Maven module\r
diff --git a/src/site/templates/ghreleasenotes.awk b/src/site/templates/ghreleasenotes.awk
new file mode 100755 (executable)
index 0000000..f447955
--- /dev/null
@@ -0,0 +1,66 @@
+#! /usr/bin/env awk -f
+
+BEGIN { on=0 ; skip=1 ; block=0 ; section=""}
+
+/^[[:blank:]]*id:/ { relId = $NF }
+
+/r[0-9]+: *{/ { on=1 ; next }
+/^[[:blank:]]*}[[:blank:]]*$/ { if (on) {
+                                       print "[Full release notes on gitblit.com](http://gitblit.com/releases.html#" relId ")"
+                                       exit 0
+                                }
+                              }
+
+
+on==1 && /^[[:blank:]]*[[:alnum:]]+:[[:blank:]]*(''|~)?$/ {
+                                                                                                                       if (!block) {
+                                                                                                                               skip=1
+                                                                                                                               if (section == "fixes:" || section == "changes:" || section == "additions:") { printf "\n</details>\n"}
+                                                                                                                               if (section != "") print ""
+                                                                                                                               if (section == "note:") print "----------"
+                                                                                                                               section = ""
+                                                                                                                               if ($NF == "~") next
+                                                                                                                       }
+                                                                                                                       else {
+                                                                                                                               printSection()
+                                                                                                                               next
+                                                                                                                       }
+                                                                                                                       if ($NF == "''") {
+                                                                                                                               block = !block
+                                                                                                                       }
+                                                                                                               }
+on==1 && /^[[:blank:]]*note:/ { skip=0 ; section=$1; print "### Update Note" ; printSingleLineSection() ; next }
+on==1 && /^[[:blank:]]*text:/ { skip=0 ; section=$1; printf "\n\n"; printSingleLineSection() ; next }
+on==1 && /^[[:blank:]]*security:/ { skip=0 ; section=$1; print "### *Security*" ; next }
+on==1 && /^[[:blank:]]*fixes:/ { skip=0 ; section=$1; printf "<details><summary>Fixes</summary>\n\n### Fixes\n" ; next}
+on==1 && /^[[:blank:]]*changes:/ { skip=0 ; section=$1; printf "<details><summary>Changes</summary>\n\n### Changes\n" ; next}
+on==1 && /^[[:blank:]]*additions:/ { skip=0 ; section=$1; printf "<details><summary>Additions</summary>\n\n### Additions\n" ; next}
+
+on==1 {
+       if ($1 == "''") {
+               block = !block
+               next
+       }
+       if ((block || !skip))  {
+               printSection()
+       }
+ }
+
+function printSingleLineSection()
+{
+       if (NF>1 && $2 != "''" && $2 != "~") {
+               if (protect) gsub(/'/, "'\\''")
+               for (i=2; i<= NF; i++) printf "%s ", $i
+               print ""
+       }
+}
+
+function printSection()
+{
+       if (section != "text:") sub(/[[:blank:]]+/, "")
+       gsub(/pr-/, "PR #")
+       gsub(/issue-/, "issue #")
+       gsub(/commit-/, "commit ")
+       if (protect) gsub(/'/, "'\\''")
+       print $0        
+}
\ No newline at end of file