From e0e23d872c625c8b5494c37b0e5b90475a3cee78 Mon Sep 17 00:00:00 2001 From: Florian Zschocke Date: Sun, 26 Jan 2020 16:47:44 +0100 Subject: [PATCH] Add deployment of a release to GitHub 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 | 2560 +++++++++++++++++++++++++ build.xml | 168 +- src/site/templates/ghreleasenotes.awk | 66 + 3 files changed, 2785 insertions(+), 9 deletions(-) create mode 100755 .github/ok.sh create mode 100755 src/site/templates/ghreleasenotes.awk diff --git a/.github/ok.sh b/.github/ok.sh new file mode 100755 index 00000000..6ac429d5 --- /dev/null +++ b/.github/ok.sh @@ -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 (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 +# password +# +# machine uploads.github.com +# login +# password +# +# 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} [] (command [, ...])` + # + # ${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/' + # ``` + # + # 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) + 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 + # + # 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 \ + # _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:// /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 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 "$@" diff --git a/build.xml b/build.xml index 68b31422..144aab08 100644 --- a/build.xml +++ b/build.xml @@ -27,7 +27,12 @@ - + + + + + + @@ -480,9 +487,9 @@ - - - @@ -688,7 +693,7 @@ - + + + + + + + Uploading Gitblit ${project.version} binaries + + + + + + + + + + + + + + + + + + + + + + + + + + + + Publishing Gitblit ${project.version} release draft on GitHub + + + + + + + + + + + + + + + Creating release ${project.tag} draft on GitHub + + + + + + + + + + + + + + + + + + + + + + + + + uploading @{source} to GitHub release ${ghrelease.id} + + + + + + + + + + + + publishing GitHub release draft @{releaseid} + + + + + + + + + + + + + + + + + +