#!/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. # shellcheck disable=SC2039,SC2220 NAME=$(basename "$0") export NAME export VERSION='0.7.0' 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 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}" || 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 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}}" # Headers are case insensitive hdr="$(printf '%s' "$hdr" | awk '{print toupper($0)}')" # 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_" toupper($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}'." arg="$(printf '%s' "$arg" | awk '{print toupper($0)}')" 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. local qs shift 1 _opts_filter "$@" _opts_qs "$@" _get "/orgs/${org}/members${qs}" | _filter_json "${_filter}" } org_collaborators() { # List organization outside collaborators # # Usage: # # org_collaborators 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. local qs shift 1 _opts_filter "$@" _opts_qs "$@" _get "/orgs/${org}/outside_collaborators${qs}" | _filter_json "${_filter}" } org_auditlog() { # Interact with the Github Audit Log # # Usage: # # org_auditlog org # # Positional arguments # local org="${1:?Org name required.}" # Organization GitHub login or id. # # Keyword arguments # local _filter='.[] | "\(.actor)\t\(.action)"' # A jq filter to apply to the return data. local qs shift 1 _opts_filter "$@" _opts_qs "$@" _get "/orgs/${org}/audit-log${qs}" | _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` # User is optional; is this a keyword arg? case "$user" in *=*) user='' ;; esac if [ -n "$user" ]; then shift 1; fi 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_commits() { # List commits of a specified repository. # ( https://developer.github.com/v3/repos/commits/#list-commits-on-a-repository ) # # Usage: # # list_commits 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 # A jq filter to apply to the return data. # local _filter='.[] | "\(.sha) \(.author.login) \(.commit.author.email) \(.committer.login) \(.commit.committer.email)"' # Querystring arguments may also be passed as keyword arguments: # # * `sha` # * `path` # * `author` # * `since` Only commits after this date will be returned. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ. # * `until` local qs _opts_filter "$@" _opts_qs "$@" url="/repos/${user}/${repo}/commits" _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 export OK_SH_ACCEPT="application/vnd.github.nebula-preview+json" _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" } delete_asset() { # Delete a release asset # # https://docs.github.com/en/rest/reference/releases#delete-a-release-asset # # Usage: # # delete_asset user repo 51955388 # # Example of deleting release assets: # # ok.sh release_assets \ # _filter='.[] | .id' \ # | xargs -L1 ./ok.sh delete_asset "$myuser" "$myrepo" # # Example of the multi-step process for grabbing the release ID for # a specific version, then grabbing the release asset IDs, and then # deleting 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='.[] | .id' \ # | xargs -L1 ./ok.sh -y delete_asset "$myuser" "$myrepo" # # Positional arguments # local owner="${1:?Owner name required.}" # A GitHub user or organization. local repo="${2:?Repo name required.}" # A GitHub repository. local asset_id="${3:?Release asset ID required.}" # The unique ID of the release asset; see release_assets. shift 3 local confirm _get_confirm 'This will permanently delete a release asset. Continue?' [ "$confirm" -eq 1 ] || exit 0 _delete "/repos/${owner}/${repo}/releases/assets/${asset_id}" exit $? } # ### 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" } list_issue_comments() { # List comments of a specified issue. # ( https://developer.github.com/v3/issues/comments/#list-issue-comments ) # # Usage: # # list_issue_comments someuser/somerepo number # # Positional arguments # # GitHub owner login or id for which to list branches # Name of the repo for which to list branches # Issue number # local repo="${1:?Repo name required.}" local number="${2:?Issue number is required.}" shift 2 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='.[] | "\(.body)"' # A jq filter to apply to the return data. _opts_pagination "$@" # A jq filter to apply to the return data. # # Querystring arguments may also be passed as keyword arguments: # # * `direction` # * `sort` # * `since` local qs _opts_filter "$@" _opts_qs "$@" url="/repos/${repo}/issues/${number}/comments" _get "${url}${qs}" | _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}" } list_commit_comments() { # List comments of a specified commit. # ( https://developer.github.com/v3/repos/comments/#list-commit-comments ) # # Usage: # # list_commit_comments someuser/somerepo sha # # Positional arguments # # GitHub owner login or id for which to list branches # Name of the repo for which to list branches # Commit SHA # local repo="${1:?Repo name required.}" local sha="${2:?Commit SHA is required.}" shift 2 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='.[] | "\(.body)"' # A jq filter to apply to the return data. _opts_pagination "$@" # A jq filter to apply to the return data. # # Querystring arguments may also be passed as keyword arguments: # # * `direction` # * `sort` # * `since` local qs _opts_filter "$@" _opts_qs "$@" url="/repos/${repo}/commits/${sha}/comments" _get "${url}${qs}" | _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_starred() { # List starred repositories # # Usage: # # list_starred # list_starred user # # Positional arguments # local user="$1" # Optional GitHub user login or id for which to list the starred 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` # User is optional; is this a keyword arg? case "$user" in *=*) user='' ;; esac if [ -n "$user" ]; then shift 1; fi local qs _opts_filter "$@" _opts_qs "$@" if [ -n "$user" ] ; then url="/users/${user}/starred" else url='/user/starred' fi _get "${url}${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" } list_users() { # List all users # # Usage: # # list_users # # 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 "/users" | _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 "$@"